diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f8e37b..1f8f9ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [Unreleased] +### Local paper MVP qualification (NRCL-107) + +- Expose experimental Kalshi data/streaming, sports matching, price-rule, recording, simulation, and journal capabilities through the existing capability matrix. Public JSON fields and existing imports remain unchanged. +- Document supported venue boundaries and executable edge cases. Extend both synthetic venues' malformed-book, exact-depth, fee/cash, timestamp-boundary, and unsupported-venue regression coverage. Novig and international Polymarket remain unsupported by the local paper pipeline; this does not enable live trading or real-data capture. + ### Added - **Exchange Core:** Added exchange abstraction contracts and normalized models in `neural/exchanges/`. - **Kalshi Adapter Routing:** Refactored `TradingClient` to route Kalshi behavior through an exchange adapter without breaking default usage. diff --git a/docs/architecture/local-paper-acceptance.mdx b/docs/architecture/local-paper-acceptance.mdx new file mode 100644 index 00000000..7a55c005 --- /dev/null +++ b/docs/architecture/local-paper-acceptance.mdx @@ -0,0 +1,61 @@ +--- +title: Local Paper MVP Acceptance +description: Kalshi-first edge cases, supported venue boundaries, and release-candidate gates +--- + +## Goal and scope + +NRCL-107 qualifies a local paper MVP: reproducible Kalshi experiments, explicit +Polymarket US synthetic support, and safe rejection of unsupported behavior. +NRCL-105 supplies the Vaticor consumer migration. This acceptance contract does +not resume deferred NRCL-101/102, authorize capture, or qualify hosted/live trading. + +## Executable acceptance matrix + +Paths below are repository-relative. Each row names behavior and the test that +asserts it; running a suite is evidence only for those supported behaviors. + +| Boundary | Required positive and negative behavior | Executable evidence | +| --- | --- | --- | +| Kalshi decimal data | Preserve decimal strings and native identity; reject malformed prices, unexpected markets, redirects, and incomplete pagination | `tests/exchanges/test_kalshi_data.py`: `test_exact_decimal_market_and_serialization`, `test_invalid_price_fails_closed`, `test_pagination_never_silently_truncates`, `test_bad_identity_and_redirect` | +| HTTP failure handling | Bounded retries for 429/5xx/timeouts; no retry on auth failure or beyond allowed Retry-After | Same file: `test_bounded_retries`, `test_no_unauthorized_or_early_retry`, `test_network_retries_exhaust_and_malformed_json_closes` | +| Book continuity | Gaps, duplicate/out-of-order sequence and malformed deltas invalidate the book; fresh snapshot restores it | `tests/exchanges/test_kalshi_stream.py`: `test_exact_snapshot_delta_removal_and_recovery`, `test_sequence_and_shape_fail_closed`, `test_bad_delta_invalidates` | +| Stream recovery | Disconnect, timeout, cancellation, or gap clears book before teardown; reconnect budget enforced; recordings replay deterministically | Same file: `test_invalidate_before_socket_teardown`, `test_gap_reconnect_recording_and_replay`, `test_retry_budget_and_invalidation` | +| Authentication boundary | Exercise local WebSocket handshake; redirects cannot forward credentials | Same file: `test_local_wire_transport_and_auth`, `test_redirect_rejected_before_credentials_forwarded`. These do not establish venue-authenticated access. | +| Normalized venue data | Both synthetic venues reject empty/one-sided/crossed books, bad ordering, stale/future timestamps, sequence gaps, malformed depth, false provenance and incomplete sessions | `tests/test_recordings.py`: `test_quality_faults_fail_before_returning_a_report`, `test_source_age_boundary_to_microsecond` | +| Exact fill and budget boundaries | Both venues cancel orders below full executable depth; exact depth fills; fees included in reserved cash down to a decimal unit; exit fees cannot make cash negative | Same file: `test_fill_or_kill_at_exact_depth_boundary`, `test_reserved_cash_boundary_includes_fees`, `test_exit_fees_never_make_cash_negative` | +| Model limits | Reject off-lot quantity, invalid numeric assumptions and unsupported venues/outcomes; expire old intents; retain unrealized positions | `tests/test_paper_recording.py`: `test_reject_invalid_model_inputs`, `test_expiry_and_terminal_cancel`, `test_open_position_remains_unrealized`; `tests/test_recordings.py`: `test_unsupported_venue_cannot_create_strategy_or_recording`, `test_no_strategy_cannot_reuse_yes_sports_proposition` | +| Causality and reconnect | A source observation before/at the intent cannot fill it; reconnect cancels intent while preserving holdings | `tests/test_recordings.py`: `test_delayed_source_cannot_fill_before_or_at_intent_time`; `tests/test_paper_recording.py`: `test_reconnect_cancels_intent_and_preserves_holdings` | +| Determinism | Same supported logic yields expected decimal traces on both synthetic venues while identities remain distinct | `tests/test_recordings.py`: `test_same_strategy_logic_uses_full_decimal_depth_across_venues`; `tests/kernel/test_replay.py` | +| Durable execution | Duplicate submissions reuse identity; process death rolls back; concurrent workers publish once; corrupt input becomes terminal failure | `tests/test_paper_worker.py`: `test_snapshot_dedup_and_restart`, `test_process_death_after_simulation_rolls_back`, `test_concurrent_processes_publish_one_result`, `test_bad_recording_is_terminal_failure` | +| Historical reads | Preserve original journal/recording bytes, reject unknown/damaged or WAL archives, retain old model results without reinterpretation | `tests/test_paper_query.py`: `test_reopen_compare_and_source_bytes_across_synthetic_venues`, `test_reads_do_not_acquire_writer_lock_or_initialize_journal`, `test_historical_models_stay_opaque_and_read_only`, `test_damaged_journals_fail_without_repairing_them`, `test_wal_archive_rejected_without_creating_sidecars` | +| Fair comparison | Reject different recordings, quantities, assumptions and unfinished jobs; preserve tiny fee products | Same file: `test_incompatible_assumptions_and_unfinished_jobs_reject`, `test_derived_fees_preserve_product_scale` | +| Settlement matching | Compare identity and rule evidence; distinguish ordinary winner, forfeit/postponement, doubleheader and partial-game cases; unknown never implies equivalent | `tests/contracts/test_sports_matching.py`: `test_customer_comparison_cases`, `test_missing_identity_on_both_sides_is_unknown`, `test_missing_rule_hash_and_incomplete_review_cannot_claim_compatibility` | +| Public support discovery | Capability names resolve to importable modules without promoting experimental APIs or loading optional stacks during kernel import | `tests/kernel/test_capabilities.py` | + +## Release-candidate gates + +1. Run the full SDK suite and required Ruff, Black, and mypy checks on the candidate. + Run documentation/example validators and the documentation site's typecheck/build. +2. Build the wheel from the candidate; record commit and artifact digest. Install + in an isolated environment and exercise capabilities, replay, saved jobs and + read-only comparison outside the source checkout. +3. With Vaticor's reviewed NRCL-105 integration, run installed-wheel integration + tests and browser save/run/reopen/clone/compare journeys on both synthetic venues. + Include invalid imports, incompatible comparisons and restart/archive behavior. +4. Required hosted checks and exact-candidate review must pass. Attach evidence + and limitations to Plane and the PR. Merge, release publication and deployment + remain distinct approvals and outcomes. + +## Compatibility and limits + +The current patch adds capability descriptions and regression coverage; it does +not migrate journals, remove 0.4.x imports, or change simulation model identity. +Existing capability JSON fields remain unchanged; consumers must accept added +capability rows. Public paper APIs remain experimental. + +Real venue wire fixtures, actual account authentication, fee schedules, permitted +retention/replay, real-game quality and live order behavior require separate +evidence. Novig remains a research candidate. See the +[support matrix](./stability#local-paper-venue-support) and +[access and cost plan](../research/2026-09-10-venue-access-and-cost-plan). diff --git a/docs/architecture/meta.json b/docs/architecture/meta.json index 5574dd6b..fcadd270 100644 --- a/docs/architecture/meta.json +++ b/docs/architecture/meta.json @@ -5,6 +5,7 @@ "overview", "contracts", "sports-matching", - "stability" + "stability", + "local-paper-acceptance" ] } diff --git a/docs/architecture/stability.mdx b/docs/architecture/stability.mdx index f0857a9c..0b6522ee 100644 --- a/docs/architecture/stability.mdx +++ b/docs/architecture/stability.mdx @@ -40,6 +40,10 @@ matrix. | Kernel normalization | Stable | base wheel | Public `neural.kernel` contract | | Kernel replay | Stable | base wheel | Deterministic output and digest | | CLI diagnostics | Stable | base wheel | JSON envelope retained | +| Kalshi market data and book streaming | Experimental | base wheel | Read-only; stream authentication supplied by caller | +| Sports matching and price-rule specification | Experimental | base wheel | Explicit identity, settlement evidence, and unsupported combinations | +| Recording replay and paper simulation | Experimental | base wheel | Synthetic normalized sports inputs; next-book fill-or-kill | +| Durable paper jobs and read-only journal | Experimental | base wheel | Local persistence; supported journal versions only | | Kalshi auth and collection | Experimental | `neural-sdk[trading]` | May change before 1.0 | | Paper and venue adapters | Experimental | `neural-sdk[trading]` | Paper-first; no live authority | | Strategy and backtesting | Experimental | `neural-sdk[analysis]` | May change before 1.0 | @@ -50,6 +54,39 @@ matrix. Existing imports remain available during the `0.4.x` compatibility window. New code should use `neural.kernel` for stable contracts. +## Local paper venue support + +The newer local paper APIs above are distinct from the older optional trading +adapters. Their presence in the base wheel does not promote them to stable APIs. + +| Behavior | Kalshi | Polymarket US | Novig | +| --- | --- | --- | --- | +| Native market discovery and decimal books in the local paper data path | `neural.kalshi`; read-only | No native capture client in this path | Unsupported | +| Native book streaming/recovery | `neural.kalshi_stream`; caller supplies signer | Unsupported | Unsupported | +| Legacy `kalshi-book/1` replay | YES/NO; provenance unverified | Unsupported | Unsupported | +| Normalized `neural-book/1` replay | Synthetic, full-depth YES team-wins | Synthetic, full-depth YES team-wins | Unsupported | +| Price-rule paper simulation and local jobs | Supported within model limits | Supported on normalized fixtures within model limits | Unsupported | +| Authenticated venue or real-game acceptance | Separate permission-gated verification | Deferred real-data pilot | Access and adapter evidence unresolved | +| Live execution acceptance | Not established by this MVP | Not established by this MVP | Not established by this MVP | + +`polymarket_us` means Polymarket US. International `polymarket`, `novig`, and +unknown venue identifiers are rejected by the price-rule and recording APIs. +Existing provider-specific modules elsewhere in the package are not proof that +the local paper pipeline supports their live capture or execution paths. + +The simulator runs at most one buy/exit cycle. It uses the next eligible book, +fill-or-kill semantics, a 0.01 model lot, and caller-supplied per-contract fees +on both sides. Insufficient executable depth cancels the whole order; no partial +fill is booked. Fees are exact decimal assumptions, not implemented venue fee +schedules. No queue priority, slippage model beyond recorded depth, settlement, +or profitability claim is implied. A valid live stream sequence alone is not +proof of market freshness. Normalized replay enforces source age and ordering. + +See [Local Paper MVP Acceptance](./local-paper-acceptance) for executable +acceptance cases and the release-candidate gates. Additional venue support must +pass those shared cases with explicit venue-specific assumptions before this +matrix is expanded. Novig research does not enable an adapter. + ## Compatibility policy - Stable symbols retain compatible behavior within a minor release line. diff --git a/examples/09_daily_nba_markets.py b/examples/09_daily_nba_markets.py index 6ad27417..c95f5b8e 100644 --- a/examples/09_daily_nba_markets.py +++ b/examples/09_daily_nba_markets.py @@ -54,7 +54,15 @@ async def fetch_daily_nba_markets( if not markets.empty: preview_columns = [ column - for column in ("ticker", "title", "home_team", "away_team", "game_date", "yes_ask", "volume") + for column in ( + "ticker", + "title", + "home_team", + "away_team", + "game_date", + "yes_ask", + "volume", + ) if column in markets.columns ] if preview_columns: @@ -64,11 +72,14 @@ async def fetch_daily_nba_markets( return output_path - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Fetch and store a daily NBA market snapshot.") - parser.add_argument("--status", default="open", help="Kalshi market status filter, default: open") - parser.add_argument("--limit", type=int, default=200, help="Maximum markets to fetch, default: 200") + parser.add_argument( + "--status", default="open", help="Kalshi market status filter, default: open" + ) + parser.add_argument( + "--limit", type=int, default=200, help="Maximum markets to fetch, default: 200" + ) parser.add_argument( "--output-dir", type=Path, @@ -83,7 +94,6 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() - def main() -> None: args = parse_args() asyncio.run( diff --git a/examples/10_daily_nba_markets_polymarket.py b/examples/10_daily_nba_markets_polymarket.py index 573bd9b0..1cc08f5b 100644 --- a/examples/10_daily_nba_markets_polymarket.py +++ b/examples/10_daily_nba_markets_polymarket.py @@ -81,9 +81,7 @@ def fetch_daily_polymarket_markets( output_path = output_dir / f"polymarket_{sport.lower()}_markets_{snapshot_date}.csv" markets.to_csv(output_path, index=False) - print( - f"Wrote {len(markets)} Polymarket {sport.upper()} markets to {output_path.as_posix()}" - ) + print(f"Wrote {len(markets)} Polymarket {sport.upper()} markets to {output_path.as_posix()}") if not markets.empty: preview_columns = [ column @@ -122,7 +120,9 @@ def parse_args() -> argparse.Namespace: default="open", help="Optional status filter applied after fetch, default: open", ) - parser.add_argument("--limit", type=int, default=200, help="Maximum markets to fetch, default: 200") + parser.add_argument( + "--limit", type=int, default=200, help="Maximum markets to fetch, default: 200" + ) parser.add_argument( "--output-dir", type=Path, diff --git a/neural/contracts/registry.py b/neural/contracts/registry.py index 3618bfee..572f1993 100644 --- a/neural/contracts/registry.py +++ b/neural/contracts/registry.py @@ -105,9 +105,7 @@ def _semantic_details(payload: dict[str, Any]) -> list[str]: lineage = payload["lineageRefs"] contract_payload = payload["payload"] - lineage_identities = [ - (str(ref["schemaName"]), str(ref["objectId"])) for ref in lineage - ] + lineage_identities = [(str(ref["schemaName"]), str(ref["objectId"])) for ref in lineage] if len(lineage_identities) != len(set(lineage_identities)): details.append("lineageRefs: duplicate schemaName and objectId") diff --git a/neural/data_collection/polymarket_us.py b/neural/data_collection/polymarket_us.py index cccbded6..d597a65c 100644 --- a/neural/data_collection/polymarket_us.py +++ b/neural/data_collection/polymarket_us.py @@ -32,11 +32,12 @@ def _extract_game_context(raw: dict[str, Any]) -> dict[str, Any]: "home_team": home_team, "away_team": away_team, "game_date": raw.get("gameStartTime") or raw.get("startDate") or raw.get("endDate"), - "market_type": raw.get("sportsMarketTypeV2") or raw.get("sportsMarketType") or raw.get("marketType"), + "market_type": raw.get("sportsMarketTypeV2") + or raw.get("sportsMarketType") + or raw.get("marketType"), } - @dataclass(slots=True) class PolymarketUSConfig: sport: str | None = None diff --git a/neural/kernel/capabilities.py b/neural/kernel/capabilities.py index a71ce1f2..29aca42f 100644 --- a/neural/kernel/capabilities.py +++ b/neural/kernel/capabilities.py @@ -61,6 +61,55 @@ def as_dict(self) -> dict[str, str | None]: extra="trading", summary="Kalshi authentication and signed HTTP clients.", ), + Capability( + name="data.kalshi", + module="neural.kalshi", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Read-only Kalshi markets and decimal books; bounded pagination and retries.", + ), + Capability( + name="stream.kalshi", + module="neural.kalshi_stream", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Single-market Kalshi book recovery and recording; caller supplies authentication.", + ), + Capability( + name="sports.matching", + module="neural.sports", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Sports proposition and settlement comparison; unknown rules never imply equivalence.", + ), + Capability( + name="strategy.price_rule", + module="neural.strategy", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Price-rule specifications for Kalshi and Polymarket US; validation is not execution.", + ), + Capability( + name="recordings.paper", + module="neural.recordings", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Legacy Kalshi replay and synthetic Kalshi/Polymarket US YES sports recordings.", + ), + Capability( + name="simulation.paper", + module="neural.paper", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Offline next-book fill-or-kill, one buy/exit cycle; supplied fees, no settlement.", + ), + Capability( + name="experiments.paper", + module="neural.paper_worker", + status=CapabilityStatus.EXPERIMENTAL, + extra=None, + summary="Durable local paper jobs; read-only history/comparison through neural.paper_query.", + ), Capability( name="data_collection", module="neural.data_collection", diff --git a/neural/trading/client.py b/neural/trading/client.py index 796f5338..8c060bdf 100644 --- a/neural/trading/client.py +++ b/neural/trading/client.py @@ -386,5 +386,3 @@ def _run_coro_sync(coro: Any) -> Any: "Cannot execute sync paper-trading call inside a running event loop. " "Use await TradingClient.place_order_async(..., paper=True)." ) - - diff --git a/neural/trading/polymarket_us_adapter.py b/neural/trading/polymarket_us_adapter.py index 1b9ffd4b..28f352c8 100644 --- a/neural/trading/polymarket_us_adapter.py +++ b/neural/trading/polymarket_us_adapter.py @@ -63,7 +63,9 @@ def __post_init__(self) -> None: api_secret = self.api_secret or creds.get("api_secret") passphrase = self.passphrase or creds.get("passphrase") self.base_url = (self.base_url or get_polymarket_us_base_url()).rstrip("/") - self.public_base_url = (self.public_base_url or DEFAULT_POLYMARKET_US_PUBLIC_BASE_URL).rstrip("/") + self.public_base_url = ( + self.public_base_url or DEFAULT_POLYMARKET_US_PUBLIC_BASE_URL + ).rstrip("/") self._http = self.session or requests.Session() if api_key is None or api_secret is None or passphrase is None: @@ -122,7 +124,9 @@ def list_markets( def get_quote(self, market_id: str) -> NormalizedQuote: market_lookup_path = ( - f"/v1/market/id/{market_id}" if str(market_id).isdigit() else f"/v1/market/slug/{market_id}" + f"/v1/market/id/{market_id}" + if str(market_id).isdigit() + else f"/v1/market/slug/{market_id}" ) market_payload = self._request_public("GET", market_lookup_path) market_row = market_payload.get("market") or market_payload.get("data") or market_payload @@ -210,7 +214,9 @@ def get_candles( if end_ts_ms is not None: params["end_ts"] = end_ts_ms - payload = self._request("GET", f"/api/v1/markets/{market_id}/candles", params=params, require_auth=True) + payload = self._request( + "GET", f"/api/v1/markets/{market_id}/candles", params=params, require_auth=True + ) rows = payload.get("candles") or payload.get("data") or [] if not isinstance(rows, list): return [] @@ -226,7 +232,9 @@ def get_trade_replay( params: dict[str, Any] = {"limit": limit} if cursor: params["cursor"] = cursor - payload = self._request("GET", f"/api/v1/markets/{market_id}/trades", params=params, require_auth=True) + payload = self._request( + "GET", f"/api/v1/markets/{market_id}/trades", params=params, require_auth=True + ) rows = payload.get("trades") or payload.get("data") or [] clean_rows = rows if isinstance(rows, list) else [] return { @@ -245,7 +253,9 @@ def get_market_events( params: dict[str, Any] = {"limit": limit} if cursor: params["cursor"] = cursor - payload = self._request("GET", f"/api/v1/markets/{market_id}/events", params=params, require_auth=True) + payload = self._request( + "GET", f"/api/v1/markets/{market_id}/events", params=params, require_auth=True + ) rows = payload.get("events") or payload.get("data") or [] clean_rows = rows if isinstance(rows, list) else [] return { diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py index 34510f6e..ca59ae6f 100644 --- a/scripts/validate_docs.py +++ b/scripts/validate_docs.py @@ -66,9 +66,7 @@ def validate_fumadocs_navigation(self) -> None: page_path.is_dir() and (page_path / "meta.json").exists() ): relative_meta = meta_file.relative_to(self.docs_dir) - self.errors.append( - f"Navigation entry not found in {relative_meta}: {page}" - ) + self.errors.append(f"Navigation entry not found in {relative_meta}: {page}") def validate_required_sections(self) -> None: """Check for required documentation sections.""" diff --git a/tests/data_collection/test_polymarket_us_source.py b/tests/data_collection/test_polymarket_us_source.py index cb7155c0..f3aebec9 100644 --- a/tests/data_collection/test_polymarket_us_source.py +++ b/tests/data_collection/test_polymarket_us_source.py @@ -40,11 +40,19 @@ def list_markets( "marketSides": [ { "description": "Chicago", - "team": {"name": "Chicago Bulls", "ordering": "away", "league": "nba"}, + "team": { + "name": "Chicago Bulls", + "ordering": "away", + "league": "nba", + }, }, { "description": "New York", - "team": {"name": "New York Knicks", "ordering": "home", "league": "nba"}, + "team": { + "name": "New York Knicks", + "ordering": "home", + "league": "nba", + }, }, ], } @@ -140,7 +148,6 @@ def test_get_markets_df_enriches_sports_rows() -> None: assert markets.iloc[0]["game_date"] == pd.Timestamp("2026-03-10T23:00:00Z") - def test_market_history_uses_adapter_public_candles_api() -> None: source = PolymarketUSMarketsSource(adapter=FakeAdapter()) history = source.get_market_history("MKT-1") diff --git a/tests/exchanges/test_polymarket_adapter.py b/tests/exchanges/test_polymarket_adapter.py index a195613a..5d7ad9d0 100644 --- a/tests/exchanges/test_polymarket_adapter.py +++ b/tests/exchanges/test_polymarket_adapter.py @@ -315,8 +315,6 @@ def test_numeric_parsing_helpers_return_none_for_invalid_values() -> None: assert _to_float(object()) is None - - def test_list_markets_paginates_until_sport_filter_is_satisfied( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -345,8 +343,20 @@ def request( "active": True, "closed": False, "marketSides": [ - {"team": {"name": "A", "league": "nfl", "ordering": "away"}}, - {"team": {"name": "B", "league": "nfl", "ordering": "home"}}, + { + "team": { + "name": "A", + "league": "nfl", + "ordering": "away", + } + }, + { + "team": { + "name": "B", + "league": "nfl", + "ordering": "home", + } + }, ], } ] @@ -364,8 +374,20 @@ def request( "active": True, "closed": False, "marketSides": [ - {"team": {"name": "A", "league": "nba", "ordering": "away"}}, - {"team": {"name": "B", "league": "nba", "ordering": "home"}}, + { + "team": { + "name": "A", + "league": "nba", + "ordering": "away", + } + }, + { + "team": { + "name": "B", + "league": "nba", + "ordering": "home", + } + }, ], } ] diff --git a/tests/kernel/test_capabilities.py b/tests/kernel/test_capabilities.py index c5054773..21dd7011 100644 --- a/tests/kernel/test_capabilities.py +++ b/tests/kernel/test_capabilities.py @@ -23,6 +23,28 @@ def test_unknown_capability_fails_clearly() -> None: get_capability("missing") +@pytest.mark.parametrize( + "name,module", + [ + ("data.kalshi", "neural.kalshi"), + ("stream.kalshi", "neural.kalshi_stream"), + ("sports.matching", "neural.sports"), + ("strategy.price_rule", "neural.strategy"), + ("recordings.paper", "neural.recordings"), + ("simulation.paper", "neural.paper"), + ("experiments.paper", "neural.paper_worker"), + ], +) +def test_local_paper_capabilities_are_experimental_base_install(name, module) -> None: + import importlib + + capability = get_capability(name) + assert capability.module == module + assert capability.status is CapabilityStatus.EXPERIMENTAL + assert capability.extra is None + assert importlib.import_module(module).__name__ == module + + def test_stable_kernel_import_does_not_load_optional_stacks() -> None: code = """ import json diff --git a/tests/streaming/test_deterministic_cross_provider_replay.py b/tests/streaming/test_deterministic_cross_provider_replay.py index 07f4d6c0..7b46d136 100644 --- a/tests/streaming/test_deterministic_cross_provider_replay.py +++ b/tests/streaming/test_deterministic_cross_provider_replay.py @@ -7,7 +7,9 @@ def _load_example_module(): root = Path(__file__).resolve().parents[2] module_path = root / "examples" / "13_deterministic_cross_provider_replay.py" - spec = importlib.util.spec_from_file_location("deterministic_cross_provider_replay", module_path) + spec = importlib.util.spec_from_file_location( + "deterministic_cross_provider_replay", module_path + ) assert spec is not None assert spec.loader is not None module = importlib.util.module_from_spec(spec) diff --git a/tests/test_recordings.py b/tests/test_recordings.py index 22059890..79f61551 100644 --- a/tests/test_recordings.py +++ b/tests/test_recordings.py @@ -27,8 +27,8 @@ def run(spec, path): return simulate_recording(spec, path, initial_cash="10", fee_per_contract="0.01") -def changed_recording(tmp_path, change): - spec, fixture = inputs() +def changed_recording(tmp_path, change, venue="polymarket-us"): + spec, fixture = inputs(venue) rows = [json.loads(line) for line in fixture.read_text().splitlines()] change(rows) path = tmp_path / "modified.jsonl" @@ -95,6 +95,7 @@ def test_no_header_cannot_label_opposite_trade_as_yes_team(tmp_path): describe_recording(path) +@pytest.mark.parametrize("venue", ["kalshi", "polymarket-us"]) @pytest.mark.parametrize( "change,reason", [ @@ -123,14 +124,91 @@ def test_no_header_cannot_label_opposite_trade_as_yes_team(tmp_path): (lambda rows: rows.pop(1), "outside recording session"), ], ) -def test_quality_faults_fail_before_returning_a_report(tmp_path, change, reason): - spec, path = changed_recording(tmp_path, change) +def test_quality_faults_fail_before_returning_a_report(tmp_path, change, reason, venue): + spec, path = changed_recording(tmp_path, change, venue) with pytest.raises(ValueError, match=reason): run(spec, path) with pytest.raises(ValueError): describe_recording(path) +@pytest.mark.parametrize("venue", ["kalshi", "polymarket-us"]) +@pytest.mark.parametrize("side", ["buy", "sell"]) +@pytest.mark.parametrize("depth", ["1.99", "2.00"]) +def test_fill_or_kill_at_exact_depth_boundary(tmp_path, venue, side, depth): + def change(rows): + index, ladder, price = (3, "asks", "0.42") if side == "buy" else (5, "bids", "0.7") + rows[index][ladder] = [[price, depth]] + + spec, path = changed_recording(tmp_path, change, venue) + result = run(spec, path) + row = result["trace"][2 if side == "buy" else 4] + if depth == "1.99": + assert row["action"] == "cancel" + assert row["reason"] == "insufficient_executable_depth" + assert result["position"] == ("0" if side == "buy" else "2") + assert result["cash"] == ("10" if side == "buy" else "9.155") + assert result["realized_pnl"] == "0" + else: + assert row["action"] == "fill" + assert row["levels"] == [{"price": "0.42" if side == "buy" else "0.7", "quantity": "2"}] + assert row["fees"] == "0.02" + + +@pytest.mark.parametrize("venue", ["kalshi", "polymarket-us"]) +@pytest.mark.parametrize("cash", ["0.919999999999999999", "0.92"]) +def test_reserved_cash_boundary_includes_fees(venue, cash): + spec, path = inputs(venue) + result = simulate_recording(spec, path, initial_cash=cash, fee_per_contract="0.01") + if cash == "0.92": + assert result["trace"][1]["action"] == "intent" + assert result["trace"][1]["reserved_cash"] == "0.92" + assert result["realized_pnl"] == "0.52" + else: + assert result["trace"][1]["reason"] == "insufficient_cash" + assert result["cash"] == cash + assert result["position"] == "0" + assert not any(row["action"] == "fill" for row in result["trace"]) + + +@pytest.mark.parametrize("venue", ["kalshi", "polymarket-us"]) +@pytest.mark.parametrize("source", ["2026-09-10T17:59:31Z", "2026-09-10T17:59:30.999999Z"]) +def test_source_age_boundary_to_microsecond(tmp_path, venue, source): + spec, path = changed_recording(tmp_path, lambda rows: rows[2].update(source_at=source), venue) + if source.endswith("31Z"): + assert run(spec, path)["cash"] == "10.52" + else: + with pytest.raises(ValueError, match="stale"): + run(spec, path) + + +@pytest.mark.parametrize("venue", ["novig", "polymarket", "unknown"]) +def test_unsupported_venue_cannot_create_strategy_or_recording(tmp_path, venue): + spec, _ = inputs() + with pytest.raises(ValueError, match="venue"): + replace(spec, venue=venue) + _, path = changed_recording(tmp_path, lambda rows: rows[0].update(venue=venue)) + with pytest.raises(ValueError, match="venue"): + describe_recording(path) + + +@pytest.mark.parametrize("venue", ["kalshi", "polymarket-us"]) +@pytest.mark.parametrize("cash", ["3.439999999999999999", "3.44"]) +def test_exit_fees_never_make_cash_negative(venue, cash): + spec, path = inputs(venue) + spec = replace(spec, max_exposure_usd="3") + result = simulate_recording(spec, path, initial_cash=cash, fee_per_contract="1") + if cash == "3.44": + assert result["trace"][4]["action"] == "fill" + assert result["cash"] == result["position"] == "0" + assert result["realized_pnl"] == "-3.44" + else: + assert result["trace"][4]["reason"] == "insufficient_cash_for_fees" + assert result["cash"] == "0.614999999999999999" + assert result["position"] == "2" + assert result["realized_pnl"] == "0" + + def test_wrong_venue_rejected_even_when_market_ids_match(): spec, path = inputs() with pytest.raises(ValueError, match="venue/market"):