diff --git a/.env.example b/.env.example index 8736e850..654525ad 100644 --- a/.env.example +++ b/.env.example @@ -12,9 +12,6 @@ NETWORK= NETUID= LOG_ID_PREFIX= -# Pyth API key for Lazer (Pro) backend. If set, the miner will use Pyth Lazer instead of Hermes for assets with a Lazer feed. -PYTH_API_KEY= - # Bigtable storage backend for miner predictions. Only required when the # validator is launched with `--storage.backend bigtable`. Two tables are # used so that retention is enforced by per-table GC policy: one for the diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36697e06..3c97573c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,4 +41,8 @@ jobs: uv run black --check . - name: Run unit tests + env: + # api.binance.com geo-blocks US IPs (HTTP 451) and GitHub + # runners are US-hosted; use Binance's public market-data host. + BINANCE_API_HOST: https://data-api.binance.vision run: uv run pytest ./tests/ diff --git a/README.md b/README.md index c1292b9c..a18a0540 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ The asset set has grown over time: | 2026-01 | Added tokenized equities SPYX, NVDAX, TSLAX, AAPLX, GOOGLX to the `24h` competition. | | 2026-03 | Added XRP, HYPE, WTIOIL to the `24h` competition; added HYPE to the `1h` competition. | | 2026-06 | Changed the split of the competitions from 2 (1h/24h) to 3 (Crypto 1h, Crypto 24h, Commodities/Equities 24h). Added XRP to the 1h time length, removed XAU from the 1h time length, added SPCX to the 24h time length | +| 2026-07 | Migrated price feeds off Pyth: BTC/ETH/SOL/XRP to Binance spot, HYPE to Hyperliquid spot, equities/commodities to Hyperliquid perps. Replaced SPYX (tokenized SPY) with SP500 (S&P 500 index) in the Commodities/Equities 24h competition. | Whereas other subnets ask miners to predict single values for future prices, we’re interested in the miners correctly quantifying uncertainty. We want their price paths to represent their view of the probability distribution of the future price, and we want their paths to encapsulate realistic price dynamics, such as volatility clustering and skewed fat tailed price change distributions. As the network matures, modelling the correlations between asset prices will be essential. @@ -133,7 +134,7 @@ The three competitions differ on the parameters below: | Time increment ($\Delta t$) | 1 min | 5 min | 5 min | | Time horizon ($T$) | 1 h | 24 h | 24 h | | Simulations ($N_{\text{sim}}$) | 1000 | 1000 | 1000 | -| Assets | BTC, ETH, SOL, XRP, HYPE | BTC, ETH, SOL, XRP, HYPE | XAU, SPYX, NVDAX, GOOGLX, TSLAX, AAPLX, WTIOIL, SPCX | +| Assets | BTC, ETH, SOL, XRP, HYPE | BTC, ETH, SOL, XRP, HYPE | XAU, SP500, NVDAX, GOOGLX, TSLAX, AAPLX, WTIOIL, SPCX | | Rolling-average window | 5 days | 10 days | 10 days | | Softmax temperature ($\beta$) | 0.3 (sharper allocation) | 0.15 | 0.15 | @@ -141,7 +142,7 @@ The validator requests are sent to miner following this schedule: | Cycle | Low frequency (LF) | High frequency (HF) | | ------ | ------------------------------------------------------------------------------ | ------------------------ | -| Assets | BTC, ETH, SOL, XRP, HYPE, XAU, SPYX, NVDAX, GOOGLX, TSLAX, AAPLX, WTIOIL, SPCX | BTC, ETH, SOL, XRP, HYPE | +| Assets | BTC, ETH, SOL, XRP, HYPE, XAU, SP500, NVDAX, GOOGLX, TSLAX, AAPLX, WTIOIL, SPCX | BTC, ETH, SOL, XRP, HYPE | Validators cycle through the assets, sending out prediction requests at regular intervals. The miner has until the start time to return ($N_{\text{sim}}$) paths, each containing price predictions at times given by: @@ -153,7 +154,7 @@ where: - $N = \dfrac{T}{\Delta t}$ is the total number of increments. -We recommend the miner sends a request to the Pyth Oracle to acquire the price of the asset at the start_time. +We recommend the miner acquires the price of the asset at the start_time from the same feed the validator scores against: Binance spot for BTC/ETH/SOL/XRP, Hyperliquid for everything else (see `synth/miner/price_simulation.py` for the reference implementation and the asset maps). If they fail to return predictions by the start_time or the predictions are in the wrong format, the submission is marked invalid and assigned the 90th-percentile score during the per-prompt CRPS transformation (see [§1.4](#14-calculation-of-leaderboard-score)). @@ -165,7 +166,7 @@ The assets and their weights for the rolling average are as follows: | ETH | 0.7064366394033871 | | XAU | 1.7370922597118699 | | SOL | 0.6310037175639559 | -| SPYX | 3.437935601155441 | +| SP500 | 3.437935601155441 | | NVDAX | 1.6028217601617174 | | TSLAX | 1.6068755936957768 | | AAPLX | 2.0916380815843123 | @@ -210,7 +211,7 @@ To comprehensively assess the miners' forecasts, the CRPS is applied to sets of For each time increment: - **Predicted Price Changes**: The miners' ensemble forecasts are used to compute predicted price changes in basis points over the specified intervals -- **Observed Price Changes**: The real asset prices are used to calculate the observed price changes in basis points over the same intervals. We recommend the validators collect and store the prices by sending requests to the Pyth oracle at each time increment, to be used at the end of the time horizon. +- **Observed Price Changes**: The real asset prices are used to calculate the observed price changes in basis points over the same intervals. The validator scores against 1-minute candle close prices — Binance spot for BTC/ETH/SOL/XRP, Hyperliquid spot for HYPE, Hyperliquid perps for equities/commodities (see the asset maps in `synth/validator/price_data_provider.py`). - **CRPS Calculation**: The CRPS is calculated for each increment by comparing the ensemble of predicted changes in basis points to the observed price change. The final score for a miner for a single checking prompt is the sum of these CRPS values over all the time increments. diff --git a/scripts/pyth-lazer-listing.py b/scripts/pyth-lazer-listing.py deleted file mode 100644 index 775248bb..00000000 --- a/scripts/pyth-lazer-listing.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -One-shot discovery script for the Pyth Pro migration. - -Goals: -1. List the Pyth Lazer feed catalog and map our existing 32-byte hermes IDs - to the new u32 `pyth_lazer_id` values. -2. Determine which symbol string format the Pyth Pro Router history endpoint - accepts ("BTC/USD" vs "Crypto.BTC/USD"), so we know what to put in the - validator's PYTH_SYMBOL_MAP after the migration. - -Run from the synth-subnet repo root: - python verify/pyth-lazer-listing.py - -Requires PYTH_API_KEY in .env (free key from pythdata.app -> Pyth Terminal). -""" - -import os -import sys -import time -from typing import Optional - -import requests -from dotenv import load_dotenv - -# Pyth Pro Router (TradingView-shaped, PUBLIC — no auth on /v1/symbols or -# /v1/{channel}/history; same `symbol` strings as the legacy Benchmarks -# API). The channel in the path matters: `real_time` returns 404 for feeds -# whose `min_channel > real_time` (stocks/metals/oil); `fixed_rate@200ms` -# works for every feed and is what production code uses. -ROUTER_SYMBOLS_URL = "https://pyth.dourolabs.app/v1/symbols" -ROUTER_HISTORY_URL = "https://pyth.dourolabs.app/v1/fixed_rate@200ms/history" - -# Pyth Lazer (low-latency POST endpoints, requires Bearer access_token). -LAZER_LATEST_PRICE_URL = "https://pyth-lazer.dourolabs.app/v1/latest_price" - -# 32-byte hermes hex IDs that synth-subnet currently uses (synth/miner/price_simulation.py). -HERMES_ID_MAP = { - "BTC": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", - "ETH": "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", - "XAU": "44465e17d2e9d390e70c999d5a11fda4f092847fcd2e3e5aa089d96c98a30e67", - "SOL": "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", - "SPYX": "2817b78438c769357182c04346fddaad1178c82f4048828fe0997c3c64624e14", - "NVDAX": "4244d07890e4610f46bbde67de8f43a4bf8b569eebe904f136b469f148503b7f", - "TSLAX": "47a156470288850a440df3a6ce85a55917b813a19bb5b31128a33a986566a362", - "AAPLX": "978e6cc68a119ce066aa830017318563a9ed04ec3a0a6439010fc11296a58675", - "GOOGLX": "b911b0329028cd0283e4259c33809d62942bd2716a58084e5f31d64c00b5424e", - "XRP": "ec5d399846a9209f3fe5881d70aae9268c94339ff9817e8d18ff19fa05eea1c8", - "HYPE": "4279e31cc369bbcc2faf022b382b080e32a8e689ff20fbc530d2a603eb6cd98b", - # WTIOIL's legacy hermes_id is missing from the new Lazer catalog (the - # crypto-style WTI feed was deprecated). The closest spot replacement is - # `Commodities.USOILSPOT` (pyth_lazer_id=657, "WTI LIGHT SWEET CRUDE OIL - # CFD"). We map WTIOIL to that feed's hermes_id so the discovery probe - # picks it up. - "WTIOIL": "925ca92ff005ae943c158e3563f59698ce7e75c5a8c8dd43303a0a154887b3e6", -} - -# Assets with no hermes_id in the Lazer catalog — Pyth-Pro / Lazer-only feeds. -# SPCX is `Pyth.HL.SPCX/USDC` (pyth_lazer_id=99934, exponent=-4): the legacy -# hermes/Benchmarks endpoint returns "Symbol ... doesn't exist" for it, so the -# validator can only score SPCX with PYTH_BACKEND=pro. The hermes_id bridge -# can't resolve these, so we match them by symbol string instead. -LAZER_SYMBOL_MAP = { - "SPCX": "Pyth.HL.SPCX/USDC", -} - - -def lazer_auth_headers() -> dict: - key = os.environ.get("PYTH_API_KEY") - if not key: - print( - "ERROR: PYTH_API_KEY not set in environment / .env", - file=sys.stderr, - ) - sys.exit(1) - return {"Authorization": f"Bearer {key}"} - - -def normalize_hex(value: Optional[str]) -> Optional[str]: - if not value: - return None - v = value.lower().strip() - if v.startswith("0x"): - v = v[2:] - return v - - -def fetch_router_symbols() -> list: - """Router /v1/symbols is public; carries the hermes_id <-> pyth_lazer_id - bridge field that we need to build the migration mapping.""" - resp = requests.get(ROUTER_SYMBOLS_URL, timeout=30) - if resp.status_code != 200: - print( - f"ERROR: GET {ROUTER_SYMBOLS_URL} -> {resp.status_code}", - file=sys.stderr, - ) - print(resp.text[:500], file=sys.stderr) - sys.exit(2) - return resp.json() - - -def map_assets_to_lazer(symbols: list) -> dict: - by_hermes = {} - for entry in symbols: - hermes_id = normalize_hex(entry.get("hermes_id")) - if hermes_id: - by_hermes[hermes_id] = entry - - by_symbol = {entry.get("symbol"): entry for entry in symbols} - - mapping = {} - for asset, hermes_id in HERMES_ID_MAP.items(): - match = by_hermes.get(normalize_hex(hermes_id)) - mapping[asset] = match - # Lazer-only feeds have no hermes_id; resolve them by symbol instead. - for asset, symbol in LAZER_SYMBOL_MAP.items(): - mapping[asset] = by_symbol.get(symbol) - return mapping - - -def probe_history_symbol(symbol: str) -> tuple: - """Probe the Pro Router history endpoint with a given symbol string. - - Public endpoint, no auth. Returns (status_code, s, num_candles, errmsg). - """ - now = int(time.time()) - params = { - "symbol": symbol, - "resolution": 1, - "from": now - 600, - "to": now, - } - resp = requests.get(ROUTER_HISTORY_URL, params=params, timeout=30) - body = {} - try: - body = resp.json() - except Exception: - pass - s = body.get("s") - t_arr = body.get("t") or [] - errmsg = body.get("errmsg") - return resp.status_code, s, len(t_arr), errmsg - - -def probe_lazer_latest_price( - pyth_lazer_id: int, channel: str = "fixed_rate@200ms" -) -> tuple: - """Probe Lazer POST /v1/latest_price with one feed. - - Returns (status_code, parsed_price_str_or_None, exponent_or_None, error). - """ - payload = { - "channel": channel, - "priceFeedIds": [pyth_lazer_id], - "properties": ["price", "exponent", "publisherCount"], - "formats": [], - "parsed": True, - "jsonBinaryEncoding": "hex", - } - resp = requests.post( - LAZER_LATEST_PRICE_URL, - json=payload, - headers=lazer_auth_headers(), - timeout=30, - ) - if resp.status_code != 200: - return resp.status_code, None, None, resp.text[:200] - body = resp.json() - parsed = body.get("parsed") or {} - feeds = parsed.get("priceFeeds") or [] - if not feeds: - return resp.status_code, None, None, "no priceFeeds in parsed" - feed = feeds[0] - return resp.status_code, feed.get("price"), feed.get("exponent"), None - - -def main() -> None: - load_dotenv() - - print("=" * 70) - print("Pyth Pro Router symbols catalog") - print("=" * 70) - symbols = fetch_router_symbols() - print(f"Fetched {len(symbols)} symbol entries\n") - - print("=" * 70) - print("Mapping our 11 assets by hermes_id") - print("=" * 70) - mapping = map_assets_to_lazer(symbols) - print(f"{'ASSET':<8}{'LAZER_ID':<12}{'SYMBOL':<24}{'EXPONENT':<10}NAME") - print("-" * 70) - for asset, entry in mapping.items(): - if entry is None: - print(f"{asset:<8}MISSING") - continue - print( - f"{asset:<8}" - f"{str(entry.get('pyth_lazer_id')):<12}" - f"{str(entry.get('symbol')):<24}" - f"{str(entry.get('exponent')):<10}" - f"{entry.get('name')}" - ) - print() - - print("=" * 70) - print("Pro Router /history symbol-format probe (BTC)") - print("=" * 70) - for candidate in ("BTC/USD", "Crypto.BTC/USD"): - code, status, n, err = probe_history_symbol(candidate) - print( - f"symbol={candidate!r:<22} -> http={code} s={status!r} " - f"candles={n} errmsg={err!r}" - ) - - print() - print("=" * 70) - print("Lazer POST /v1/latest_price sanity check (BTC)") - print("=" * 70) - print( - "Channel: fixed_rate@200ms (universal — meets every feed min_channel)" - ) - for asset, entry in mapping.items(): - if entry is None: - print(f"{asset:<8} (unmapped — fallback needed)") - continue - lazer_id = entry.get("pyth_lazer_id") - code, price_mantissa, exponent, err = probe_lazer_latest_price( - int(lazer_id) - ) - if err: - print( - f"{asset:<8} lazer_id={lazer_id:<6} http={code} error={err!r}" - ) - continue - try: - price = float(price_mantissa) * (10 ** int(exponent)) - print( - f"{asset:<8} lazer_id={lazer_id:<6} http={code} " - f"mantissa={price_mantissa} exp={exponent} -> {price}" - ) - except (TypeError, ValueError): - print( - f"{asset:<8} lazer_id={lazer_id} http={code} " - f"raw price={price_mantissa!r} exponent={exponent!r}" - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/pyth-listing.py b/scripts/pyth-listing.py deleted file mode 100644 index 083a1ad0..00000000 --- a/scripts/pyth-listing.py +++ /dev/null @@ -1,62 +0,0 @@ -import json -import requests - -if __name__ == "__main__": - urls = [ - "https://benchmarks.pyth.network/v1/shims/tradingview/symbol_info?group=pyth_stock", - "https://benchmarks.pyth.network/v1/shims/tradingview/symbol_info?group=pyth_crypto", - "https://benchmarks.pyth.network/v1/shims/tradingview/symbol_info?group=pyth_crypto_rr", - ] - assets = [] - for url in urls: - response = requests.get(url) - data = response.json() - - search = [ - "SOLUSD", - "GLD", - "SIVR", - "AAPL", - "IVV", - "TSLA", - "SOLANA", - "SOL", - "BTC", - "ETH", - "TAO", - "MODE", - "SOLANA", - "SOL", - ] - - for item in zip( - data["symbol"], - data["description"], - data["session-regular"], - data["base-currency"], - data["ticker"], - data["supported-resolutions"], - ): - search_result = False - for s in search: - if (item[0] is not None and s in item[0]) or ( - item[3] is not None and s in item[3] - ): - search_result = True - break - if item[1] is not None and s in item[1]: - search_result = True - break - if search_result: - assets.append( - { - "symbol": item[0], - "description": item[1], - "session-regular": item[2], - "base-currency": item[3], - "ticker": item[4], - "supported-resolutions": item[5], - } - ) - - print(json.dumps(assets, indent=2, ensure_ascii=False)) diff --git a/synth/miner/price_simulation.py b/synth/miner/price_simulation.py index 730bf60e..b5f66acc 100644 --- a/synth/miner/price_simulation.py +++ b/synth/miner/price_simulation.py @@ -11,55 +11,42 @@ wait_random_exponential, ) -# Hermes Pyth API documentation: https://hermes.pyth.network/docs/ -# Pyth Lazer https://docs.pyth.network/price-feeds/pro/api/rest#post-v1latest_price -# If env: PYTH_API_KEY= is set, the miner will use Pyth Lazer instead of Hermes for assets with a Lazer feed. - -TOKEN_MAP = { - "BTC": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", - "ETH": "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", - "XAU": "44465e17d2e9d390e70c999d5a11fda4f092847fcd2e3e5aa089d96c98a30e67", - "SOL": "ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", - "SPYX": "2817b78438c769357182c04346fddaad1178c82f4048828fe0997c3c64624e14", - "NVDAX": "4244d07890e4610f46bbde67de8f43a4bf8b569eebe904f136b469f148503b7f", - "TSLAX": "47a156470288850a440df3a6ce85a55917b813a19bb5b31128a33a986566a362", - "AAPLX": "978e6cc68a119ce066aa830017318563a9ed04ec3a0a6439010fc11296a58675", - "GOOGLX": "b911b0329028cd0283e4259c33809d62942bd2716a58084e5f31d64c00b5424e", - "XRP": "ec5d399846a9209f3fe5881d70aae9268c94339ff9817e8d18ff19fa05eea1c8", - "HYPE": "4279e31cc369bbcc2faf022b382b080e32a8e689ff20fbc530d2a603eb6cd98b", - "WTIOIL": "67784f72e95ac01337edb7d7bd5bbd1c03669101b7068a620df228ed4e52ef14", +BINANCE_ASSET_MAP = { + "BTC": "BTCUSDT", + "ETH": "ETHUSDT", + "SOL": "SOLUSDT", + "XRP": "XRPUSDT", } -# Lazer u32 feed IDs sourced from https://pyth.dourolabs.app/v1/symbols by -# matching each asset's hermes_id. -LAZER_FEED_ID_MAP: dict[str, int] = { - "BTC": 1, - "ETH": 2, - "SOL": 6, - "XRP": 14, - "HYPE": 110, - "XAU": 172, - "AAPLX": 1792, - "GOOGLX": 1808, - "NVDAX": 1833, - "SPYX": 1843, - "TSLAX": 1847, -} - -# Hyperliquid `coin` codes for assets that have no usable Pyth feed. HYPERLIQUID_ASSET_MAP = { - "WTIOIL": "xyz:CL", + # HL spot HYPE/USDC (spot coins are addressed by `@`). + "HYPE": "@107", + "XAU": "xyz:GOLD", + "NVDAX": "xyz:NVDA", + "TSLAX": "xyz:TSLA", + "AAPLX": "xyz:AAPL", + "GOOGLX": "xyz:GOOGL", + "SP500": "xyz:SP500", "SPCX": "xyz:SPCX", + "WTIOIL": "xyz:CL", } -# `fixed_rate@200ms` meets every feed's min_channel (crypto majors accept -# `real_time`, but stocks/metals/commodities require `fixed_rate@200ms` or -# slower). Using one channel for every feed keeps the request body simple. -LAZER_CHANNEL = "fixed_rate@200ms" +# SPYX is retired (replaced by SP500) but not-yet-upgraded validators may +# still prompt it during the rollout. This Pyth Hermes path exists only +# for that tail — delete it together with the validator's SPYX support. +TOKEN_MAP = { + "SPYX": "2817b78438c769357182c04346fddaad1178c82f4048828fe0997c3c64624e14", +} -pyth_base_url = "https://hermes.pyth.network/v2/updates/price/latest" -lazer_base_url = "https://pyth-lazer.dourolabs.app/v1/latest_price" hyperliquid_base_url = "https://api.hyperliquid.xyz/info" +# BINANCE_API_HOST is a process-env escape hatch (read at import time): +# api.binance.com returns HTTP 451 from geo-restricted regions, e.g. the +# US-hosted CI runners, which use data-api.binance.vision instead. +binance_ticker_url = ( + os.environ.get("BINANCE_API_HOST", "https://api.binance.com") + + "/api/v3/ticker/price" +) +pyth_base_url = "https://hermes.pyth.network/v2/updates/price/latest" def _fetch_price_hermes(asset: str) -> float | None: @@ -80,37 +67,17 @@ def _fetch_price_hermes(asset: str) -> float | None: return live_price -def _fetch_price_lazer(asset: str, api_key: str) -> float | None: - payload = { - "channel": LAZER_CHANNEL, - "priceFeedIds": [LAZER_FEED_ID_MAP[asset]], - "properties": ["price", "exponent"], - "formats": [], - "parsed": True, - "jsonBinaryEncoding": "hex", - } - response = requests.post( - lazer_base_url, - json=payload, - headers={"Authorization": f"Bearer {api_key}"}, +def _fetch_price_binance(asset: str) -> float | None: + response = requests.get( + binance_ticker_url, + params={"symbol": BINANCE_ASSET_MAP[asset]}, + timeout=30, ) if response.status_code != 200: - print("Error in response of Pyth Lazer API") - return None - - data = response.json() - feeds = (data.get("parsed") or {}).get("priceFeeds") or [] - if not feeds: + print("Error in response of Binance API") return None - feed = feeds[0] - price_mantissa = feed.get("price") - expo = feed.get("exponent") - if price_mantissa is None or expo is None: - return None - - live_price: float = float(price_mantissa) * (10 ** int(expo)) - return live_price + return float(response.json()["price"]) def _fetch_price_hyperliquid(asset: str) -> float | None: @@ -142,11 +109,10 @@ def _fetch_price_hyperliquid(asset: str) -> float | None: reraise=True, ) def get_asset_price(asset="BTC") -> float | None: + if asset in BINANCE_ASSET_MAP: + return _fetch_price_binance(asset) if asset in HYPERLIQUID_ASSET_MAP: return _fetch_price_hyperliquid(asset) - api_key = os.environ.get("PYTH_API_KEY") - if asset in LAZER_FEED_ID_MAP and api_key: - return _fetch_price_lazer(asset, api_key) return _fetch_price_hermes(asset) diff --git a/synth/miner/simulations.py b/synth/miner/simulations.py index 70246e06..d6577834 100644 --- a/synth/miner/simulations.py +++ b/synth/miner/simulations.py @@ -11,6 +11,9 @@ "ETH": 0.00766, "XAU": 0.00312, "SOL": 0.00858, + # SP500 inherits SPYX's sigma (index vol == tokenized-SPY vol). + # SPYX stays for the rollout tail — remove with the Pyth code path. + "SP500": 0.00157, "SPYX": 0.00157, "NVDAX": 0.00338, "TSLAX": 0.00337, diff --git a/synth/validator/competition_config.py b/synth/validator/competition_config.py index 5ba72e80..658644a9 100644 --- a/synth/validator/competition_config.py +++ b/synth/validator/competition_config.py @@ -24,6 +24,9 @@ class CompetitionConfig: "AAPLX", "WTIOIL", "SPCX", + # SP500 (Hyperliquid xyz:SP500, index scale) replaces SPYX + # (tokenized SPY, Pyth) + "SP500", ], label="Commodities/Equities 24h", time_length=86400, diff --git a/synth/validator/moving_average.py b/synth/validator/moving_average.py index 9a0c0f1a..fc0328ee 100644 --- a/synth/validator/moving_average.py +++ b/synth/validator/moving_average.py @@ -22,6 +22,7 @@ "XAU": 1.7370922597118699, "SOL": 0.6310037175639559, "SPYX": 3.437935601155441, + "SP500": 3.437935601155441, "NVDAX": 1.6028217601617174, "TSLAX": 1.6068755936957768, "AAPLX": 2.0916380815843123, diff --git a/synth/validator/price_data_provider.py b/synth/validator/price_data_provider.py index ddef12dd..435f9ba2 100644 --- a/synth/validator/price_data_provider.py +++ b/synth/validator/price_data_provider.py @@ -1,4 +1,5 @@ import logging +import os import time import requests @@ -18,52 +19,62 @@ class PriceDataProvider: - # `fixed_rate@200ms` is the channel that meets every feed's min_channel: - # stocks/metals/oil reject `real_time` with 404, but accept this. Crypto - # majors accept it too. One channel, every feed. - PYTH_PRO_URL = "https://pyth.dourolabs.app/v1/fixed_rate@200ms/history" HYPERLIQUID_BASE_URL = "https://api.hyperliquid.xyz/info" + # BINANCE_API_HOST is a process-env escape hatch (read at import time): + # api.binance.com returns HTTP 451 from geo-restricted regions, e.g. + # the US-hosted CI runners, which use data-api.binance.vision instead. + BINANCE_SPOT_URL = ( + os.environ.get("BINANCE_API_HOST", "https://api.binance.com") + + "/api/v3/klines" + ) + + PYTH_PRO_URL = "https://pyth.dourolabs.app/v1/fixed_rate@200ms/history" + PYTH_SYMBOL_MAP = { + "SPYX": "Crypto.SPYX/USD", + } - # Both Pyth and Hyperliquid serve 1-minute candles indexed by their open + # Hyperliquid serves 1-minute candles indexed by their open # timestamp; the candle at T is only final once time has passed T + 60s. # `CANDLE_INTERVAL_SECONDS` is the *structural* offset — exactly one # candle past the last scored grid point, which is where the settlement - # witness lives. Asking Pyth for more (e.g. + 120s) doesn't make the - # witness arrive sooner; it just widens the query window unnecessarily. + # witness lives. Asking the source for more (e.g. + 120s) doesn't make + # the witness arrive sooner; it just widens the query window + # unnecessarily. # - # The *operational* wait (one candle interval + Pyth's publish latency) + # The *operational* wait (one candle interval + publish latency) # belongs to the scoring gate in # `miner_data_handler.SCORING_GATE_SECONDS` — that constant decides when # scoring is even attempted. This one only decides where to look for # the witness once we do attempt. CANDLE_INTERVAL_SECONDS = 60 - # Assets fetched from Pyth - PYTH_SYMBOL_MAP = { - "BTC": "Crypto.BTC/USD", - "ETH": "Crypto.ETH/USD", - "XAU": "Crypto.XAUT/USD", - "SOL": "Crypto.SOL/USD", - "SPYX": "Crypto.SPYX/USD", - "NVDAX": "Crypto.NVDAX/USD", - "TSLAX": "Crypto.TSLAX/USD", - "AAPLX": "Crypto.AAPLX/USD", - "GOOGLX": "Crypto.GOOGLX/USD", - "XRP": "Crypto.XRP/USD", - "HYPE": "Crypto.HYPE/USD", - "SPCX": "Pyth.HL.SPCX/USDC", + BINANCE_ASSET_MAP = { + "BTC": "BTCUSDT", + "ETH": "ETHUSDT", + "SOL": "SOLUSDT", + "XRP": "XRPUSDT", } - # Assets fetched from Hyperliquid (overrides Pyth for these assets) - HYPERLIQUID_SYMBOL_MAP = { + HYPERLIQUID_ASSET_MAP = { + # HL spot HYPE/USDC (spot coins are addressed by `@` in + # candleSnapshot, same endpoint as perps). + "HYPE": "@107", + "XAU": "xyz:GOLD", + "NVDAX": "xyz:NVDA", + "TSLAX": "xyz:TSLA", + "AAPLX": "xyz:AAPL", + "GOOGLX": "xyz:GOOGL", + "SP500": "xyz:SP500", + "SPCX": "xyz:SPCX", "WTIOIL": "xyz:CL", } @staticmethod def assert_assets_supported(asset_list: list[str]): supported = ( - PriceDataProvider.PYTH_SYMBOL_MAP.keys() - | PriceDataProvider.HYPERLIQUID_SYMBOL_MAP.keys() + PriceDataProvider.BINANCE_ASSET_MAP.keys() + | PriceDataProvider.HYPERLIQUID_ASSET_MAP.keys() + | PriceDataProvider.PYTH_SYMBOL_MAP.keys() ) for asset in asset_list: assert asset in supported @@ -83,43 +94,12 @@ def fetch_data(self, validator_request: ValidatorRequest) -> list: """ asset = str(validator_request.asset) - prices = [] - - if asset in self.HYPERLIQUID_SYMBOL_MAP: + if asset in self.BINANCE_ASSET_MAP: + prices = self.fetch_data_binance(validator_request) + elif asset in self.HYPERLIQUID_ASSET_MAP: prices = self.fetch_data_hyperliquid(validator_request) else: - start_time_int = from_iso_to_unix_time( - validator_request.start_time.isoformat() - ) - last_grid_timestamp = ( - start_time_int + validator_request.time_length - ) - params = { - "symbol": self.PYTH_SYMBOL_MAP[asset], - "resolution": 1, - "from": start_time_int, - # Fetch one extra minute past the last grid point so we - # can verify that candle has closed before scoring with it. - "to": last_grid_timestamp + self.CANDLE_INTERVAL_SECONDS, - } - - response = requests.get(self.PYTH_PRO_URL, params=params) - response.raise_for_status() - data = response.json() - - self._assert_settled( - data, - asset, - validator_request.id, - last_grid_timestamp, - ) - - prices = self._transform_data( - data, - start_time_int, - int(validator_request.time_increment), - int(validator_request.time_length), - ) + prices = self.fetch_data_pyth(validator_request) if not prices or np.isnan(prices[-1]): bt.logging.warning( @@ -144,6 +124,139 @@ def fetch_data_hyperliquid( time_increment=int(validator_request.time_increment), ) + def fetch_data_binance(self, validator_request: ValidatorRequest) -> list: + start_time_int = from_iso_to_unix_time( + validator_request.start_time.isoformat() + ) + return self.download_binance_price_data( + beginning=start_time_int, + end=start_time_int + int(validator_request.time_length), + symbol=str(validator_request.asset), + time_increment=int(validator_request.time_increment), + ) + + @retry( + stop=stop_after_attempt(3), + wait=wait_random_exponential(multiplier=2), + reraise=True, + before=before_log(bt.logging._logger, logging.DEBUG), + ) + def download_binance_price_data( + self, + beginning: int, # Unix timestamp in seconds + end: int, # Unix timestamp in seconds + symbol: str, + time_increment: int = 60, + loop_wait_time_seconds: float = 0.1, + ) -> list: + MAX_KLINES = 1000 + INTERVAL_MS = 60 * 1000 # 1 minute in ms + chunk_ms = MAX_KLINES * INTERVAL_MS + + beginning_ms = beginning * 1000 + end_ms = end * 1000 + # Settlement guard: extend the request by one extra minute so we + # can verify the kline at `end_ms` has closed. Binance prints + # klines eagerly every minute (even with zero trades), so the + # witness semantics match the Hyperliquid path. + settlement_witness_ms = end_ms + self.CANDLE_INTERVAL_SECONDS * 1000 + klines = [] + saw_settled_witness = False + + with requests.Session() as session: + current_start = beginning_ms + while current_start < settlement_witness_ms: + current_end = min( + current_start + chunk_ms - INTERVAL_MS, + settlement_witness_ms, + ) + + params = { + "symbol": self.BINANCE_ASSET_MAP[symbol], + "interval": "1m", + "startTime": current_start, + "endTime": current_end, + "limit": MAX_KLINES, + } + + response = session.get( + self.BINANCE_SPOT_URL, params=params, timeout=30 + ) + response.raise_for_status() + data = response.json() + + bt.logging.debug( + f"Fetched {len(data)} klines for {symbol} [{current_start}, {current_end}]" + ) + + for kline in data: + t = int(kline[0]) # open time in ms + if beginning_ms <= t <= end_ms: + klines.append(kline) + if t > end_ms: + saw_settled_witness = True + + current_start += chunk_ms + time.sleep(loop_wait_time_seconds) + + if not saw_settled_witness: + bt.logging.warning( + f"realized path not yet settled for asset {symbol}: no " + f"Binance kline with open time > {end_ms} ms" + ) + raise ValueError( + f"realized path not yet settled for asset {symbol}" + ) + + if not klines: + bt.logging.warning(f"No data returned from Binance for {symbol}") + return [] + + normalized = { + "t": [int(kline[0]) // 1000 for kline in klines], + "c": [float(kline[4]) for kline in klines], + } + return self._transform_data( + normalized, beginning, time_increment, end - beginning + ) + + def fetch_data_pyth(self, validator_request: ValidatorRequest) -> list: + """Rollout-tail path for in-flight SPYX requests — see + PYTH_SYMBOL_MAP.""" + asset = str(validator_request.asset) + start_time_int = from_iso_to_unix_time( + validator_request.start_time.isoformat() + ) + last_grid_timestamp = start_time_int + int( + validator_request.time_length + ) + params = { + "symbol": self.PYTH_SYMBOL_MAP[asset], + "resolution": 1, + "from": start_time_int, + # Fetch one extra minute past the last grid point so we + # can verify that candle has closed before scoring with it. + "to": last_grid_timestamp + self.CANDLE_INTERVAL_SECONDS, + } + + response = requests.get(self.PYTH_PRO_URL, params=params) + response.raise_for_status() + data = response.json() + + self._assert_settled( + data, + asset, + validator_request.id, + last_grid_timestamp, + ) + + return self._transform_data( + data, + start_time_int, + int(validator_request.time_increment), + int(validator_request.time_length), + ) + @retry( stop=stop_after_attempt(3), wait=wait_random_exponential(multiplier=2), @@ -154,7 +267,7 @@ def download_hyperliquid_price_data( self, beginning: int, # Unix timestamp in seconds end: int, # Unix timestamp in seconds - symbol: str = "WTIOIL", + symbol: str, time_increment: int = 60, loop_wait_time_seconds: float = 0.1, ) -> list: @@ -164,8 +277,8 @@ def download_hyperliquid_price_data( beginning_ms = beginning * 1000 end_ms = end * 1000 - # Same settlement guard as the Pyth path: extend the request by one - # extra minute so we can verify the candle at `end_ms` has closed. + # Settlement guard: extend the request by one extra minute so we + # can verify the candle at `end_ms` has closed. settlement_witness_ms = end_ms + self.CANDLE_INTERVAL_SECONDS * 1000 candles = [] saw_settled_witness = False @@ -180,7 +293,7 @@ def download_hyperliquid_price_data( payload = { "type": "candleSnapshot", "req": { - "coin": self.HYPERLIQUID_SYMBOL_MAP[symbol], + "coin": self.HYPERLIQUID_ASSET_MAP[symbol], "interval": "1m", "startTime": current_start, "endTime": current_end, diff --git a/synth/validator/prompt_config.py b/synth/validator/prompt_config.py index 62468e44..b162f115 100644 --- a/synth/validator/prompt_config.py +++ b/synth/validator/prompt_config.py @@ -28,7 +28,7 @@ class PromptConfig: "XRP", "HYPE", "XAU", - "SPYX", + "SP500", "NVDAX", "GOOGLX", "TSLAX", diff --git a/tests/test_forward.py b/tests/test_forward.py index a8a72cfc..bc60fdbc 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -89,7 +89,7 @@ def test_calculate_moving_average_and_update_rewards(db_engine: Engine): # Pin the miner's live-price fetch so these # tests don't depend on it. PriceDataProvider's history fetch stays live — it -# hits the public Pyth Pro history endpoint and is part of what's scored. +# hits the public Hyperliquid history endpoint and is part of what's scored. @patch("synth.miner.simulations.get_asset_price", return_value=90000.0) def test_calculate_moving_average_and_update_rewards_new_miner( mock_get_asset_price, @@ -117,7 +117,7 @@ def test_calculate_moving_average_and_update_rewards_new_miner( ) + timedelta(hours=i) start_time_str = start_time.isoformat() simulation_input = SimulationInput( - asset="BTC", + asset="HYPE", start_time=start_time_str, time_increment=300, time_length=86400, @@ -233,7 +233,7 @@ def test_calculate_moving_average_and_update_rewards_new_miner_registration( ) + timedelta(hours=i) start_time_str = start_time.isoformat() simulation_input = SimulationInput( - asset="BTC" if i % 2 == 0 else "ETH", + asset="HYPE", start_time=start_time_str, time_increment=300, time_length=86400, @@ -400,7 +400,10 @@ def test_moving_average_writes_all_three_competitions(db_engine: Engine): """ handler = MinerDataHandler(db_engine) miner_ids = [90101, 90102] # high ids to avoid collision with other tests - scored_time = datetime.now(timezone.utc) - timedelta(hours=1) + # 30 days back so the module-shared DB's rows from other tests (live + # scores near now, with huge CRPS values) fall outside this cutoff and + # can't drown the seeded miners in the softmax. + scored_time = datetime.now(timezone.utc) - timedelta(days=30) now = datetime.now(timezone.utc) with db_engine.connect() as connection: @@ -494,7 +497,7 @@ def test_calculate_moving_average_and_update_rewards_only_invalid( ) + timedelta(hours=i) start_time_str = start_time.isoformat() simulation_input = SimulationInput( - asset="BTC", + asset="HYPE", start_time=start_time_str, time_increment=300, time_length=86400, diff --git a/tests/test_miner_data_handler.py b/tests/test_miner_data_handler.py index b76831da..d77c57af 100644 --- a/tests/test_miner_data_handler.py +++ b/tests/test_miner_data_handler.py @@ -381,7 +381,7 @@ def test_set_get_scores(db_engine: Engine): handler, _, _ = prepare_random_predictions(db_engine, start_time) validator_requests = handler.get_validator_requests_to_score( - scored_time, 7, 86400, ["BTC"] + scored_time, 7, 86400, ["HYPE"] ) assert validator_requests is not None assert len(validator_requests) == 1 @@ -403,7 +403,7 @@ def test_set_get_scores(db_engine: Engine): scored_time=scored_time, window_days=4, time_length=86400, - asset_list=["BTC"], + asset_list=["HYPE"], ) assert not miner_scores_df.empty @@ -432,7 +432,7 @@ def test_set_get_scores(db_engine: Engine): scored_time=scored_time, window_days=4, time_length=86400, - asset_list=["BTC"], + asset_list=["HYPE"], ) assert not old_key_df.empty @@ -515,7 +515,7 @@ def test_set_miner_scores_upsert_preserves_individual_values( ) validator_requests = handler.get_validator_requests_to_score( - scored_time, 7, 86400, ["BTC"] + scored_time, 7, 86400, ["HYPE"] ) assert len(validator_requests) >= 1 diff --git a/tests/test_miner_price_simulation.py b/tests/test_miner_price_simulation.py index 3ec7fb41..2a37f86c 100644 --- a/tests/test_miner_price_simulation.py +++ b/tests/test_miner_price_simulation.py @@ -1,67 +1,53 @@ -import os import unittest from unittest.mock import patch, MagicMock from synth.miner import price_simulation from synth.miner.price_simulation import ( + BINANCE_ASSET_MAP, HYPERLIQUID_ASSET_MAP, - LAZER_FEED_ID_MAP, TOKEN_MAP, get_asset_price, ) +from synth.validator.price_data_provider import PriceDataProvider + class TestGetAssetPriceHermes(unittest.TestCase): - def test_without_api_key_reads_hermes(self): - # No PYTH_API_KEY -> the keyless Hermes endpoint, even for assets - # that also have a Lazer feed. + def test_spyx_reads_hermes(self): + assert "SPYX" not in HYPERLIQUID_ASSET_MAP + assert "SPYX" in TOKEN_MAP + mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.json.return_value = { - "parsed": [{"price": {"price": "7930115688547", "expo": "-8"}}] + "parsed": [{"price": {"price": "75431800000", "expo": "-8"}}] } - with patch.dict("os.environ", {}, clear=False): - os.environ.pop("PYTH_API_KEY", None) - with patch("requests.get", return_value=mock_resp) as mock_get: - price = get_asset_price("BTC") - called_url = mock_get.call_args[0][0] + with patch("requests.get", return_value=mock_resp) as mock_get: + price = get_asset_price("SPYX") + called_url = mock_get.call_args[0][0] assert called_url == price_simulation.pyth_base_url - assert price == 79301.15688547 + assert price == 754.318 + +class TestGetAssetPriceBinance(unittest.TestCase): + def test_crypto_major_routes_to_binance_ticker(self): + assert "BTC" not in HYPERLIQUID_ASSET_MAP + assert BINANCE_ASSET_MAP["BTC"] == "BTCUSDT" -class TestGetAssetPriceLazer(unittest.TestCase): - def test_with_api_key_posts_lazer_with_bearer(self): - # PYTH_API_KEY present -> Lazer (paid) for assets with a Lazer feed. mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.json.return_value = { - "parsed": { - "priceFeeds": [ - { - "priceFeedId": LAZER_FEED_ID_MAP["BTC"], - "price": "7930115688547", - "exponent": -8, - } - ] - } + "symbol": "BTCUSDT", + "price": "63128.00", } + with patch("requests.get", return_value=mock_resp) as mock_get: + price = get_asset_price("BTC") - with patch.dict("os.environ", {"PYTH_API_KEY": "test-token"}): - with patch("requests.post", return_value=mock_resp) as mock_post: - price = get_asset_price("BTC") - - assert price == 79301.15688547 - - called_url = mock_post.call_args[0][0] - assert called_url == price_simulation.lazer_base_url - - kwargs = mock_post.call_args.kwargs - assert kwargs["headers"] == {"Authorization": "Bearer test-token"} - body = kwargs["json"] - assert body["channel"] == "fixed_rate@200ms" - assert body["priceFeedIds"] == [LAZER_FEED_ID_MAP["BTC"]] - assert body["parsed"] is True + assert price == 63128.0 + called_url = mock_get.call_args[0][0] + assert called_url == price_simulation.binance_ticker_url + assert mock_get.call_args.kwargs["params"] == {"symbol": "BTCUSDT"} class TestGetAssetPriceHyperliquid(unittest.TestCase): @@ -75,51 +61,40 @@ def _candles_resp(close: str) -> MagicMock: ] return mock_resp - def test_wtioil_routes_to_hyperliquid(self): - # WTIOIL has no usable Lazer feed; the miner pulls it from - # Hyperliquid (same coin the validator scores against), key or not. - assert "WTIOIL" not in LAZER_FEED_ID_MAP - assert HYPERLIQUID_ASSET_MAP["WTIOIL"] == "xyz:CL" - - resp = self._candles_resp("65.00") - with patch.dict("os.environ", {"PYTH_API_KEY": "test-token"}): - with patch("requests.get") as mock_get: - with patch("requests.post", return_value=resp) as mock_post: - price = get_asset_price("WTIOIL") + def _assert_routes_to_hyperliquid(self, asset: str, coin: str, close: str): + resp = self._candles_resp(close) + with patch("requests.post", return_value=resp) as mock_post: + price = get_asset_price(asset) - assert price == 65.0 + assert price == float(close) - # Hyperliquid hit, neither Hermes nor Lazer touched. - mock_get.assert_not_called() called_url = mock_post.call_args[0][0] assert called_url == price_simulation.hyperliquid_base_url body = mock_post.call_args.kwargs["json"] assert body["type"] == "candleSnapshot" - assert body["req"]["coin"] == "xyz:CL" + assert body["req"]["coin"] == coin assert body["req"]["interval"] == "1m" - def test_spcx_routes_to_hyperliquid_even_with_key(self): - # SPCX is not on Hermes and has no Lazer feed — it exists only on - # Hyperliquid, so it must resolve there regardless of PYTH_API_KEY. - assert "SPCX" not in TOKEN_MAP - assert "SPCX" not in LAZER_FEED_ID_MAP - assert HYPERLIQUID_ASSET_MAP["SPCX"] == "xyz:SPCX" + def test_hype_routes_to_hyperliquid_spot(self): + self._assert_routes_to_hyperliquid("HYPE", "@107", "60.10") - resp = self._candles_resp("187.08") - with patch.dict("os.environ", {"PYTH_API_KEY": "test-token"}): - with patch("requests.get") as mock_get: - with patch("requests.post", return_value=resp) as mock_post: - price = get_asset_price("SPCX") + def test_equity_routes_to_xyz_dex(self): + self._assert_routes_to_hyperliquid("NVDAX", "xyz:NVDA", "206.70") - assert price == 187.08 + def test_sp500_routes_to_xyz_dex(self): + self._assert_routes_to_hyperliquid("SP500", "xyz:SP500", "7523.0") - mock_get.assert_not_called() - called_url = mock_post.call_args[0][0] - assert called_url == price_simulation.hyperliquid_base_url - body = mock_post.call_args.kwargs["json"] - assert body["type"] == "candleSnapshot" - assert body["req"]["coin"] == "xyz:SPCX" - assert body["req"]["interval"] == "1m" + def test_wtioil_routes_to_hyperliquid(self): + self._assert_routes_to_hyperliquid("WTIOIL", "xyz:CL", "65.00") + + def test_spcx_routes_to_hyperliquid(self): + self._assert_routes_to_hyperliquid("SPCX", "xyz:SPCX", "187.08") + + def test_maps_match_validator(self): + # Miners must fetch spot from the exact feed the validator scores + # against. + assert HYPERLIQUID_ASSET_MAP == PriceDataProvider.HYPERLIQUID_ASSET_MAP + assert BINANCE_ASSET_MAP == PriceDataProvider.BINANCE_ASSET_MAP if __name__ == "__main__": diff --git a/tests/test_moving_average_unit.py b/tests/test_moving_average_unit.py index 327d8c18..3adac8c4 100644 --- a/tests/test_moving_average_unit.py +++ b/tests/test_moving_average_unit.py @@ -713,7 +713,7 @@ def test_realistic_equivalence(self): "ETH", "XAU", "SOL", - "SPYX", + "SP500", "NVDAX", "TSLAX", "AAPLX", @@ -908,7 +908,7 @@ def _make_realistic_df( "ETH", "XAU", "SOL", - "SPYX", + "SP500", "NVDAX", "TSLAX", "AAPLX", diff --git a/tests/test_price_data_provider.py b/tests/test_price_data_provider.py index 79c209c8..dcc3cdfc 100644 --- a/tests/test_price_data_provider.py +++ b/tests/test_price_data_provider.py @@ -1,20 +1,52 @@ from datetime import datetime, timedelta, timezone import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch + import numpy as np +import requests from synth.db.models import ValidatorRequest from synth.validator.price_data_provider import PriceDataProvider validator_request = ValidatorRequest( - asset="BTC", + asset="XAU", start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), time_length=360, time_increment=120, ) +def _hl_candles(timestamps: list[int], closes: list[float]) -> list[dict]: + """Hyperliquid candleSnapshot shape: open time in ms, close as string.""" + return [{"t": t * 1000, "c": str(c)} for t, c in zip(timestamps, closes)] + + +def _mock_hl_session(candles: list[dict]) -> MagicMock: + """A stand-in for requests.Session whose post() returns `candles`.""" + session_cls = MagicMock() + session = session_cls.return_value.__enter__.return_value + session.post.return_value.json.return_value = candles + return session_cls + + +def _bn_klines(timestamps: list[int], closes: list[float]) -> list[list]: + """Binance kline shape: [openTime(ms), o, h, l, c, v, closeTime, ...], + prices as strings.""" + return [ + [t * 1000, "0", "0", "0", str(c), "0", t * 1000 + 59_999] + for t, c in zip(timestamps, closes) + ] + + +def _mock_bn_session(klines: list[list]) -> MagicMock: + """A stand-in for requests.Session whose get() returns `klines`.""" + session_cls = MagicMock() + session = session_cls.return_value.__enter__.return_value + session.get.return_value.json.return_value = klines + return session_cls + + class TestPriceDataProvider(unittest.TestCase): def setUp(self): self.dataProvider = PriceDataProvider() @@ -28,8 +60,8 @@ def test_fetch_data_all_prices(self): # 1739974620 - 2025-02-19T14:17:00+00:00 # 1739974680 - 2025-02-19T14:18:00+00:00 (last grid point) # 1739974740 - 2025-02-19T14:19:00+00:00 (settlement witness) - mock_response = { - "t": [ + candles = _hl_candles( + [ 1739974320, 1739974380, 1739974440, @@ -39,7 +71,7 @@ def test_fetch_data_all_prices(self): 1739974680, 1739974740, ], - "c": [ + [ 100000.23, 101000.55, 99000.55, @@ -49,11 +81,9 @@ def test_fetch_data_all_prices(self): 108000.867, 108500.0, ], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + ) + with patch("requests.Session", _mock_hl_session(candles)): result = self.dataProvider.fetch_data(validator_request) assert result == [100000.23, 99000.55, 103000.55, 108000.867] @@ -66,16 +96,14 @@ def test_fetch_data_gap_1(self): # gap - 2025-02-19T14:16:00+00:00 # 1739974620 - 2025-02-19T14:17:00+00:00 # 1739974680 - 2025-02-19T14:18:00+00:00 - mock_response = { - # 1739974740 (14:19) is the settlement-witness candle proving - # the last grid point's 1-min candle has closed. - "t": [1739974320, 1739974620, 1739974680, 1739974740], - "c": [100000.23, 105000.55, 108000.867, 108500.0], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + # 1739974740 (14:19) is the settlement-witness candle proving + # the last grid point's 1-min candle has closed. + candles = _hl_candles( + [1739974320, 1739974620, 1739974680, 1739974740], + [100000.23, 105000.55, 108000.867, 108500.0], + ) + with patch("requests.Session", _mock_hl_session(candles)): result = self.dataProvider.fetch_data(validator_request) assert result == [100000.23, np.nan, np.nan, 108000.867] @@ -88,22 +116,20 @@ def test_fetch_data_gap_2(self): # gap - 2025-02-19T14:16:00+00:00 # gap - 2025-02-19T14:17:00+00:00 # 1739974680 - 2025-02-19T14:18:00+00:00 - mock_response = { - # 1739974740 is the settlement-witness candle. - "t": [1739974320, 1739974680, 1739974740], - "c": [100000.23, 108000.867, 108500.0], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + # 1739974740 is the settlement-witness candle. + candles = _hl_candles( + [1739974320, 1739974680, 1739974740], + [100000.23, 108000.867, 108500.0], + ) - validator_request_eth = ValidatorRequest( - asset="ETH", + with patch("requests.Session", _mock_hl_session(candles)): + validator_request_sp500 = ValidatorRequest( + asset="SP500", start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), time_length=360, time_increment=60, ) - result = self.dataProvider.fetch_data(validator_request_eth) + result = self.dataProvider.fetch_data(validator_request_sp500) assert result == [ 100000.23, @@ -115,19 +141,11 @@ def test_fetch_data_gap_2(self): 108000.867, ] - def test_fetch_data_gap_3(self): - # 1739974320 - 2025-02-19T14:12:00+00:00 - # gap - 2025-02-19T14:13:00+00:00 - # gap - 2025-02-19T14:14:00+00:00 - # gap - 2025-02-19T14:15:00+00:00 - # gap - 2025-02-19T14:16:00+00:00 - # gap - 2025-02-19T14:17:00+00:00 - # 1739974680 - 2025-02-19T14:18:00+00:00 - # 1739974740 - 2025-02-19T14:19:00+00:00 - # 1739974800 - 2025-02-19T14:20:00+00:00 - # 1739974860 - 2025-02-19T14:21:00+00:00 - # 1739974920 - 2025-02-19T14:22:00+00:00 - mock_response = { + def test_transform_data_non_divisible_grid(self): + # time_length=540 with time_increment=120 produces one extra grid + # point past start + time_length (the precaution branch in + # _transform_data). + data = { "t": [ 1739974320, 1739974680, @@ -146,26 +164,16 @@ def test_fetch_data_gap_3(self): ], } - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response - - validator_request_eth = ValidatorRequest( - asset="ETH", - start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), - time_length=540, - time_increment=120, - ) - - result = self.dataProvider.fetch_data(validator_request_eth) + result = PriceDataProvider._transform_data(data, 1739974320, 120, 540) - assert result == [ - 100000.23, - np.nan, - np.nan, - 108000.867, - 97123.55, - 107995.889, - ] + assert result == [ + 100000.23, + np.nan, + np.nan, + 108000.867, + 97123.55, + 107995.889, + ] def test_fetch_data_gap_from_start(self): # gap - 2025-02-19T14:12:00+00:00 @@ -179,14 +187,12 @@ def test_fetch_data_gap_from_start(self): # 1739974800 - 2025-02-19T14:20:00+00:00 # 1739974860 - 2025-02-19T14:21:00+00:00 # 1739974920 - 2025-02-19T14:22:00+00:00 - mock_response = { - "t": [1739974680, 1739974740, 1739974800, 1739974860, 1739974920], - "c": [108000.867, 99000.23, 97123.55, 105123.345, 107995.889], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + candles = _hl_candles( + [1739974680, 1739974740, 1739974800, 1739974860, 1739974920], + [108000.867, 99000.23, 97123.55, 105123.345, 107995.889], + ) + with patch("requests.Session", _mock_hl_session(candles)): result = self.dataProvider.fetch_data(validator_request) assert result == [np.nan, np.nan, np.nan, 108000.867] @@ -203,10 +209,10 @@ def test_fetch_data_gap_from_start_2(self): # 1739974800 - 2025-02-19T14:20:00+00:00 # 1739974860 - 2025-02-19T14:21:00+00:00 # 1739974920 - 2025-02-19T14:22:00+00:00 - mock_response = { - # 1739974980 (14:23) is the settlement-witness candle for the - # local request below whose last grid point is 14:22. - "t": [ + # 1739974980 (14:23) is the settlement-witness candle for the + # local request below whose last grid point is 14:22. + candles = _hl_candles( + [ 1739974380, 1739974440, 1739974500, @@ -219,7 +225,7 @@ def test_fetch_data_gap_from_start_2(self): 1739974920, 1739974980, ], - "c": [ + [ 101000.55, 99000.55, 102000.55, @@ -232,13 +238,11 @@ def test_fetch_data_gap_from_start_2(self): 107995.889, 108500.0, ], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + ) + with patch("requests.Session", _mock_hl_session(candles)): validator_request = ValidatorRequest( - asset="BTC", + asset="XAU", start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), time_length=600, time_increment=300, @@ -261,8 +265,8 @@ def test_fetch_data_gap_in_the_middle(self): # 1739974860 - 2025-02-20T14:21:00+00:00 # 1739974920 - 2025-02-20T14:22:00+00:00 # 1739974980 - 2025-02-20T14:23:00+00:00 - mock_response = { - "t": [ + candles = _hl_candles( + [ 1739974320, 1739974380, 1739974440, @@ -275,7 +279,7 @@ def test_fetch_data_gap_in_the_middle(self): 1739974920, 1739974980, ], - "c": [ + [ 100000.23, 101000.55, 99000.55, @@ -288,13 +292,11 @@ def test_fetch_data_gap_in_the_middle(self): 105123.345, 107995.889, ], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + ) + with patch("requests.Session", _mock_hl_session(candles)): validator_request = ValidatorRequest( - asset="BTC", + asset="XAU", start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), time_length=600, time_increment=300, @@ -317,8 +319,8 @@ def test_fetch_data_several_values(self): # 1739974860 - 2025-02-20T14:21:00+00:00 # 1739974920 - 2025-02-20T14:22:00+00:00 # 1739974980 - 2025-02-20T14:23:00+00:00 - mock_response = { - "t": [ + candles = _hl_candles( + [ 1739974320, 1739974380, 1739974440, @@ -332,7 +334,7 @@ def test_fetch_data_several_values(self): 1739974920, 1739974980, ], - "c": [ + [ 100000.23, 101000.55, 99000.55, @@ -346,13 +348,11 @@ def test_fetch_data_several_values(self): 105123.345, 107995.889, ], - } - - with patch("requests.get") as mock_get: - mock_get.return_value.json.return_value = mock_response + ) + with patch("requests.Session", _mock_hl_session(candles)): validator_request = ValidatorRequest( - asset="BTC", + asset="XAU", start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), time_length=600, time_increment=300, @@ -363,10 +363,9 @@ def test_fetch_data_several_values(self): assert result == [100000.23, 105000.55, 105123.345] def test_fetch_data(self): - # Live call — uses a recent window so the Pyth Pro Router (which - # only retains a rolling history) actually has data. The shared - # module-level `validator_request` is fine for the mocked tests - # above but its hardcoded 2025-02 date is outside the live window. + # Live call (BTC routes through Binance spot klines) — uses a + # recent window so the mocked module-level `validator_request` + # date doesn't leak into a live query. start = datetime.now(timezone.utc).replace( second=0, microsecond=0 ) - timedelta(minutes=15) @@ -376,7 +375,12 @@ def test_fetch_data(self): time_length=360, time_increment=120, ) - result = self.dataProvider.fetch_data(live_request) + try: + result = self.dataProvider.fetch_data(live_request) + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 451: + self.skipTest("feed geo-blocks this runner (HTTP 451)") + raise # 360s / 120s + 1 grid points; all finite and positive (BTC > 0). assert len(result) == 4 assert all(np.isfinite(p) for p in result) @@ -390,36 +394,135 @@ class TestSettlementGuard(unittest.TestCase): in-progress close that changes by the time we re-score, breaking CRPS reproducibility.""" - def test_raises_when_no_candle_past_last_grid(self): + def test_hyperliquid_raises_when_no_candle_past_last_grid(self): + # All candles are within the grid — no settlement witness. + candles = _hl_candles( + [1739974320, 1739974440, 1739974560, 1739974680], + [1.0, 2.0, 3.0, 4.0], + ) + + provider = PriceDataProvider() + # Call the unwrapped function to skip the tenacity retries (three + # attempts with random exponential waits) around the raise. + download = PriceDataProvider.download_hyperliquid_price_data + with patch("requests.Session", _mock_hl_session(candles)): + with self.assertRaises(ValueError): + download.__wrapped__( + provider, + beginning=1739974320, + end=1739974680, + symbol="XAU", + time_increment=120, + ) + + def test_witness_accepted_when_present(self): + # A candle past the last grid point proves settlement; the grid + # itself transforms normally and the witness is discarded. + candles = _hl_candles( + [1739974320, 1739974680, 1739974740], + [1.0, 4.0, 5.0], + ) + + provider = PriceDataProvider() + download = PriceDataProvider.download_hyperliquid_price_data + with patch("requests.Session", _mock_hl_session(candles)): + result = download.__wrapped__( + provider, + beginning=1739974320, + end=1739974680, + symbol="XAU", + time_increment=120, + ) + + assert result == [1.0, np.nan, np.nan, 4.0] + + def test_pyth_raises_when_no_candle_past_last_grid(self): data = { "t": [1739974320, 1739974440, 1739974560, 1739974680], "c": [1.0, 2.0, 3.0, 4.0], } with self.assertRaises(ValueError): PriceDataProvider._assert_settled( - data, "BTC", "req-1", last_grid_timestamp=1739974680 - ) - - def test_raises_when_no_candles_at_all(self): - data = {"t": [], "c": []} - with self.assertRaises(ValueError): - PriceDataProvider._assert_settled( - data, "BTC", "req-1", last_grid_timestamp=1739974680 + data, "SPYX", "req-1", last_grid_timestamp=1739974680 ) - def test_accepts_when_witness_candle_present(self): + def test_pyth_accepts_when_witness_candle_present(self): data = { "t": [1739974320, 1739974680, 1739974740], "c": [1.0, 4.0, 5.0], } # Should not raise. PriceDataProvider._assert_settled( - data, "BTC", "req-1", last_grid_timestamp=1739974680 + data, "SPYX", "req-1", last_grid_timestamp=1739974680 ) -class TestPriceDataProviderProBackend(unittest.TestCase): - def test_pro_backend_uses_pro_url(self): +class TestPriceDataProviderBinance(unittest.TestCase): + """Crypto majors are scored from Binance spot 1m klines.""" + + def test_btc_uses_binance_klines(self): + # 1739974740 is the settlement-witness kline past the last grid + # point at 1739974680 (= start + time_length). + klines = _bn_klines( + [ + 1739974320, + 1739974440, + 1739974560, + 1739974680, + 1739974740, + ], + [100000.23, 99000.55, 103000.55, 108000.867, 108500.0], + ) + + btc_request = ValidatorRequest( + asset="BTC", + start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), + time_length=360, + time_increment=120, + ) + + provider = PriceDataProvider() + session_cls = _mock_bn_session(klines) + with patch("requests.Session", session_cls): + result = provider.fetch_data(btc_request) + + session = session_cls.return_value.__enter__.return_value + called_url = session.get.call_args[0][0] + assert called_url == PriceDataProvider.BINANCE_SPOT_URL + params = session.get.call_args.kwargs["params"] + assert params["symbol"] == "BTCUSDT" + assert params["interval"] == "1m" + # The fetch window must extend one minute past the last grid + # point so the settlement witness can land in the response. + assert params["endTime"] == (1739974680 + 60) * 1000 + assert result == [100000.23, 99000.55, 103000.55, 108000.867] + + def test_raises_when_no_kline_past_last_grid(self): + # All klines are within the grid — no settlement witness. + klines = _bn_klines( + [1739974320, 1739974440, 1739974560, 1739974680], + [1.0, 2.0, 3.0, 4.0], + ) + + provider = PriceDataProvider() + # Call the unwrapped function to skip the tenacity retries. + download = PriceDataProvider.download_binance_price_data + with patch("requests.Session", _mock_bn_session(klines)): + with self.assertRaises(ValueError): + download.__wrapped__( + provider, + beginning=1739974320, + end=1739974680, + symbol="BTC", + time_increment=120, + ) + + +class TestPriceDataProviderPythTail(unittest.TestCase): + """SPYX rollout-tail coverage: retired from prompting but in-flight + requests still score from Pyth Pro history until the tail ends.""" + + def test_spyx_uses_pyth_pro_history(self): # 1739974740 is the settlement-witness candle past the last grid # point at 1739974680 (= start + time_length). mock_response = { @@ -433,53 +536,82 @@ def test_pro_backend_uses_pro_url(self): "c": [100000.23, 99000.55, 103000.55, 108000.867, 108500.0], } + spyx_request = ValidatorRequest( + asset="SPYX", + start_time=datetime.fromisoformat("2025-02-19T14:12:00+00:00"), + time_length=360, + time_increment=120, + ) + provider = PriceDataProvider() with patch("requests.get") as mock_get: mock_get.return_value.json.return_value = mock_response - result = provider.fetch_data(validator_request) + result = provider.fetch_data(spyx_request) called_params = mock_get.call_args.kwargs["params"] + assert ( + called_params["symbol"] + == PriceDataProvider.PYTH_SYMBOL_MAP["SPYX"] + ) # The fetch window must extend one minute past the last grid # point so the settlement witness can land in the response. assert called_params["to"] == 1739974680 + 60 assert result == [100000.23, 99000.55, 103000.55, 108000.867] -class TestPriceDataProviderLiveProBackend(unittest.TestCase): - """Hits the live Pyth Pro Router history endpoint for every Pyth-routed - asset — no mocks. The endpoint is public, so no PYTH_API_KEY is - required. Catches channel/symbol regressions per asset (e.g. the 404s - we saw on stocks/metals when the URL channel was real_time).""" +class TestPriceDataProviderLive(unittest.TestCase): + """Hits the live Hyperliquid history endpoint for every asset — no + mocks. The endpoint is public. Catches coin-code regressions per asset + (e.g. a HIP-3 dex redeploy renaming a coin, as km:US500 -> mkts:US500 + did in July 2026).""" - def test_live_history_from_pro_router_per_asset(self): + def _assert_live_history(self, provider, asset): end = datetime.now(timezone.utc).replace( second=0, microsecond=0 ) - timedelta(minutes=5) start = end - timedelta(minutes=10) + req = ValidatorRequest( + asset=asset, + start_time=start, + time_length=600, + time_increment=60, + ) + try: + prices = provider.fetch_data(req) + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 451: + self.skipTest( + f"{asset}: feed geo-blocks this runner (HTTP 451)" + ) + raise + + # time_length=600s @ time_increment=60s => 11 grid points. + self.assertEqual(len(prices), 11) + finite = [p for p in prices if not np.isnan(p)] + self.assertGreater( + len(finite), + 5, + f"{asset}: too many gaps: {prices}", + ) + for p in finite: + # Loose sanity bounds — XAU ~$5k, HYPE ~$40, BTC ~$80k. + self.assertGreater(p, 0, f"{asset}: non-positive price") + self.assertLess(p, 10_000_000, f"{asset}: suspicious magnitude") + + def test_live_history_hyperliquid_per_asset(self): provider = PriceDataProvider() + for asset in PriceDataProvider.HYPERLIQUID_ASSET_MAP.keys(): + with self.subTest(asset=asset): + self._assert_live_history(provider, asset) - for asset in PriceDataProvider.PYTH_SYMBOL_MAP.keys(): + def test_live_history_binance_per_asset(self): + provider = PriceDataProvider() + for asset in PriceDataProvider.BINANCE_ASSET_MAP.keys(): with self.subTest(asset=asset): - req = ValidatorRequest( - asset=asset, - start_time=start, - time_length=600, - time_increment=60, - ) - prices = provider.fetch_data(req) - - # time_length=600s @ time_increment=60s => 11 grid points. - self.assertEqual(len(prices), 11) - finite = [p for p in prices if not np.isnan(p)] - self.assertGreater( - len(finite), - 5, - f"{asset}: too many gaps: {prices}", - ) - for p in finite: - # Loose sanity bounds — XAU ~$5k, HYPE ~$40, BTC ~$80k. - self.assertGreater(p, 0, f"{asset}: non-positive price") - self.assertLess( - p, 10_000_000, f"{asset}: suspicious magnitude" - ) + self._assert_live_history(provider, asset) + + def test_live_history_spyx_pyth_tail(self): + # Rollout tail: SPYX still scores from Pyth Pro (keyless today). + provider = PriceDataProvider() + self._assert_live_history(provider, "SPYX") diff --git a/tests/test_rewards.py b/tests/test_rewards.py index cb115493..376ec0be 100644 --- a/tests/test_rewards.py +++ b/tests/test_rewards.py @@ -84,7 +84,7 @@ def test_get_rewards(db_engine): price_data_provider = PriceDataProvider() validator_requests = handler.get_validator_requests_to_score( - scored_time, 7, 86400, ["BTC"] + scored_time, 7, 86400, ["HYPE"] ) prompt_scores, detailed_info, real_prices = get_rewards_multiprocess( diff --git a/tests/utils.py b/tests/utils.py index 175a51dd..7dbdacc0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,10 +15,11 @@ def recent_start_time(hours_ago: int = 25) -> str: """Return an ISO start_time `hours_ago` hours before now (minute-aligned). - Live tests against the Pyth Pro Router can only query its rolling - history window, so hardcoded dates from months ago return empty data - and trip the settlement guard. Tests that exercise the full scoring - path call this to pick a window the router still serves. + Live tests against Hyperliquid can only query its rolling history + window (~5000 1m candles, ~3.5 days), so hardcoded dates from months + ago return empty data and trip the settlement guard. Tests that + exercise the full scoring path call this to pick a window the API + still serves. """ now = datetime.now(timezone.utc).replace(second=0, microsecond=0) return (now - timedelta(hours=hours_ago)).isoformat() @@ -47,7 +48,7 @@ def prepare_random_predictions(db_engine: Engine, start_time: str): connection.execute(insert_stmt_validator) simulation_input = SimulationInput( - asset="BTC", + asset="HYPE", start_time=start_time, time_increment=300, time_length=86400,