diff --git a/.gitignore b/.gitignore index 640d6ac..fdb450a 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,5 @@ __marimo__/ # Internal working docs .docs/ +# CocoIndex Code (ccc) +/.cocoindex_code/ diff --git a/README.md b/README.md index e0413a1..79b7f7a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Augur -Structured market anomaly detection for prediction markets. Augur observes Polymarket and Kalshi with adaptive polling, extracts typed signals with calibrated confidence, and attaches investigation prompts drawn from a frozen library. The canonical consumer interface is a JSON schema; deterministic Markdown and a gated, opt-in LLM formatter are built on top of it. +Augur extracts structured intelligence signals from prediction markets. It observes Polymarket and Kalshi markets, measures consensus velocity, volume, liquidity, order-book pressure, and cross-market divergence, then emits typed events for downstream agents and analysts to investigate. The canonical consumer interface is a JSON schema; deterministic Markdown and a gated, opt-in LLM formatter are secondary renderings built on top of it. Augur is not a forecaster, an arbitrage engine, or a news writer. It is a deterministic structured-signal pipeline. See `docs/foundations/overview.md` for the full product framing and `docs/foundations/non-goals.md` for what Augur explicitly does not do. -Current version: **0.1.0**. Phase 1-5 scaffolding landed; runnable surfaces are the test suite, the labeling CLI, and the distributed-runtime smoke stack. See `docs/operations/manual-testing.md` for the end-to-end guide. +Current version: **0.1.0**. The component implementation is substantial, but the live proof loop is not complete. Runnable surfaces are the test suite, the labeling CLI, the single-process engine runner, and the distributed-runtime smoke stack. An active watchlist, backtest runner, calibration runner, and real consumer feed remain follow-up work. See `docs/operations/manual-testing.md` for the current manual testing guide. ## Documentation @@ -43,11 +43,11 @@ All three workspace packages — `augur-signals`, `augur-labels`, `augur-format` Each workspace package exposes extras for opt-in integrations. Install only what a deployment needs: ```bash -# LLM secondary formatter (phase 4) +# LLM secondary formatter uv sync --extra llm-local # augur-format[llm-local] — Ollama client uv sync --extra llm-cloud # augur-format[llm-cloud] — Anthropic SDK -# Distributed runtime (phase 5) +# Distributed runtime uv sync --extra bus-nats # NATS JetStream adapter uv sync --extra bus-redis # Redis Streams adapter uv sync --extra storage-timescale # TimescaleDB via psycopg @@ -59,7 +59,7 @@ The dev dependency group in the repo root already pulls every extra so CI exerci ## Runnable Surfaces -### Labeling CLI (phase 2) +### Labeling CLI ```bash uv run python scripts/label.py --help @@ -67,16 +67,25 @@ uv run python scripts/label.py candidates uv run python scripts/label.py decide ``` -### Worker entrypoints (phase 5) +### Single-process engine runner + +```bash +uv run python scripts/run_engine.py --help +uv run python scripts/run_engine.py --once +``` + +`scripts/run_engine.py` loads `AUGUR_CONFIG_DIR` or `config/`, opens the DuckDB store, runs the existing in-process extraction engine, and writes canonical `SignalContext` JSON to stdout. It fails fast when `config/markets.toml` has no active markets. Active Kalshi markets require `KALSHI_API_KEY`; Polymarket-only watchlists do not. + +### Worker entrypoints ```bash uv run python -m augur_signals.workers # catalog uv run python -m augur_signals.workers.poller --help # per-kind entrypoints ``` -The `workers` package exposes bootstrap helpers (`augur_signals.workers.bootstrap`) that every `__main__` module uses for config loading, observability activation, and bus connection. Per-kind transform wiring for feature / detector / manipulation / calibration / dedup / context_format / llm requires a follow-up commit — see `docs/operations/manual-testing.md §3`. +The `workers` package exposes bootstrap helpers (`augur_signals.workers.bootstrap`) that every `__main__` module uses for config loading, observability activation, and bus connection. Per-kind transform wiring for feature / detector / manipulation / calibration / dedup / context_format / llm requires a follow-up commit. See `docs/operations/manual-testing.md §4`. -### Migration scripts (phase 5) +### Migration scripts ```bash uv run python scripts/migrate_to_timescale.py backfill --from labels/snapshots_archive @@ -120,10 +129,10 @@ augur/ ├── pyproject.toml # uv workspace root (v0.1.0) ├── uv.lock ├── config/ # TOML configuration -│ ├── bus.toml # phase 5 — message bus backend -│ ├── storage.toml # phase 5 — DuckDB / TimescaleDB selector -│ ├── observability.toml # phase 5 — Prometheus + OTel exporters -│ ├── llm.toml # phase 4 — gated LLM formatter +│ ├── bus.toml # message bus backend +│ ├── storage.toml # DuckDB / TimescaleDB selector +│ ├── observability.toml # Prometheus + OTel exporters +│ ├── llm.toml # gated LLM formatter │ └── ... # polling, detectors, dedup, formatters, consumers, labeling, markets, forbidden_tokens ├── data/ # market taxonomy, investigation prompts, calibration state ├── labels/ # newsworthy-event labels (Parquet) @@ -134,6 +143,7 @@ augur/ │ ├── export_schemas.py │ ├── label.py # labeling CLI wrapper │ ├── lint_detector_now.py +│ ├── run_engine.py # single-process live runner │ ├── migrate_to_timescale.py # phase 5 backfill + verify │ └── dual_write_sidecar.py # phase 5 tee replay ├── src/ diff --git a/config/detectors.toml b/config/detectors.toml index 1d87b1a..7117981 100644 --- a/config/detectors.toml +++ b/config/detectors.toml @@ -8,28 +8,28 @@ # tune against the historical corpus before live deployment. [price_velocity] -hazard = 0.004 # 1/250 -fire_probability_threshold = 0.7 -resolution_exclusion_hours = 6 +hazard_rate = 0.004 # 1/250 +fire_threshold = 0.7 +resolution_exclusion_seconds = 21600 [volume_spike] ewma_alpha = 0.05 target_fdr_q = 0.05 [book_imbalance] -top_levels = 5 +depth_levels = 5 bullish_threshold = 0.72 bearish_threshold = 0.28 -persist_snapshots = 3 -min_total_depth_usd = 5000.0 +persistence_snapshots = 3 +minimum_total_depth = 5000.0 -[cross_market_divergence] -window_hours = 4 +[cross_market] +window_seconds = 14400 min_historical_correlation = 0.6 -activity_floor_volume_ratio = 1.0 +activity_floor = 1.0 [regime_shift] target_alpha = 0.02 -k_sigma = 0.5 -h_sigma = 4.0 -min_dormancy_hours = 6 +k_multiplier = 0.5 +h_multiplier = 4.0 +dormancy_minimum_seconds = 21600 diff --git a/docs/operations/manual-testing.md b/docs/operations/manual-testing.md index 56ec199..2880545 100644 --- a/docs/operations/manual-testing.md +++ b/docs/operations/manual-testing.md @@ -1,6 +1,6 @@ # Manual Testing Guide -Augur has three runnable surfaces today: the test suite, the labeling CLI, and the distributed-runtime smoke stack. This document enumerates what can be exercised locally and what remains operator-wiring work. +Augur has four runnable surfaces today: the test suite, the labeling CLI, the single-process engine runner, and the distributed-runtime smoke stack. This document enumerates what can be exercised locally and what remains operator-wiring work. ## 1. Quality gates and tests @@ -40,7 +40,33 @@ uv run python scripts/label.py coverage # per-category coverage State persists to `labels/queue.json` and promoted rows land as partitioned Parquet under `labels/newsworthy_events/date=YYYY-MM-DD/`. -## 3. Distributed-runtime smoke stack +## 3. Single-process engine runner + +The monolith runner drives the existing in-process engine against configured active markets and writes canonical `SignalContext` JSON lines to stdout. + +```bash +uv run python scripts/run_engine.py --help +uv run python scripts/run_engine.py --once +``` + +Runtime contract: + +- `AUGUR_CONFIG_DIR` overrides the default `config/` directory. +- `config/markets.toml` must contain at least one active market. +- Polymarket-only watchlists run without platform credentials. +- Active Kalshi markets require `KALSHI_API_KEY`. +- DuckDB storage is opened from `config/storage.toml`. +- Output is deterministic canonical JSON from `augur_format.deterministic.json_feed`. + +Current repository state still has only an inactive placeholder watchlist, so `uv run python scripts/run_engine.py --once` fails fast with: + +```text +run_engine failed: config/markets.toml has no active markets +``` + +Populate `config/markets.toml` before using the runner for live capture. + +## 4. Distributed-runtime smoke stack The phase 5 compose stack brings up every external dependency the workers need: NATS JetStream, Redis, TimescaleDB, Prometheus, and (optionally) an OTel collector. Workers run as separate host processes so each one is inspectable. @@ -128,7 +154,7 @@ bus = build_event_bus(cfg.bus) # nats or redis await bus.connect() ``` -## 4. Migration scripts +## 5. Migration scripts Both scripts are fully runnable against the smoke stack once TimescaleDB is initialized. @@ -162,7 +188,7 @@ uv run python scripts/dual_write_sidecar.py \ Requires the engine to publish to `augur.writes` — this path is not wired in the monolith yet, so the sidecar is smoke-testable against handcrafted fixtures for now. -## 5. Container build and Kubernetes +## 6. Container build and Kubernetes ### Build the image @@ -191,7 +217,7 @@ kubectl -n augur create secret generic augur-secrets \ --dry-run=client -o yaml | kubectl apply -f - ``` -## 6. Observability +## 7. Observability - Prometheus: `http://localhost:9090` after compose is up. Scrapes `host.docker.internal:9091..9097`. - NATS admin: `http://localhost:8222/varz`. @@ -199,16 +225,16 @@ kubectl -n augur create secret generic augur-secrets \ - TimescaleDB: `psql $AUGUR_TIMESCALE_URL -c 'select * from timescaledb_information.hypertables'`. - OTel collector: spans print to the container stdout (`docker compose logs otel-collector`). -## 7. Tear down +## 8. Tear down ```bash docker compose -f ops/docker/compose.yaml down -v unset AUGUR_CONFIG_DIR AUGUR_TIMESCALE_URL AUGUR_REPLICA_ID ``` -## 8. Known gaps +## 9. Known gaps -- No end-to-end monolith launcher (`python -m augur_signals.engine` has no `__main__`). The engine is driveable from Python and from `tests/signals/test_engine_integration.py`, but no production script. +- The monolith runner exists as `scripts/run_engine.py`, but the checked-in watchlist still has no active markets. - `scripts/backtest.py` and `scripts/calibrate.py` are stubs that raise `NotImplementedError`. -- Worker entrypoints for feature / detector / manipulation / calibration / dedup / context_format / llm require the bus message-schema work described in §3 above. +- Worker entrypoints for feature / detector / manipulation / calibration / dedup / context_format / llm require the bus message-schema work described in §4 above. - Live failover tests against a real NATS or Redis cluster are operator-owned; CI uses dependency-injected fakes. diff --git a/pyproject.toml b/pyproject.toml index 66e2839..51a120f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "augur" version = "0.1.0" -description = "Structured market anomaly detection for prediction markets" +description = "Structured intelligence signals from prediction markets" readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } diff --git a/scripts/run_engine.py b/scripts/run_engine.py new file mode 100644 index 0000000..f6f5f13 --- /dev/null +++ b/scripts/run_engine.py @@ -0,0 +1,319 @@ +"""Run Augur's single-process live extraction engine.""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Literal + +import aiohttp +from pydantic import BaseModel, ConfigDict, Field + +from augur_format.deterministic.json_feed import to_canonical_json +from augur_signals._config import load_config +from augur_signals.bus.memory import InProcessAsyncBus +from augur_signals.calibration._config import CalibrationConfig +from augur_signals.calibration.fdr_controller import FDRController +from augur_signals.context.assembler import ContextAssembler +from augur_signals.context.investigation_prompts import InvestigationPromptLibrary +from augur_signals.context.related import RelatedMarketResolver +from augur_signals.context.taxonomy import MarketTaxonomy +from augur_signals.dedup._config import DedupConfig +from augur_signals.dedup.cluster import ClusterMerge, TaxonomyEdgesProvider +from augur_signals.detectors._config import DetectorsConfig +from augur_signals.detectors.book_imbalance import BookImbalanceDetector +from augur_signals.detectors.cross_market import CrossMarketDivergenceDetector +from augur_signals.detectors.price_velocity import PriceVelocityDetector +from augur_signals.detectors.regime_shift import RegimeShiftDetector +from augur_signals.detectors.registry import DetectorRegistry +from augur_signals.detectors.volume_spike import VolumeSpikeDetector +from augur_signals.engine import Engine +from augur_signals.features.pipeline import FeaturePipeline +from augur_signals.ingestion.base import AbstractPoller, RawMarketData, RawTrade +from augur_signals.ingestion.kalshi import KalshiPoller +from augur_signals.ingestion.normalizer import normalize +from augur_signals.ingestion.polymarket import PolymarketPoller +from augur_signals.manipulation._config import ManipulationConfig +from augur_signals.manipulation.detector import ManipulationDetector +from augur_signals.models import FeatureVector, MarketSnapshot +from augur_signals.storage._config import StorageConfig +from augur_signals.storage.factory import make_duckdb_store + + +class MarketEntry(BaseModel): + """One configured watchlist market.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str + platform: Literal["polymarket", "kalshi"] + platform_market_id: str + category: str + active: bool + poll_priority: Literal["hot", "warm", "cool", "cold", "normal"] = "normal" + + +class RelationshipEntry(BaseModel): + """One curated relationship edge from markets.toml.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + market_a: str + market_b: str + type: Literal["positive", "inverse", "complex", "causal"] + strength: float = Field(default=1.0, ge=0.0, le=1.0) + source: Literal["manual", "embedding"] = "manual" + + +class MarketsConfig(BaseModel): + """Top-level markets.toml schema used by the monolith runner.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + markets: list[MarketEntry] = Field(default_factory=list) + relationships: list[RelationshipEntry] = Field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class RuntimeConfig: + config_dir: Path + data_dir: Path + once: bool + poll_seconds: float + trade_lookback_seconds: int + + +@dataclass(slots=True) +class EngineRuntime: + engine: Engine + feature_pipeline: FeaturePipeline + markets: list[MarketEntry] + + +def _parse_args(argv: Sequence[str]) -> RuntimeConfig: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config-dir", + type=Path, + default=Path(os.environ.get("AUGUR_CONFIG_DIR", "config")), + help="Directory containing Augur TOML config files.", + ) + parser.add_argument( + "--data-dir", + type=Path, + default=Path("data"), + help="Directory containing data/investigation_prompts.toml.", + ) + parser.add_argument("--once", action="store_true", help="Run one poll cycle and exit.") + parser.add_argument( + "--poll-seconds", + type=float, + default=60.0, + help="Sleep interval between cycles when --once is not set.", + ) + parser.add_argument( + "--trade-lookback-seconds", + type=int, + default=300, + help="Initial trade lookback window for manipulation checks.", + ) + args = parser.parse_args(argv) + return RuntimeConfig( + config_dir=args.config_dir, + data_dir=args.data_dir, + once=args.once, + poll_seconds=args.poll_seconds, + trade_lookback_seconds=args.trade_lookback_seconds, + ) + + +def _build_registry(config: DetectorsConfig) -> DetectorRegistry: + registry = DetectorRegistry() + registry.register(PriceVelocityDetector(config.price_velocity)) + registry.register(VolumeSpikeDetector(config.volume_spike)) + registry.register(BookImbalanceDetector(config.book_imbalance)) + registry.register(RegimeShiftDetector(config.regime_shift)) + registry.register_batch( + CrossMarketDivergenceDetector( + config.cross_market, + FDRController(CalibrationConfig()), + related_pairs=[], + ) + ) + return registry + + +def _build_cluster(config_dir: Path, dedup: DedupConfig) -> ClusterMerge: + taxonomy = MarketTaxonomy.from_toml(config_dir / "markets.toml") + edges = { + market_id: [ + (edge.market_b, edge.relationship_type) for edge in taxonomy.edges_for(market_id) + ] + for market_id in _taxonomy_market_ids(taxonomy, config_dir) + } + return ClusterMerge( + TaxonomyEdgesProvider(edges), + window_seconds=dedup.dedup.cluster_window_seconds, + relationship_types=set(dedup.dedup.cluster_relationship_types), + ) + + +def _taxonomy_market_ids(taxonomy: MarketTaxonomy, config_dir: Path) -> set[str]: + config = load_config(config_dir / "markets.toml", MarketsConfig) + market_ids = {market.id for market in config.markets} + for relationship in config.relationships: + market_ids.add(relationship.market_a) + market_ids.add(relationship.market_b) + return {market_id for market_id in market_ids if taxonomy.edges_for(market_id)} + + +def _build_runtime(config: RuntimeConfig) -> EngineRuntime: + markets_config = load_config(config.config_dir / "markets.toml", MarketsConfig) + active = [market for market in markets_config.markets if market.active] + if not active: + raise RuntimeError("config/markets.toml has no active markets") + detector_config = load_config(config.config_dir / "detectors.toml", DetectorsConfig) + dedup_config = load_config(config.config_dir / "dedup.toml", DedupConfig) + storage_config = load_config(config.config_dir / "storage.toml", StorageConfig) + store = make_duckdb_store(storage_config) + store.initialize() + taxonomy = MarketTaxonomy.from_toml(config.config_dir / "markets.toml") + resolver = RelatedMarketResolver(taxonomy, store) + prompt_library = InvestigationPromptLibrary.from_toml( + config.data_dir / "investigation_prompts.toml" + ) + category_by_market = {market.id: market.category for market in active} + engine = Engine( + store=store, + registry=_build_registry(detector_config), + manipulation=ManipulationDetector(ManipulationConfig()), + cluster=_build_cluster(config.config_dir, dedup_config), + bus=InProcessAsyncBus(capacity=dedup_config.dedup.bus.queue_capacity), + assembler=ContextAssembler(store, resolver, prompt_library, category_by_market), + ) + return EngineRuntime(engine, FeaturePipeline(), active) + + +def _required_platforms(markets: Sequence[MarketEntry]) -> set[str]: + return {market.platform for market in markets} + + +def _build_pollers( + session: aiohttp.ClientSession, + markets: Sequence[MarketEntry], +) -> dict[str, AbstractPoller]: + platforms = _required_platforms(markets) + pollers: dict[str, AbstractPoller] = {} + if "polymarket" in platforms: + pollers["polymarket"] = PolymarketPoller(session) + if "kalshi" in platforms: + pollers["kalshi"] = KalshiPoller(session) + return pollers + + +async def _fetch_cycle( + pollers: dict[str, AbstractPoller], + markets: Sequence[MarketEntry], + since: datetime, +) -> tuple[list[MarketSnapshot], dict[str, list[RawTrade]]]: + raw_by_platform = await _poll_markets_by_platform(pollers) + snapshots: list[MarketSnapshot] = [] + trades: dict[str, list[RawTrade]] = {} + for market in markets: + raw = _select_market(raw_by_platform[market.platform], market) + book = await pollers[market.platform].poll_orderbook(market.platform_market_id) + snapshot = normalize(raw, book) + snapshots.append(snapshot) + raw_trades = await pollers[market.platform].poll_trades(market.platform_market_id, since) + trades[market.id] = [_remap_trade(trade, market.id) for trade in raw_trades] + return snapshots, trades + + +async def _poll_markets_by_platform( + pollers: dict[str, AbstractPoller], +) -> dict[str, list[RawMarketData]]: + results: dict[str, list[RawMarketData]] = {} + for platform, poller in pollers.items(): + results[platform] = await poller.poll_markets() + return results + + +def _select_market(raw_markets: Sequence[RawMarketData], market: MarketEntry) -> RawMarketData: + for raw in raw_markets: + if raw.market_id == market.platform_market_id: + return RawMarketData( + market_id=market.id, + platform=raw.platform, + fetched_at=raw.fetched_at, + payload=raw.payload, + ) + raise RuntimeError( + f"{market.platform} market {market.platform_market_id!r} " + f"for configured id {market.id!r} was not returned by poll_markets" + ) + + +def _remap_trade(trade: RawTrade, market_id: str) -> RawTrade: + return RawTrade( + market_id=market_id, + platform=trade.platform, + timestamp=trade.timestamp, + price=trade.price, + size=trade.size, + side=trade.side, + counterparty=trade.counterparty, + ) + + +async def _run(config: RuntimeConfig) -> None: + runtime = _build_runtime(config) + since = datetime.now(tz=UTC) - timedelta(seconds=config.trade_lookback_seconds) + async with aiohttp.ClientSession() as session: + pollers = _build_pollers(session, runtime.markets) + while True: + snapshots, trades = await _fetch_cycle(pollers, runtime.markets, since) + since = datetime.now(tz=UTC) + features = _ingest_features(runtime, snapshots) + contexts = await runtime.engine.run_cycle( + snapshots=snapshots, + features=features, + recent_trades=trades, + recent_book_events={}, + now=since, + ) + for context in contexts: + print(to_canonical_json(context).decode("utf-8"), flush=True) + if config.once: + return + await asyncio.sleep(config.poll_seconds) + + +def _ingest_features( + runtime: EngineRuntime, + snapshots: Sequence[MarketSnapshot], +) -> dict[str, FeatureVector]: + features: dict[str, FeatureVector] = {} + for snapshot in snapshots: + feature = runtime.feature_pipeline.ingest(snapshot) + if feature is not None: + features[snapshot.market_id] = feature + return features + + +def main(argv: Sequence[str] | None = None) -> int: + try: + asyncio.run(_run(_parse_args(argv or sys.argv[1:]))) + except Exception as exc: + print(f"run_engine failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/signals/test_config.py b/tests/signals/test_config.py index d974443..1c0de16 100644 --- a/tests/signals/test_config.py +++ b/tests/signals/test_config.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ValidationError from augur_signals._config import load_config +from augur_signals.detectors._config import DetectorsConfig class _Engine(BaseModel): @@ -52,3 +53,11 @@ def test_load_config_validation_error_surfaces(tmp_path: Path) -> None: with pytest.raises(ValidationError): load_config(path, _Root) + + +@pytest.mark.unit +def test_checked_in_detector_config_matches_schema() -> None: + result = load_config(Path("config/detectors.toml"), DetectorsConfig) + + assert result.price_velocity.hazard_rate == 0.004 + assert result.cross_market.window_seconds == 14_400 diff --git a/tests/signals/test_run_engine.py b/tests/signals/test_run_engine.py new file mode 100644 index 0000000..1ac1299 --- /dev/null +++ b/tests/signals/test_run_engine.py @@ -0,0 +1,76 @@ +"""Tests for the single-process engine runner.""" + +from __future__ import annotations + +import importlib.util +import sys +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from augur_signals.ingestion.base import RawMarketData + + +def _load_run_engine() -> object: + path = Path("scripts/run_engine.py") + spec = importlib.util.spec_from_file_location("run_engine", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["run_engine"] = module + spec.loader.exec_module(module) + return module + + +@pytest.mark.unit +def test_run_engine_rejects_empty_active_watchlist(tmp_path: Path) -> None: + module = _load_run_engine() + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "markets.toml").write_text( + """ +[[markets]] +id = "inactive" +platform = "polymarket" +platform_market_id = "condition-id" +category = "monetary_policy" +active = false +poll_priority = "normal" +""", + encoding="utf-8", + ) + runtime_config = module.RuntimeConfig( + config_dir=config_dir, + data_dir=tmp_path / "data", + once=True, + poll_seconds=1.0, + trade_lookback_seconds=300, + ) + + with pytest.raises(RuntimeError, match="no active markets"): + module._build_runtime(runtime_config) + + +@pytest.mark.unit +def test_select_market_remaps_platform_id_to_config_id() -> None: + module = _load_run_engine() + market = module.MarketEntry( + id="macro-fed-cut", + platform="polymarket", + platform_market_id="condition-id", + category="monetary_policy", + active=True, + poll_priority="normal", + ) + raw = RawMarketData( + market_id="condition-id", + platform="polymarket", + fetched_at=datetime(2026, 5, 17, tzinfo=UTC), + payload={"question": "Will rates fall?"}, + ) + + selected = module._select_market([raw], market) + + assert selected.market_id == "macro-fed-cut" + assert selected.payload == raw.payload