diff --git a/docs/architecture/meta.json b/docs/architecture/meta.json index 351187ee..5574dd6b 100644 --- a/docs/architecture/meta.json +++ b/docs/architecture/meta.json @@ -1,4 +1,10 @@ { "title": "Architecture", - "pages": ["start-here", "overview", "contracts", "stability"] + "pages": [ + "start-here", + "overview", + "contracts", + "sports-matching", + "stability" + ] } diff --git a/docs/architecture/sports-matching.mdx b/docs/architecture/sports-matching.mdx new file mode 100644 index 00000000..726813e7 --- /dev/null +++ b/docs/architecture/sports-matching.mdx @@ -0,0 +1,126 @@ +--- +title: Sports proposition matching +description: Compare MLB winner propositions while preserving settlement differences and unknown rules +--- + +`neural.sports` adds a versioned, offline contract for MLB team-winner markets. +It separates **the sporting proposition** from **the settlement policy**. +Matching teams and a game does not establish economically equivalent contracts. + +## API and existing consumers + +```python +from neural.sports import SportsMarket, compare_sports_markets + +left = SportsMarket.from_dict(left_payload) +right = SportsMarket.from_dict(right_payload) +comparison = compare_sports_markets(left, right) +print(comparison.to_dict()) +``` + +The output contains `status`, `proposition`, `settlement`, `differences`, and +`unknowns`. Each status is `compatible`, `different`, or `unknown`. Field names +identify the reason; the input contracts retain each rule value and its source +for display. The function makes no network calls or settlement decisions. + +This is additive to `StrategySpec` and `neural.contracts` v1. Their schemas, +hashes and replay behavior remain unchanged. Existing `NormalizedMarket` +consumers can retain `SportsMarket.to_dict()` under +`market.metadata["sports_proposition"]`; keep `market_id` equal to the native +identifier already used by the adapter. No broker abstraction or dependency is +introduced. Novig is accepted as a metadata venue, not as an executable adapter. + +## Version 1 identity + +Wire payloads require every documented dataclass field, reject extra fields, +and use `schema_version: "1.0.0"`. `sport`, `league`, and `market_type` are fixed +to `baseball`, `mlb`, and `winner`. Identity fields are: + +| Fields | Meaning | +| --- | --- | +| `venue`, `event_id`, `market_id`, `outcome_id` | Original venue and native identifiers; never replaced with canonical IDs | +| `raw_home_team_id`, `raw_away_team_id` | Original venue team IDs, or null when absent | +| `canonical_event_id`, `home_team_id`, `away_team_id` | Caller-resolved identities in a shared namespace; null when unresolved | +| `outcome_team_id` | The selected team-wins proposition, not a venue-specific yes/no label | +| `game_date` | Original official local schedule date (`YYYY-MM-DD`), retained after postponement | +| `game_number` | 1 or 2, explicitly established; null when unknown | +| `period` | `full_game` for the supported comparison; other named segments remain representable but excluded | + +The canonical event ID must identify one game, not a matchup or date bucket. +Use an authoritative common game identifier or an explicitly reviewed mapping. +An adapter must not invent mappings by lowercasing team names, translating an +unknown game number to 1, or truncating a UTC start timestamp into a local game +date. A rescheduled start time belongs in venue metadata; it does not silently +create a new canonical game. Home/away changes require mapping review. + +Every canonical field must agree and be known before settlement comparison. +Two missing values produce `unknown`. Game 1 and game 2 are different even if +an upstream mapper accidentally reuses the event ID. Opposite team outcomes +are different. Partial-game markets are excluded even when both periods agree. + +## Rule evidence and compatibility + +`SettlementRules` contains `terms` and `complete`. Each `RuleEvidence` contains: + +- `name`: one of `winner`, `extra_innings`, `forfeit`, `postponement`, + `cancellation`, `shortened_game`, `settlement_source`, `exceptional_payout`, + `venue_change`, or `replay`. +- `value`: a reviewed semantic policy identifier. Preserve material exceptions, + deadlines, source hierarchies, and payout procedures in its meaning. Raw text + similarity is not semantic equality. +- `source_url`, `source_sha256`, `retrieved_at`: HTTPS source, exact retrieved + content digest (null if unavailable), and timezone-aware observation time. + The digest is a content version, not a claimed publisher revision number. +- `scope`: `listed_contract`, `series`, `guidance`, or explicitly synthetic + `fixture`. + +`complete` defaults to false. Set it only after reviewing the listed contract +and applicable supplemental rules against every v1 dimension. If an exception +cannot be represented faithfully, leave review incomplete. Series PDFs and +general FAQs can surface documented differences, but cannot establish complete +listed-contract compatibility. A missing rule or snapshot also prevents it. + +For matching propositions, a known policy difference yields `different`, even +if other fields remain unknown; those unknowns are still returned. Equal rule +values require full source coverage to produce `compatible`. Source URLs need +not be identical across venues: compare reviewed meanings, retain both sources. +Synthetic rules require `fixture:`-prefixed native event, market and outcome +IDs, plus a `fixture:`-prefixed canonical event ID when known. Copying fixture +rules onto real market identities is rejected. Synthetic rules can compare +with synthetic rules, but cannot certify real contract evidence. Source URLs +must have valid HTTPS DNS names or unscoped IPv6 hosts and valid ports. IPv6 +zone identifiers are unsupported for rule sources. Wire text must encode as UTF-8. +`compatible` describes these reviewed v1 dimensions; it is +neither a legal guarantee nor a risk-free arbitrage claim. + +## Offline fixture demonstration + +```bash +uv run python examples/sports_matching_demo.py +uv run pytest --no-cov tests/contracts/test_sports_matching.py +``` + +The JSON fixture uses synthetic game, team and native venue IDs throughout. +The ordinary-winner compatible pair uses identical **synthetic** rules. Other +cases demonstrate forfeit differences, postponed-game differences, doubleheader +separation, partial-game exclusion, missing rules and an unknown game number. +The demo tests matching and rule comparison, not game settlement or fill quality. + +Rule-reference cases retain source hashes observed September 10, 2026: + +- [Kalshi BASEBALLGAMEWIN terms](https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf) + distinguish forfeits before first pitch from those after play starts and use + a 48-hour postponement window. The fixture marks this as series evidence. +- [Polymarket US listed TB–ATL market](https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10) + names MLB, includes extra innings, and specifies a rescheduling date within + two weeks or last fair market price. Its response hash includes mutable market + data. The fixture does not infer unlisted forfeit or shortened-game rules from + the general FAQ. +- [Novig contract directory](https://support.novig.com/en/articles/16083642-contracts) + links the MLB Winner Series PDF. Its rule-reference fixture records the linked + PDF URL without temporary access parameters and the PDF content hash. The + series voids a forfeit without an on-field result; postponement terms include + season-dependent windows and date-update exceptions. + +These are bounded policy examples, not verified market mappings or a complete +current rules database. The recorded inputs do not claim three-venue equivalence. diff --git a/docs/trading/durable-paper.mdx b/docs/trading/durable-paper.mdx index 22ae4344..058f85f5 100644 --- a/docs/trading/durable-paper.mdx +++ b/docs/trading/durable-paper.mdx @@ -55,6 +55,13 @@ single buy/exit cycle, no settlement or freshness certification. Model identity is part of each job; incompatible future models must fail rather than silently reinterpret queued work. No hosted worker or Vaticor UI is deployed here. +The current model is `neural-paper/2` (`neural.paper.PAPER_MODEL`). The model +participates in the job ID, so resubmitting model-1 inputs under model 2 creates +a new job instead of returning an old result. Completed model-1 jobs remain +available through `inspect`. Queued model-1 jobs fail explicitly as unsupported; +submit the original strategy, recording and assumptions again to run model 2. +Old results are not silently recalculated or relabeled with the new model. + ```sh pytest tests/test_paper_worker.py -q ``` diff --git a/docs/trading/recorded-paper.mdx b/docs/trading/recorded-paper.mdx index d21eda48..3f973e10 100644 --- a/docs/trading/recorded-paper.mdx +++ b/docs/trading/recorded-paper.mdx @@ -6,8 +6,9 @@ description: Run one reproducible offline price-rule cycle without exchange acce # Recorded-book paper simulation `neural.paper` runs a validated StrategySpec over a complete `kalshi-book/1` -recording from `neural.kalshi_stream`. No credentials, network calls, live -orders, or automatic persistence. This is separate from the legacy paper client. +recording or a synthetic `neural-book/1` recording for Kalshi or Polymarket US. +It makes no credential or network calls. The same simulator and durable paper +queue handle both venues. This is separate from the legacy paper client. ```sh python -m neural.paper --strategy examples/strategy-price-rule.json \ @@ -20,6 +21,9 @@ Pass zero explicitly only for an idealized zero-fee experiment. Python callers use `simulate_recording(spec, path, initial_cash="100", fee_per_contract="0.01")`. The returned dictionary is JSON serializable. +The current model is `neural-paper/2`, available as `neural.paper.PAPER_MODEL`. +Model 2 versions source-causal fills and the synthetic sports recording boundary; +its report and durable job identities differ from model 1 even for identical input. ## Execution assumptions @@ -32,11 +36,16 @@ fee_per_contract="0.01")`. The returned dictionary is JSON serializable. fee. Cash, position and acquisition-cost exposure caps apply before entry. A single pending intent prevents overlapping reservations. Fees apply both ways. - Disconnect cancels pending intent, preserving holdings. Pending orders expire - after 30 receive-time seconds (configurable). This is **not** a source freshness - or market-open check. Source timestamps, queue priority, impact, other traders, - actual venue fees and account-wide multi-strategy risk are not modeled. + after 30 receive-time seconds (configurable). This order deadline is separate + from source freshness: normalized books require a source timestamp no more + than 30 seconds before observation. A source-dated next book must be strictly + newer than the pending intent's receipt time, or the intent cancels with + `source_not_after_intent`. Legacy books without source timestamps retain their + receipt-time behavior. Market opening, queue priority, impact, other traders, actual + venue fees and account-wide multi-strategy risk are not modeled. - Paper lot is 0.01 contracts; this is a model assumption, not venue certification. - Kalshi recordings only; Polymarket US remains a later adapter milestone. + Full depth in a synthetic recording is a fixture assertion, not evidence of + liquidity available at a venue. The entire recording must validate before a report is returned. Corrupt tails, sequence gaps, regressing receive times, crossed books, ticker mismatches and @@ -58,3 +67,72 @@ pytest tests/test_paper_recording.py -q Passing this simulation does not establish profitability, live exchange compatibility, approval to trade, or a completed hosted MVP. + +## Same strategy across synthetic sports venues + +The committed fixtures describe the same synthetic MLB full-game winner +proposition with different native venue IDs. The price thresholds, quantity and +risk caps remain identical. Only `venue` and `market_id` change in StrategySpec. + +```sh +python -m neural.paper \ + --strategy examples/recordings/synthetic-polymarket-us-strategy.json \ + --recording examples/recordings/synthetic-polymarket-us.jsonl \ + --cash 10 --fee-per-contract 0.01 +``` + +Repeat with `synthetic-kalshi-strategy.json` and `synthetic-kalshi.jsonl`. +Both fixtures yield cash `10.52` and realized PnL `0.52`, including the explicit +`0.01` fee assumption on each traded contract in each direction. These are +constructed examples, not measured venue performance or current fee schedules. +Submitting either pair through `PaperJobs.submit` uses the same durable queue. + +The new `neural.recordings` boundary autodetects both formats. +`describe_recording(path, max_events=10000)` validates the entire recording and +returns venue/native market, selected outcome, event/book/reset counts, +reconnect/disconnect counts, observation start/end, final book source timestamp, +provenance and the attached `SportsMarket`. `replay_book_recording(path)` yields +the existing stream-event shape; consumers must exhaust it to validate EOF. +Replay parses normalized metadata and rows from one open file handle. Simulation +and summary generation also compare that header with their accepted metadata, +rejecting path replacement that would mix sports identity with another book. + +## Normalized recording contract + +`neural-book/1` is an offline synthetic format. It does not add a Polymarket US +collector or turn a BBO quote into depth. Every JSONL line must end with a newline. + +- First line: exactly `version`, `kind: "header"`, `venue`, `market_id`, `outcome`, + `sports_market`, and `provenance: "synthetic"`. The sports contract must have + the same venue and native market. `outcome` must be `"yes"`: the attached + sports identity is the named YES team-wins proposition. Normalized NO-side + recordings are rejected because the sports contract cannot identify an + opposite binary side. Legacy Kalshi recordings still support YES and NO. +- Session boundaries: `version`, `kind: "reset"`, `received_at`, and `reason` + (`connecting` or `disconnected`). Start connected, alternate boundaries, end + disconnected. Reconnect cancels pending orders and resets sequence tracking. +- Book lines: `version`, `kind: "book"`, `received_at`, `source_at`, `sequence`, + `quality: "full_depth"`, `bids`, and `asks`. Both ladders describe the selected + outcome and contain `[price, quantity]` decimal-string pairs. Bids descend and + asks ascend with unique prices, positive quantities and prices in `[0,1]`. + Each session starts at sequence 1 and increments without gaps. + +Missing, one-sided, crossed, unordered, stale, future-dated or malformed books +fail the entire replay. Source timestamps cannot regress within a session; +observation timestamps cannot regress across the file. Prices and sizes retain +up to 18 fractional digits without converting through binary floats. + +Normalized reports add `sports_market`, recording identity/provenance and +source timestamps/full-depth quality in book trace rows. They explicitly leave +`market_compatibility.status` as `unknown`: one recording alone cannot establish +cross-venue equivalence. Compare two attached contracts with +`compare_sports_markets(SportsMarket.from_dict(left), +SportsMarket.from_dict(right)).to_dict()` to show `compatible`, `different` or +`unknown` and reasons. Compatible fixture rules do not certify live contracts. +Legacy Kalshi recordings without source timestamps retain their trading behavior +and report shape; the new model version changes content identities. Source-dated +books also use the causal fill guard. + +```sh +pytest tests/test_recordings.py tests/test_paper_recording.py tests/test_paper_worker.py -q +``` diff --git a/examples/recordings/synthetic-kalshi-strategy.json b/examples/recordings/synthetic-kalshi-strategy.json new file mode 100644 index 00000000..1b864f26 --- /dev/null +++ b/examples/recordings/synthetic-kalshi-strategy.json @@ -0,0 +1,12 @@ +{ + "venue": "kalshi", + "market_id": "fixture:kalshi:winner-1", + "outcome": "yes", + "entry_price": "0.45", + "exit_price": "0.65", + "quantity": "2", + "max_position": "2", + "max_exposure_usd": "1", + "schema_version": "1.0.0", + "kind": "price_rule" +} diff --git a/examples/recordings/synthetic-kalshi.jsonl b/examples/recordings/synthetic-kalshi.jsonl new file mode 100644 index 00000000..fb18e40d --- /dev/null +++ b/examples/recordings/synthetic-kalshi.jsonl @@ -0,0 +1,7 @@ +{"version":"neural-book/1","kind":"header","venue":"kalshi","market_id":"fixture:kalshi:winner-1","outcome":"yes","sports_market":{"venue":"kalshi","event_id":"fixture:kalshi:event-1","market_id":"fixture:kalshi:winner-1","outcome_id":"fixture:kalshi:home-wins","raw_home_team_id":"fixture:kalshi:ATL","raw_away_team_id":"fixture:kalshi:TB","canonical_event_id":"fixture:mlb:2026-09-10:tb-atl:1","home_team_id":"mlb:atl","away_team_id":"mlb:tb","outcome_team_id":"mlb:atl","game_date":"2026-09-10","game_number":1,"period":"full_game","rules":{"terms":[{"name":"winner","value":"official_game_winner","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"extra_innings","value":"included","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"forfeit","value":"official_awarded_winner","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"postponement","value":"within_48_hours_of_original_start","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"cancellation","value":"fair_value","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"shortened_game","value":"official_game_result","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"settlement_source","value":"mlb_only","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"exceptional_payout","value":"fair_value","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"venue_change","value":"contract_stands","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"replay","value":"original_result","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"}],"complete":true},"schema_version":"1.0.0","sport":"baseball","league":"mlb","market_type":"winner"},"provenance":"synthetic"} +{"version":"neural-book/1","kind":"reset","received_at":"2026-09-10T18:00:00Z","reason":"connecting"} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:01Z","source_at":"2026-09-10T18:00:01Z","sequence":1,"quality":"full_depth","bids":[["0.30","1.25"],["0.25","1.75"]],"asks":[["0.40","0.75"],["0.42","2.25"]]} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:02Z","source_at":"2026-09-10T18:00:02Z","sequence":2,"quality":"full_depth","bids":[["0.30","1.25"],["0.25","1.75"]],"asks":[["0.40","0.75"],["0.42","2.25"]]} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:03Z","source_at":"2026-09-10T18:00:03Z","sequence":3,"quality":"full_depth","bids":[["0.70","1.25"],["0.68","1.75"]],"asks":[["0.80","0.75"],["0.82","2.25"]]} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:04Z","source_at":"2026-09-10T18:00:04Z","sequence":4,"quality":"full_depth","bids":[["0.70","1.25"],["0.68","1.75"]],"asks":[["0.80","0.75"],["0.82","2.25"]]} +{"version":"neural-book/1","kind":"reset","received_at":"2026-09-10T18:00:05Z","reason":"disconnected"} diff --git a/examples/recordings/synthetic-polymarket-us-strategy.json b/examples/recordings/synthetic-polymarket-us-strategy.json new file mode 100644 index 00000000..c41384b9 --- /dev/null +++ b/examples/recordings/synthetic-polymarket-us-strategy.json @@ -0,0 +1,12 @@ +{ + "venue": "polymarket_us", + "market_id": "fixture:polymarket_us:winner-1", + "outcome": "yes", + "entry_price": "0.45", + "exit_price": "0.65", + "quantity": "2", + "max_position": "2", + "max_exposure_usd": "1", + "schema_version": "1.0.0", + "kind": "price_rule" +} diff --git a/examples/recordings/synthetic-polymarket-us.jsonl b/examples/recordings/synthetic-polymarket-us.jsonl new file mode 100644 index 00000000..43563df9 --- /dev/null +++ b/examples/recordings/synthetic-polymarket-us.jsonl @@ -0,0 +1,7 @@ +{"version":"neural-book/1","kind":"header","venue":"polymarket_us","market_id":"fixture:polymarket_us:winner-1","outcome":"yes","sports_market":{"venue":"polymarket_us","event_id":"fixture:polymarket_us:event-1","market_id":"fixture:polymarket_us:winner-1","outcome_id":"fixture:polymarket_us:home-wins","raw_home_team_id":"fixture:polymarket_us:ATL","raw_away_team_id":"fixture:polymarket_us:TB","canonical_event_id":"fixture:mlb:2026-09-10:tb-atl:1","home_team_id":"mlb:atl","away_team_id":"mlb:tb","outcome_team_id":"mlb:atl","game_date":"2026-09-10","game_number":1,"period":"full_game","rules":{"terms":[{"name":"winner","value":"official_game_winner","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"extra_innings","value":"included","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"forfeit","value":"official_awarded_winner","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"postponement","value":"within_48_hours_of_original_start","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"cancellation","value":"fair_value","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"shortened_game","value":"official_game_result","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"settlement_source","value":"mlb_only","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"exceptional_payout","value":"fair_value","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"venue_change","value":"contract_stands","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"},{"name":"replay","value":"original_result","source_url":"https://example.invalid/nrcl-99/synthetic-rules-v1.json","source_sha256":"1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217","retrieved_at":"2026-09-10T00:00:00Z","scope":"fixture"}],"complete":true},"schema_version":"1.0.0","sport":"baseball","league":"mlb","market_type":"winner"},"provenance":"synthetic"} +{"version":"neural-book/1","kind":"reset","received_at":"2026-09-10T18:00:00Z","reason":"connecting"} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:01Z","source_at":"2026-09-10T18:00:01Z","sequence":1,"quality":"full_depth","bids":[["0.30","1.25"],["0.25","1.75"]],"asks":[["0.40","0.75"],["0.42","2.25"]]} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:02Z","source_at":"2026-09-10T18:00:02Z","sequence":2,"quality":"full_depth","bids":[["0.30","1.25"],["0.25","1.75"]],"asks":[["0.40","0.75"],["0.42","2.25"]]} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:03Z","source_at":"2026-09-10T18:00:03Z","sequence":3,"quality":"full_depth","bids":[["0.70","1.25"],["0.68","1.75"]],"asks":[["0.80","0.75"],["0.82","2.25"]]} +{"version":"neural-book/1","kind":"book","received_at":"2026-09-10T18:00:04Z","source_at":"2026-09-10T18:00:04Z","sequence":4,"quality":"full_depth","bids":[["0.70","1.25"],["0.68","1.75"]],"asks":[["0.80","0.75"],["0.82","2.25"]]} +{"version":"neural-book/1","kind":"reset","received_at":"2026-09-10T18:00:05Z","reason":"disconnected"} diff --git a/examples/sports-matching-fixtures.json b/examples/sports-matching-fixtures.json new file mode 100644 index 00000000..03148d3e --- /dev/null +++ b/examples/sports-matching-fixtures.json @@ -0,0 +1,504 @@ +{ + "schema_version": "1.0.0", + "notice": "All event, team and venue IDs are synthetic. Real source rules illustrate policy differences; they do not establish listed cross-venue equivalence. The compatible case uses synthetic rules only.", + "synthetic_rule_source": { + "canonical_json": "{\"cancellation\":\"fair_value\",\"exceptional_payout\":\"fair_value\",\"extra_innings\":\"included\",\"forfeit\":\"official_awarded_winner\",\"postponement\":\"within_48_hours_of_original_start\",\"replay\":\"original_result\",\"settlement_source\":\"mlb_only\",\"shortened_game\":\"official_game_result\",\"venue_change\":\"contract_stands\",\"winner\":\"official_game_winner\"}", + "sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217" + }, + "markets": { + "synthetic_kalshi": { + "venue": "kalshi", + "event_id": "fixture:kalshi:event-1", + "market_id": "fixture:kalshi:winner-1", + "outcome_id": "fixture:kalshi:home-wins", + "raw_home_team_id": "fixture:kalshi:ATL", + "raw_away_team_id": "fixture:kalshi:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "forfeit", + "value": "official_awarded_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "postponement", + "value": "within_48_hours_of_original_start", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "cancellation", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "shortened_game", + "value": "official_game_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "settlement_source", + "value": "mlb_only", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "exceptional_payout", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "venue_change", + "value": "contract_stands", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "replay", + "value": "original_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + } + ], + "complete": true + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "synthetic_polymarket_us": { + "venue": "polymarket_us", + "event_id": "fixture:polymarket_us:event-1", + "market_id": "fixture:polymarket_us:winner-1", + "outcome_id": "fixture:polymarket_us:home-wins", + "raw_home_team_id": "fixture:polymarket_us:ATL", + "raw_away_team_id": "fixture:polymarket_us:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "forfeit", + "value": "official_awarded_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "postponement", + "value": "within_48_hours_of_original_start", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "cancellation", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "shortened_game", + "value": "official_game_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "settlement_source", + "value": "mlb_only", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "exceptional_payout", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "venue_change", + "value": "contract_stands", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "replay", + "value": "original_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + } + ], + "complete": true + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "kalshi_series_reference": { + "venue": "kalshi", + "event_id": "fixture:kalshi:event-1", + "market_id": "fixture:kalshi:winner-1", + "outcome_id": "fixture:kalshi:home-wins", + "raw_home_team_id": "fixture:kalshi:ATL", + "raw_away_team_id": "fixture:kalshi:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "forfeit", + "value": "before_first_pitch_fair_value_after_start_official_awarded_winner", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "postponement", + "value": "within_48_hours_of_original_start", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "shortened_game", + "value": "official_game_result", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "settlement_source", + "value": "league_then_espn_cbs_fox_ap_wsj_official_broadcaster", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + } + ], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "polymarket_us_listing_reference": { + "venue": "polymarket_us", + "event_id": "fixture:polymarket_us:event-1", + "market_id": "fixture:polymarket_us:winner-1", + "outcome_id": "fixture:polymarket_us:home-wins", + "raw_home_team_id": "fixture:polymarket_us:ATL", + "raw_away_team_id": "fixture:polymarket_us:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "postponement", + "value": "rescheduled_date_within_14_days_of_original_date", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "settlement_source", + "value": "mlb_only", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "exceptional_payout", + "value": "last_fair_market_price", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + } + ], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "novig_series_reference": { + "venue": "novig", + "event_id": "fixture:novig:event-1", + "market_id": "fixture:novig:winner-1", + "outcome_id": "fixture:novig:home-wins", + "raw_home_team_id": "fixture:novig:ATL", + "raw_away_team_id": "fixture:novig:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + }, + { + "name": "forfeit", + "value": "without_on_field_result_void", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + }, + { + "name": "postponement", + "value": "48_hours_regular_season_45_days_postseason_subject_to_date_updates", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + }, + { + "name": "exceptional_payout", + "value": "void_fair_value_process", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + } + ], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "unknown_rules": { + "venue": "novig", + "event_id": "fixture:novig:event-1", + "market_id": "fixture:novig:winner-1", + "outcome_id": "fixture:novig:home-wins", + "raw_home_team_id": "fixture:novig:ATL", + "raw_away_team_id": "fixture:novig:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + } + }, + "cases": [ + { + "name": "ordinary_winner", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": {}, + "expected": "compatible", + "expected_proposition": "compatible", + "expected_field": null + }, + { + "name": "forfeit_without_on_field_result", + "left": "kalshi_series_reference", + "right": "novig_series_reference", + "right_overrides": {}, + "expected": "different", + "expected_proposition": "compatible", + "expected_field": "rules:forfeit" + }, + { + "name": "postponed_game", + "left": "kalshi_series_reference", + "right": "polymarket_us_listing_reference", + "right_overrides": {}, + "expected": "different", + "expected_proposition": "compatible", + "expected_field": "rules:postponement" + }, + { + "name": "doubleheader_game_two", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": { + "game_number": 2 + }, + "expected": "different", + "expected_proposition": "different", + "expected_field": "game_number" + }, + { + "name": "partial_game_excluded", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": { + "period": "first_5_innings" + }, + "expected": "different", + "expected_proposition": "different", + "expected_field": "scope:full_game_only" + }, + { + "name": "missing_rules", + "left": "synthetic_kalshi", + "right": "unknown_rules", + "right_overrides": {}, + "expected": "unknown", + "expected_proposition": "compatible", + "expected_field": "rules:incomplete_review" + }, + { + "name": "missing_game_number", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": { + "game_number": null + }, + "expected": "unknown", + "expected_proposition": "unknown", + "expected_field": "game_number" + } + ] +} diff --git a/examples/sports_matching_demo.py b/examples/sports_matching_demo.py new file mode 100644 index 00000000..d985287c --- /dev/null +++ b/examples/sports_matching_demo.py @@ -0,0 +1,28 @@ +"""Offline MLB proposition/rule comparison using explicitly synthetic identities. + +Run from the repository: uv run python examples/sports_matching_demo.py +No network, credentials, orders, fee assumptions or settlement simulation. +""" + +import json +from pathlib import Path + +from neural.sports import SportsMarket, compare_sports_markets + + +def main() -> None: + fixture = json.loads(Path(__file__).with_name("sports-matching-fixtures.json").read_text()) + print(fixture["notice"]) + for case in fixture["cases"]: + left = SportsMarket.from_dict(fixture["markets"][case["left"]]) + right = SportsMarket.from_dict( + {**fixture["markets"][case["right"]], **case["right_overrides"]} + ) + result = compare_sports_markets(left, right) + assert result.status == case["expected"], case["name"] + assert result.proposition == case["expected_proposition"], case["name"] + print(json.dumps({"case": case["name"], **result.to_dict()}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/neural/paper.py b/neural/paper.py index 26cb8f09..42e9050b 100644 --- a/neural/paper.py +++ b/neural/paper.py @@ -12,10 +12,15 @@ from typing import Any from neural.kalshi import _wire -from neural.kalshi_stream import replay_book_recording +from neural.recordings import ( + MAX_SOURCE_AGE_SECONDS, + RECORD_VERSION, + read_recording_metadata, + replay_book_recording, +) from neural.strategy import StrategySpec, _decimal, _decimal_text -PAPER_MODEL = "neural-paper/1" +PAPER_MODEL = "neural-paper/2" def _canonical(value: Any) -> str: @@ -36,8 +41,12 @@ def simulate_recording( Signals execute against the next book, fill-or-kill, within the same session. Fees are a caller-supplied assumption on each side, not a venue fee schedule. """ - if spec.venue != "kalshi": - raise ValueError("paper recording runner supports kalshi only") + metadata = read_recording_metadata(path) + if (spec.venue, spec.market_id) != (metadata["venue"], metadata["market_id"]): + raise ValueError("recording venue/market does not match strategy") + if metadata["outcome"] is not None and metadata["outcome"] != spec.outcome: + raise ValueError("recording outcome does not match strategy") + normalized = metadata["version"] == RECORD_VERSION for name, value in ( ("max_order_age_seconds", max_order_age_seconds), ("max_events", max_events), @@ -56,8 +65,10 @@ def simulate_recording( previous: datetime | None = None trace: list[dict[str, Any]] = [] digest = hashlib.sha256() + if normalized: + digest.update((_canonical(metadata) + "\n").encode()) books = count = 0 - for count, event in enumerate(replay_book_recording(path), 1): + for count, event in enumerate(replay_book_recording(path, expected_metadata=metadata), 1): if count > max_events: raise ValueError("recording exceeds max_events") if previous is not None and event.received_at < previous: @@ -81,12 +92,19 @@ def simulate_recording( if bids and asks and bids[0].price > asks[0].price: raise ValueError("crossed recorded book") row.update(sid=update.sid, seq=update.seq) + if normalized: + row.update( + source_at=update.source_at.isoformat() if update.source_at else None, + quality="full_depth", + ) if pending is not None: side, created = pending pending = None row.update(action="cancel", side=side) if (event.received_at - created).total_seconds() > max_order_age_seconds: row["reason"] = "order_expired" + elif update.source_at is not None and update.source_at <= created: + row["reason"] = "source_not_after_intent" else: remaining = spec.quantity notional = Decimal(0) @@ -150,7 +168,7 @@ def simulate_recording( trace.append(row) if not books: raise ValueError("recording contains no books") - report = { + report: dict[str, Any] = { "model": PAPER_MODEL, "strategy": spec.to_dict(), "strategy_id": spec.version_id, @@ -169,6 +187,17 @@ def simulate_recording( "acquisition_cost": _decimal_text(cost), "realized_pnl": _decimal_text(realized), } + if normalized: + report["sports_market"] = metadata["sports_market"] + report["market_compatibility"] = { + "status": "unknown", + "reason": "comparison_requires_second_market", + } + report["recording"] = { + key: value for key, value in metadata.items() if key != "sports_market" + } + report["assumptions"]["max_source_age_seconds"] = MAX_SOURCE_AGE_SECONDS + report["assumptions"]["data_quality"] = "synthetic full-depth snapshots" report["result_id"] = hashlib.sha256(_canonical(report).encode()).hexdigest() return report diff --git a/neural/paper_worker.py b/neural/paper_worker.py index e7d6e42f..6b20f962 100644 --- a/neural/paper_worker.py +++ b/neural/paper_worker.py @@ -79,8 +79,6 @@ def submit( max_events: int = 10000, ) -> str: """Snapshot input bytes and enqueue once; identical submissions share ID.""" - if spec.venue != "kalshi": - raise ValueError("paper jobs support kalshi only") for name, value, ceiling in ( ("max_events", max_events, 10000), ("max_order_age_seconds", max_order_age_seconds, 86400), @@ -148,7 +146,9 @@ def run_next(self) -> dict[str, Any] | None: ): raise ValueError("stored job input integrity mismatch") if config["model"] != PAPER_MODEL: - raise ValueError("stored job model is unsupported") + raise ValueError( + f"stored job model is unsupported; resubmit under {PAPER_MODEL}" + ) spec = StrategySpec.from_dict(config["strategy"]) with tempfile.TemporaryDirectory(prefix="neural-paper-") as directory: path = Path(directory) / "recording.jsonl" diff --git a/neural/recordings.py b/neural/recordings.py new file mode 100644 index 00000000..169cd22a --- /dev/null +++ b/neural/recordings.py @@ -0,0 +1,229 @@ +"""Offline, decimal-safe book boundary for paper simulation (NRCL-100). + +The normalized format currently accepts synthetic fixtures only. It is not a +venue capture client and does not certify source depth, fees or data rights. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime +from decimal import Decimal, localcontext +from pathlib import Path +from typing import Any + +from neural.kalshi import BookLevel, OrderBookSnapshot, _ticker, _timestamp +from neural.kalshi_stream import ( + MAX_RECORD, + BookUpdate, + StreamEvent, + _json, +) +from neural.kalshi_stream import ( + RECORD_VERSION as KALSHI_VERSION, +) +from neural.kalshi_stream import ( + replay_book_recording as replay_kalshi_recording, +) +from neural.sports import SportsMarket +from neural.strategy import _decimal + +RECORD_VERSION = "neural-book/1" +MAX_SOURCE_AGE_SECONDS = 30 + + +def _records(path: str | Path) -> Iterator[dict[str, Any]]: + with open(path, "rb") as source: + while line := source.readline(MAX_RECORD + 1): + if len(line) > MAX_RECORD or not line.endswith(b"\n"): + raise ValueError("oversized or truncated recording line") + yield _json(line) + + +def read_recording_metadata(path: str | Path) -> dict[str, Any]: + """Read and validate the first record only; replay must still validate EOF.""" + records = _records(path) + try: + header = next(records, None) + finally: + records.close() + return _metadata(header) + + +def _metadata(header: dict[str, Any] | None) -> dict[str, Any]: + if header is None: + raise ValueError("empty recording") + if header.get("version") == KALSHI_VERSION: + return { + "version": KALSHI_VERSION, + "venue": "kalshi", + "market_id": _ticker(header.get("ticker")), + "outcome": None, + "sports_market": None, + "provenance": "legacy_recording_unverified", + } + if ( + set(header) + != {"version", "kind", "venue", "market_id", "outcome", "sports_market", "provenance"} + or header.get("version") != RECORD_VERSION + or header.get("kind") != "header" + ): + raise ValueError("unsupported recording header or version") + if header["venue"] not in ("kalshi", "polymarket_us"): + raise ValueError("unsupported recording venue") + _ticker(header["market_id"]) + if header["outcome"] != "yes": + raise ValueError("normalized sports recordings support the YES team-wins outcome only") + if header["provenance"] != "synthetic": + raise ValueError("normalized recording currently supports synthetic provenance only") + market = SportsMarket.from_dict(header["sports_market"]) + if (market.venue, market.market_id) != (header["venue"], header["market_id"]): + raise ValueError("sports market does not match recording venue/market") + return {key: value for key, value in header.items() if key != "kind"} + + +def _at(value: Any, name: str) -> datetime: + at = _timestamp(value, name) + if at is None: + raise ValueError(f"{name} is required") + return at + + +def _levels(value: Any, side: str) -> tuple[BookLevel, ...]: + if not isinstance(value, list) or not value: + raise ValueError("full-depth recording requires both nonempty book sides") + result = [] + previous = None + for row in value: + if not isinstance(row, list) or len(row) != 2: + raise ValueError("book level must contain price and quantity decimal strings") + price, quantity = _decimal(row[0], "price"), _decimal(row[1], "quantity") + if price > 1 or quantity <= 0: + raise ValueError("book price must be in [0,1] and quantity positive") + if previous is not None and ( + (side == "bids" and price >= previous) or (side == "asks" and price <= previous) + ): + raise ValueError("book levels must have unique prices in executable order") + result.append(BookLevel(price, quantity)) + previous = price + return tuple(result) + + +def replay_book_recording( + path: str | Path, *, expected_metadata: dict[str, Any] | None = None +) -> Iterator[StreamEvent]: + """Replay either format; exhaust the iterator to validate the final boundary. + + Normalized records contain full bid/ask ladders for the named YES team-wins + proposition only. The existing binary book type supplies the boundary. + """ + records = _records(path) + try: + metadata = _metadata(next(records, None)) + if expected_metadata is not None and metadata != expected_metadata: + raise ValueError("recording metadata changed before replay") + yield from _replay_records(path, records, metadata) + finally: + records.close() + + +def _replay_records( + path: str | Path, records: Iterator[dict[str, Any]], metadata: dict[str, Any] +) -> Iterator[StreamEvent]: + if metadata["version"] == KALSHI_VERSION: + for event in replay_kalshi_recording(path): + if event.update is not None and event.update.book.ticker != metadata["market_id"]: + raise ValueError("recording market changed before replay") + yield event + return + active = False + session = sequence = 0 + previous: datetime | None = None + previous_source: datetime | None = None + for record in records: + kind = record.get("kind") + extra = ( + {"reason"} if kind == "reset" else {"source_at", "sequence", "quality", "bids", "asks"} + ) + if set(record) != {"version", "kind", "received_at"} | extra or ( + record.get("version") != RECORD_VERSION + ): + raise ValueError("unsupported normalized record shape or version") + at = _at(record["received_at"], "received_at") + if previous is not None and at < previous: + raise ValueError("recording receive timestamps must not regress") + previous = at + if kind == "reset": + if record["reason"] not in ("connecting", "disconnected"): + raise ValueError("invalid reset reason") + connecting = record["reason"] == "connecting" + if connecting == active: + raise ValueError("invalid recording session boundary") + active = connecting + if connecting: + session += 1 + sequence = 0 + previous_source = None + yield StreamEvent("reset", at, reason=record["reason"]) + continue + if kind != "book" or not active: + raise ValueError("book outside recording session or unknown kind") + if record["quality"] != "full_depth": + raise ValueError("paper replay requires explicit full_depth quality") + if type(record["sequence"]) is not int or record["sequence"] != sequence + 1: + raise ValueError("recording sequence gap or regression") + sequence = record["sequence"] + source_at = _at(record["source_at"], "source_at") + age = (at - source_at).total_seconds() + if age < 0 or age > MAX_SOURCE_AGE_SECONDS: + raise ValueError("recorded source timestamp is future or stale") + if previous_source is not None and source_at < previous_source: + raise ValueError("recording source timestamps must not regress within a session") + previous_source = source_at + bids, asks = _levels(record["bids"], "bids"), _levels(record["asks"], "asks") + if bids[0].price > asks[0].price: + raise ValueError("crossed recorded book") + with localcontext() as context: + context.prec = 80 + opposite = tuple(BookLevel(Decimal(1) - level.price, level.quantity) for level in asks) + book = OrderBookSnapshot(metadata["market_id"], bids, opposite, at, 0) + yield StreamEvent("book", at, BookUpdate(book, session, sequence, source_at)) + if active or previous is None: + raise ValueError("empty or incomplete recording: terminal reset required") + + +def describe_recording(path: str | Path, *, max_events: int = 10000) -> dict[str, Any]: + """Return metadata and bounded summary only after validating the entire file.""" + if type(max_events) is not int or max_events <= 0: + raise ValueError("max_events must be a positive integer") + result = read_recording_metadata(path) + count = books = resets = connections = disconnects = 0 + start = end = last_source = None + for count, event in enumerate(replay_book_recording(path, expected_metadata=result), 1): + if count > max_events: + raise ValueError("recording exceeds max_events") + if end is not None and event.received_at < end: + raise ValueError("recording receive timestamps must not regress") + start = start or event.received_at + end = event.received_at + if event.kind == "book": + books += 1 + assert event.update is not None + last_source = event.update.source_at + else: + resets += 1 + connections += event.reason == "connecting" + disconnects += event.reason == "disconnected" + if not books: + raise ValueError("recording contains no books") + result.update( + event_count=count, + book_count=books, + reset_count=resets, + reconnects=max(0, connections - 1), + disconnects=disconnects, + start_at=start.isoformat() if start else None, + end_at=end.isoformat() if end else None, + source_at=last_source.isoformat() if last_source else None, + ) + return result diff --git a/neural/sports.py b/neural/sports.py new file mode 100644 index 00000000..ad0c04ff --- /dev/null +++ b/neural/sports.py @@ -0,0 +1,295 @@ +"""Versioned MLB winner propositions and evidence-aware rule comparison (NRCL-99). + +Pure data: no discovery, fuzzy team matching, settlement, or trading authority. +The existing NormalizedMarket.metadata can carry SportsMarket.to_dict(). +""" + +from __future__ import annotations + +import re +from dataclasses import asdict, dataclass, fields +from datetime import date, datetime +from ipaddress import IPv6Address +from typing import Any, Literal +from urllib.parse import urlsplit + +SPORTS_VERSION = "1.0.0" +RULE_FIELDS = ( + "winner", + "extra_innings", + "forfeit", + "postponement", + "cancellation", + "shortened_game", + "settlement_source", + "exceptional_payout", + "venue_change", + "replay", +) +Status = Literal["compatible", "different", "unknown"] + + +def _text(value: Any, name: str, *, optional: bool = False) -> None: + if optional and value is None: + return + if not isinstance(value, str) or not value or value != value.strip() or len(value) > 2048: + raise ValueError(f"{name}: expected nonempty text without surrounding whitespace") + if any(ord(char) < 32 for char in value): + raise ValueError(f"{name}: control characters are forbidden") + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"{name}: text must be valid UTF-8") from exc + + +def _wire(cls: Any, payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict) or set(payload) != {field.name for field in fields(cls)}: + raise ValueError(f"{cls.__name__}: expected exactly the documented fields") + return dict(payload) + + +@dataclass(frozen=True) +class RuleEvidence: + """A reviewed policy identifier, with retained source provenance. + + Values are semantic identifiers assigned by the source reviewer, not raw + prose or an automatic interpretation. Equal strings alone do not prove law. + """ + + name: str + value: str + source_url: str + source_sha256: str | None + retrieved_at: str + scope: str + + def __post_init__(self) -> None: + if self.name not in RULE_FIELDS: + raise ValueError("unsupported settlement rule field") + for name in ("value", "source_url", "retrieved_at"): + _text(getattr(self, name), name) + url = urlsplit(self.source_url) + if ( + url.scheme != "https" + or not url.hostname + or url.username is not None + or url.password is not None + ): + raise ValueError("rule source must be an HTTPS URL without credentials") + try: + port = url.port # Reject nonnumeric and out-of-range ports. + if url.netloc.endswith(":") or (port is not None and port < 1): + raise ValueError("empty or unusable port") + host = url.hostname.encode("idna").decode("ascii") + if url.netloc.startswith("["): + if "%" in host: + raise ValueError("IPv6 zone identifiers are not supported for rule sources") + IPv6Address(host) + else: + host = host.removesuffix(".") + label = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?" + if len(host) > 253 or re.fullmatch(rf"{label}(?:\.{label})*", host) is None: + raise ValueError("malformed hostname") + except (ValueError, UnicodeError) as exc: + raise ValueError("rule source must have a valid hostname and port") from exc + if self.source_sha256 is not None and ( + not isinstance(self.source_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", self.source_sha256) is None + ): + raise ValueError("source_sha256 must be a lowercase SHA-256 or null") + try: + observed = datetime.fromisoformat(self.retrieved_at.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("retrieved_at must be an ISO timestamp with timezone") from exc + if observed.tzinfo is None or observed.utcoffset() is None: + raise ValueError("retrieved_at must be timezone-aware") + if self.scope not in ("listed_contract", "series", "guidance", "fixture"): + raise ValueError("unsupported rule source scope") + + +@dataclass(frozen=True) +class SettlementRules: + terms: tuple[RuleEvidence, ...] = () + complete: bool = False + + def __post_init__(self) -> None: + if type(self.complete) is not bool: + raise ValueError("complete must be a boolean") + if not isinstance(self.terms, tuple) or any( + not isinstance(term, RuleEvidence) for term in self.terms + ): + raise ValueError("terms must be a tuple of RuleEvidence") + if len({term.name for term in self.terms}) != len(self.terms): + raise ValueError("duplicate settlement rule field") + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> SettlementRules: + wire = _wire(cls, payload) + if not isinstance(wire["terms"], list): + raise ValueError("terms must be an array") + wire["terms"] = tuple(RuleEvidence(**_wire(RuleEvidence, item)) for item in wire["terms"]) + return cls(**wire) + + +@dataclass(frozen=True) +class SportsMarket: + """One selected team-wins outcome; canonical mapping is caller-owned. + + game_date is the original official local schedule date, not a UTC date + derived from a timestamp. A postponed game retains its canonical identity. + Unknown canonical fields remain null. Raw venue identifiers are preserved. + """ + + venue: str + event_id: str + market_id: str + outcome_id: str + raw_home_team_id: str | None + raw_away_team_id: str | None + canonical_event_id: str | None + home_team_id: str | None + away_team_id: str | None + outcome_team_id: str | None + game_date: str | None + game_number: int | None + period: str | None + rules: SettlementRules + schema_version: str = SPORTS_VERSION + sport: str = "baseball" + league: str = "mlb" + market_type: str = "winner" + + def __post_init__(self) -> None: + if self.schema_version != SPORTS_VERSION: + raise ValueError("unsupported sports contract version") + if (self.sport, self.league, self.market_type) != ("baseball", "mlb", "winner"): + raise ValueError("v1 supports MLB winner propositions only") + if self.venue not in ("kalshi", "polymarket_us", "novig"): + raise ValueError("unsupported U.S. venue") + for name in ("event_id", "market_id", "outcome_id"): + _text(getattr(self, name), name) + for name in ( + "raw_home_team_id", + "raw_away_team_id", + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "period", + ): + _text(getattr(self, name), name, optional=True) + if self.home_team_id is not None and self.home_team_id == self.away_team_id: + raise ValueError("home and away teams must differ") + if self.outcome_team_id is not None and self.outcome_team_id not in ( + self.home_team_id, + self.away_team_id, + ): + raise ValueError("selected outcome must identify a mapped participant") + if self.game_number is not None and ( + type(self.game_number) is not int or self.game_number not in (1, 2) + ): + raise ValueError("game_number must be 1, 2, or null; never infer missing game 1") + if self.game_date is not None: + if not isinstance(self.game_date, str) or not re.fullmatch( + r"\d{4}-\d{2}-\d{2}", self.game_date + ): + raise ValueError("game_date must be an ISO local date or null") + date.fromisoformat(self.game_date) + if not isinstance(self.rules, SettlementRules): + raise ValueError("rules must be SettlementRules") + if any(term.scope == "fixture" for term in self.rules.terms): + for name in ("event_id", "market_id", "outcome_id", "canonical_event_id"): + value = getattr(self, name) + if value is not None and not value.startswith("fixture:"): + raise ValueError(f"{name}: fixture rules require fixture-prefixed identities") + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> SportsMarket: + wire = _wire(cls, payload) + wire["rules"] = SettlementRules.from_dict(wire["rules"]) + return cls(**wire) + + def to_dict(self) -> dict[str, Any]: + wire = asdict(self) + wire["rules"]["terms"] = [asdict(term) for term in self.rules.terms] + return wire + + +@dataclass(frozen=True) +class SportsComparison: + proposition: Status + settlement: Status + differences: tuple[str, ...] + unknowns: tuple[str, ...] + + @property + def status(self) -> Status: + if "different" in (self.proposition, self.settlement): + return "different" + if "unknown" in (self.proposition, self.settlement): + return "unknown" + return "compatible" + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "proposition": self.proposition, + "settlement": self.settlement, + "differences": list(self.differences), + "unknowns": list(self.unknowns), + } + + +def compare_sports_markets(left: SportsMarket, right: SportsMarket) -> SportsComparison: + """Compare the reviewed v1 dimensions, never infer missing facts as equal. + + Settlement is compared only after canonical full-game propositions match. + A known policy difference wins over other unknown rule fields. Compatibility + requires complete reviewer coverage, listed-contract sources and snapshots. + Synthetic fixtures can compare with fixtures, never real contract sources. + """ + differences: list[str] = [] + unknowns: list[str] = [] + for name in ( + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "game_date", + "game_number", + "period", + ): + a, b = getattr(left, name), getattr(right, name) + if a is None or b is None: + unknowns.append(name) + elif a != b: + differences.append(name) + if any(market.period not in (None, "full_game") for market in (left, right)): + differences.append("scope:full_game_only") + proposition: Status = "different" if differences else "unknown" if unknowns else "compatible" + if proposition != "compatible": + return SportsComparison( + proposition, "unknown", tuple(differences), tuple(unknowns + ["rules:not_compared"]) + ) + + left_rules = {term.name: term for term in left.rules.terms} + right_rules = {term.name: term for term in right.rules.terms} + for name in RULE_FIELDS: + a_rule, b_rule = left_rules.get(name), right_rules.get(name) + if a_rule is None or b_rule is None: + unknowns.append("rules:" + name) + continue + if "fixture" in (a_rule.scope, b_rule.scope) and a_rule.scope != b_rule.scope: + unknowns.append("rules:" + name + ":mixed_fixture") + continue + if a_rule.value != b_rule.value: + differences.append("rules:" + name) + if any( + rule.scope not in ("listed_contract", "fixture") or rule.source_sha256 is None + for rule in (a_rule, b_rule) + ): + unknowns.append("rules:" + name + ":provenance") + if not left.rules.complete or not right.rules.complete: + unknowns.append("rules:incomplete_review") + settlement: Status = "different" if differences else "unknown" if unknowns else "compatible" + return SportsComparison(proposition, settlement, tuple(differences), tuple(unknowns)) diff --git a/tests/contracts/test_sports_matching.py b/tests/contracts/test_sports_matching.py new file mode 100644 index 00000000..aac6d963 --- /dev/null +++ b/tests/contracts/test_sports_matching.py @@ -0,0 +1,221 @@ +"""NRCL-99: identities, rule provenance and false-equivalence boundaries.""" + +import hashlib +import json +from dataclasses import replace +from pathlib import Path + +import pytest + +from neural.sports import RuleEvidence, SettlementRules, SportsMarket, compare_sports_markets + +FIXTURE = json.loads( + (Path(__file__).parents[2] / "examples/sports-matching-fixtures.json").read_text() +) + + +def market(name="synthetic_kalshi"): + return SportsMarket.from_dict(FIXTURE["markets"][name]) + + +@pytest.mark.parametrize("case", FIXTURE["cases"], ids=lambda case: case["name"]) +def test_customer_comparison_cases(case): + left = market(case["left"]) + right = SportsMarket.from_dict({**FIXTURE["markets"][case["right"]], **case["right_overrides"]}) + result = compare_sports_markets(left, right) + assert result.status == case["expected"] + assert result.proposition == case["expected_proposition"] + if case["expected_field"]: + assert case["expected_field"] in (*result.differences, *result.unknowns) + assert compare_sports_markets(right, left) == result + if result.proposition != "compatible": + assert result.settlement == "unknown" + assert not any(field.startswith("rules:") for field in result.differences) + + +def test_wire_round_trip_retains_raw_ids_and_rule_provenance(): + for payload in FIXTURE["markets"].values(): + parsed = SportsMarket.from_dict(payload) + assert parsed.to_dict() == payload + assert SportsMarket.from_dict(json.loads(json.dumps(parsed.to_dict()))) == parsed + source = FIXTURE["synthetic_rule_source"] + assert hashlib.sha256(source["canonical_json"].encode()).hexdigest() == source["sha256"] + assert {term.source_sha256 for term in market().rules.terms} == {source["sha256"]} + + +@pytest.mark.parametrize( + "field", + [ + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "game_date", + "game_number", + "period", + ], +) +def test_missing_identity_on_both_sides_is_unknown(field): + original = market() + changes = {field: None} + if field == "home_team_id": + changes["outcome_team_id"] = None + missing = replace(original, **changes) + result = compare_sports_markets(missing, missing) + assert result.status == "unknown" + assert field in result.unknowns + + +def test_outcome_and_event_identity_cannot_match_by_team_names_only(): + original = market() + for changed in ( + replace(original, outcome_team_id=original.away_team_id), + replace(original, canonical_event_id="fixture:other-event"), + replace(original, game_date="2026-09-11"), + replace(original, period="first_5_innings"), + ): + assert compare_sports_markets(original, changed).status == "different" + partial = replace(original, period="first_5_innings") + assert compare_sports_markets(partial, partial).status == "different" + + +@pytest.mark.parametrize("scope", ["series", "guidance"]) +def test_matching_policy_values_with_partial_source_scope_stay_unknown(scope): + original = market() + rules = SettlementRules( + tuple(replace(term, scope=scope) for term in original.rules.terms), True + ) + candidate = replace(original, rules=rules) + assert compare_sports_markets(candidate, candidate).status == "unknown" + + +def test_missing_rule_hash_and_incomplete_review_cannot_claim_compatibility(): + original = market() + variants = [ + SettlementRules(original.rules.terms, False), + SettlementRules(original.rules.terms[:-1], True), + SettlementRules( + tuple(replace(term, source_sha256=None) for term in original.rules.terms), True + ), + ] + for rules in variants: + candidate = replace(original, rules=rules) + assert compare_sports_markets(candidate, candidate).status == "unknown" + real = replace( + original, + rules=SettlementRules( + tuple(replace(term, scope="listed_contract") for term in original.rules.terms), True + ), + ) + assert compare_sports_markets(original, real).status == "unknown" + + +@pytest.mark.parametrize("field", ["event_id", "market_id", "outcome_id", "canonical_event_id"]) +def test_fixture_rules_cannot_be_copied_onto_real_market_identities(field): + payload = market().to_dict() + payload[field] = "mlb:real-market-identity" + with pytest.raises(ValueError, match="fixture-prefixed"): + SportsMarket.from_dict(payload) + + +@pytest.mark.parametrize( + "source_url", + [ + "https://exa mple.com/path", + "https://./x", + "https://example.com:notaport/path", + "https://example.com:65536/path", + "https://example.com:/path", + "https://-example.com/path", + "https://example..com/path", + "https://@example.com/path", + "https://[v1.foo]/rules", + "https://[fe80::1%bad zone]/rules", + "https://[fe80::1%25en0]/rules", + ], +) +def test_malformed_source_authorities_are_rejected(source_url): + with pytest.raises(ValueError): + replace(market().rules.terms[0], source_url=source_url) + + +@pytest.mark.parametrize( + "source_url", + [ + "https://example.com:443/path", + "https://example.com./path", + "https://[2001:db8::1]/path", + "https://bücher.example/path", + ], +) +def test_valid_source_authorities_remain_supported(source_url): + assert replace(market().rules.terms[0], source_url=source_url).source_url == source_url + + +def test_lone_surrogates_cannot_escape_the_wire_boundary(): + for field in ( + "event_id", + "market_id", + "outcome_id", + "raw_home_team_id", + "raw_away_team_id", + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "period", + ): + for surrogate in ("\ud800", "\udfff"): + with pytest.raises(ValueError, match="UTF-8"): + replace(market(), **{field: "fixture:" + surrogate}) + for field in ("value", "source_url", "retrieved_at"): + with pytest.raises(ValueError, match="UTF-8"): + replace(market().rules.terms[0], **{field: "source\ud800"}) + + +@pytest.mark.parametrize( + "field,value", + [ + ("schema_version", "2.0.0"), + ("league", "nba"), + ("venue", "polymarket"), + ("market_type", "spread"), + ("game_number", True), + ("game_number", 0), + ("game_number", "1"), + ("game_date", "2026-09-10T00:00:00Z"), + ("game_date", "2026-02-30"), + ("outcome_team_id", "mlb:other"), + ("market_id", ""), + ("canonical_event_id", " event "), + ], +) +def test_invalid_wire_identity_fails(field, value): + payload = market().to_dict() + payload[field] = value + with pytest.raises(ValueError): + SportsMarket.from_dict(payload) + + +def test_strict_nested_wire_and_rule_evidence(): + payload = market().to_dict() + with pytest.raises(ValueError): + SportsMarket.from_dict({**payload, "unknown": True}) + with pytest.raises(ValueError): + SettlementRules.from_dict({"terms": [], "complete": "true"}) + with pytest.raises(ValueError): + SettlementRules((market().rules.terms[0], market().rules.terms[0]), True) + evidence = payload["rules"]["terms"][0] + for change in ( + {"name": "unmodeled_rule"}, + {"source_sha256": "not-a-hash"}, + {"retrieved_at": "2026-09-10T00:00:00"}, + {"source_url": "http://example.com"}, + {"source_url": "https://user:password@example.com"}, + {"value": ""}, + ): + with pytest.raises(ValueError): + RuleEvidence(**{**evidence, **change}) + payload["rules"]["terms"][0]["extra"] = "rejected" + with pytest.raises(ValueError): + SportsMarket.from_dict(payload) diff --git a/tests/test_paper_worker.py b/tests/test_paper_worker.py index fe25d31f..4f33cc2d 100644 --- a/tests/test_paper_worker.py +++ b/tests/test_paper_worker.py @@ -38,6 +38,61 @@ def forbidden(*args, **kwargs): assert restarted.inspect(identity) == result +@pytest.mark.parametrize("old_status", ["completed", "queued"]) +def test_model_upgrade_never_reuses_or_reinterprets_v1_jobs(tmp_path, monkeypatch, old_status): + spec = StrategySpec("kalshi", "KX-EXAMPLE", "yes", "0.45", "0.65", "2", "2", "1") + path = recording(tmp_path) + rows = [json.loads(line) for line in path.read_text().splitlines()] + # V1 filled this later-received, earlier-sourced delta; V2 must cancel. + rows[2]["frame"] = { + "type": "orderbook_delta", + "sid": 7, + "seq": 2, + "msg": { + "market_ticker": "KX-EXAMPLE", + "side": "yes", + "price_dollars": "0.3", + "delta_fp": "0", + "ts": "2026-09-07T00:00:00.500000Z", + }, + } + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + database = tmp_path / "jobs.sqlite3" + jobs = worker.PaperJobs(database) + assumptions = {"initial_cash": "10", "fee_per_contract": "0.01"} + with monkeypatch.context() as prior_version: + prior_version.setattr(worker, "PAPER_MODEL", "neural-paper/1") + old_identity = jobs.submit(spec, path, **assumptions) + # Seed the pre-upgrade result as an opaque historical receipt. + old_result = {"model": "neural-paper/1", "cash": "10.56", "realized_pnl": "0.56"} + if old_status == "completed": + with jobs._transaction() as db: + db.execute( + "UPDATE jobs SET status='completed',result=? WHERE id=?", + (json.dumps(old_result), old_identity), + ) + identity = jobs.submit(spec, path, **assumptions) + assert identity != old_identity + restarted = worker.PaperJobs(database) + if old_status == "queued": + rejected = restarted.run_next() + assert rejected["id"] == old_identity + assert rejected["status"] == "failed" + assert rejected["result"] is None + assert "resubmit under " + worker.PAPER_MODEL in rejected["error"] + result = restarted.run_next() + assert result["id"] == identity + assert result["status"] == "completed" + assert result["result"]["model"] == worker.PAPER_MODEL + assert result["result"]["cash"] == "10" + assert result["result"]["trace"][2]["reason"] == "source_not_after_intent" + assert restarted.submit(spec, path, **assumptions) == identity + assert worker.PaperJobs(database).inspect(identity) == result + if old_status == "completed": + assert restarted.inspect(old_identity)["result"] == old_result + assert restarted.run_next() is None + + @pytest.mark.parametrize("overflow", [False, True]) def test_bad_recording_is_terminal_failure(tmp_path, overflow): jobs, _, spec, path, database = enqueue(tmp_path) diff --git a/tests/test_recordings.py b/tests/test_recordings.py new file mode 100644 index 00000000..22059890 --- /dev/null +++ b/tests/test_recordings.py @@ -0,0 +1,255 @@ +"""Synthetic cross-venue replay; no credentials, data capture or market calls.""" + +import builtins +import json +from dataclasses import replace +from decimal import Decimal, localcontext +from pathlib import Path + +import pytest + +from neural.paper import simulate_recording +from neural.paper_worker import PaperJobs +from neural.recordings import describe_recording, replay_book_recording +from neural.sports import SportsMarket, compare_sports_markets +from neural.strategy import StrategySpec + +FIXTURES = Path(__file__).parents[1] / "examples" / "recordings" + + +def inputs(venue="polymarket-us"): + stem = FIXTURES / f"synthetic-{venue}" + spec = StrategySpec.from_json(Path(str(stem) + "-strategy.json").read_text()) + return spec, Path(str(stem) + ".jsonl") + + +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() + rows = [json.loads(line) for line in fixture.read_text().splitlines()] + change(rows) + path = tmp_path / "modified.jsonl" + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + return spec, path + + +def test_same_strategy_logic_uses_full_decimal_depth_across_venues(): + kalshi_spec, kalshi_path = inputs("kalshi") + poly_spec, poly_path = inputs() + assert replace(kalshi_spec, venue=poly_spec.venue, market_id=poly_spec.market_id) == poly_spec + kalshi, poly = run(kalshi_spec, kalshi_path), run(poly_spec, poly_path) + with localcontext() as ctx: + ctx.prec = 2 + assert run(poly_spec, poly_path) == poly + assert kalshi["trace"] == poly["trace"] + assert kalshi["cash"] == poly["cash"] == "10.52" + assert kalshi["realized_pnl"] == poly["realized_pnl"] == "0.52" + assert poly["trace"][2]["levels"] == [ + {"price": "0.4", "quantity": "0.75"}, + {"price": "0.42", "quantity": "1.25"}, + ] + assert poly["trace"][2]["source_at"] == "2026-09-10T18:00:02+00:00" + assert poly["market_compatibility"]["status"] == "unknown" + comparison = compare_sports_markets( + SportsMarket.from_dict(kalshi["sports_market"]), + SportsMarket.from_dict(poly["sports_market"]), + ) + assert comparison.status == "compatible" # Synthetic rules only. + assert kalshi["recording_digest"] != poly["recording_digest"] + assert kalshi["result_id"] != poly["result_id"] + + +def test_summary_preserves_native_identity_and_counts(): + spec, path = inputs() + summary = describe_recording(path) + assert summary["venue"] == spec.venue + assert summary["market_id"] == spec.market_id + assert summary["sports_market"]["event_id"] == "fixture:polymarket_us:event-1" + assert summary["provenance"] == "synthetic" + assert summary["event_count"] == 6 + assert summary["book_count"] == 4 + assert summary["reset_count"] == 2 + assert summary["disconnects"] == 1 + assert summary["reconnects"] == 0 + assert summary["start_at"] == "2026-09-10T18:00:00+00:00" + assert summary["end_at"] == "2026-09-10T18:00:05+00:00" + with pytest.raises(ValueError, match="max_events"): + describe_recording(path, max_events=5) + + +def test_no_strategy_cannot_reuse_yes_sports_proposition(): + spec, path = inputs() + assert run(spec, path)["sports_market"]["outcome_team_id"] == "mlb:atl" + with pytest.raises(ValueError, match="outcome"): + run(replace(spec, outcome="no"), path) + + +def test_no_header_cannot_label_opposite_trade_as_yes_team(tmp_path): + spec, path = changed_recording(tmp_path, lambda rows: rows[0].update(outcome="no")) + with pytest.raises(ValueError, match="YES team-wins outcome only"): + run(replace(spec, outcome="no"), path) + with pytest.raises(ValueError, match="YES team-wins outcome only"): + describe_recording(path) + + +@pytest.mark.parametrize( + "change,reason", + [ + (lambda rows: rows[2].update(asks=[]), "both nonempty"), + (lambda rows: rows[2].update(bids=[]), "both nonempty"), + (lambda rows: rows[2].pop("asks"), "shape"), + (lambda rows: rows[2].update(quality="bbo_only"), "full_depth"), + (lambda rows: rows[2].update(bids=[["0.6", "2"]]), "crossed"), + (lambda rows: rows[2].update(source_at="2026-09-10T17:59:00Z"), "stale"), + (lambda rows: rows[2].update(source_at="2026-09-10T18:01:00Z"), "future"), + (lambda rows: rows[2].update(source_at=None), "required"), + (lambda rows: rows[2].update(source_at="2026-09-10T18:00:00"), "timezone"), + (lambda rows: rows[3].update(source_at="2026-09-10T17:59:59Z"), "regress"), + (lambda rows: rows[3].update(received_at="2026-09-10T17:59:59Z"), "regress"), + (lambda rows: rows[3].update(sequence=3), "sequence"), + (lambda rows: rows[2].update(sequence=True), "sequence"), + (lambda rows: rows[2].update(asks=[[0.4, "3"]]), "decimal"), + (lambda rows: rows[2].update(asks=[["0.4", "0"]]), "quantity"), + (lambda rows: rows[2].update(asks=[["1.1", "3"]]), "price"), + (lambda rows: rows[2].update(asks=[["0.4", "1"], ["0.4", "2"]]), "unique"), + (lambda rows: rows[2].update(asks=[["0.5", "1"], ["0.4", "2"]]), "order"), + (lambda rows: rows[0].update(venue="polymarket"), "venue"), + (lambda rows: rows[0].update(market_id="other"), "sports market"), + (lambda rows: rows[0].update(provenance="live"), "synthetic"), + (lambda rows: rows.pop(), "terminal reset"), + (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) + with pytest.raises(ValueError, match=reason): + run(spec, path) + with pytest.raises(ValueError): + describe_recording(path) + + +def test_wrong_venue_rejected_even_when_market_ids_match(): + spec, path = inputs() + with pytest.raises(ValueError, match="venue/market"): + run(replace(spec, venue="kalshi"), path) + + +def test_truncated_or_duplicate_tail_fails_closed(tmp_path): + spec, fixture = inputs() + path = tmp_path / "tail.jsonl" + for tail in ('{"kind":"reset","kind":"book"}\n', "bad\n"): + path.write_text(fixture.read_text() + tail) + with pytest.raises(ValueError): + run(spec, path) + path.write_text(fixture.read_text().rstrip("\n")) + with pytest.raises(ValueError, match="truncated"): + run(spec, path) + + +def test_depth_does_not_round_trip_through_float(tmp_path): + spec, path = changed_recording( + tmp_path, + lambda rows: rows[2].update(asks=[["0.400000000000000001", "3.000000000000000001"]]), + ) + event = list(replay_book_recording(path))[1] + assert event.update.book.asks(spec.outcome)[0].price == Decimal("0.400000000000000001") + assert event.update.book.asks(spec.outcome)[0].quantity == Decimal("3.000000000000000001") + + +def test_polymarket_jobs_snapshot_and_recover_without_venue_specific_worker(tmp_path): + spec, fixture = inputs() + path = tmp_path / "recording.jsonl" + path.write_bytes(fixture.read_bytes()) + database = tmp_path / "jobs.sqlite3" + jobs = PaperJobs(database) + identity = jobs.submit(spec, path, initial_cash="10", fee_per_contract="0.01") + assert jobs.submit(spec, path, initial_cash="10.0", fee_per_contract="0.010") == identity + path.write_text("malformed replacement\n") + completed = PaperJobs(database).run_next() + assert completed["status"] == "completed" + assert completed["result"] == run(spec, fixture) + assert PaperJobs(database).inspect(identity) == completed + bad_identity = jobs.submit(spec, path, initial_cash="10", fee_per_contract="0.01") + assert jobs.run_next()["status"] == "failed" + assert PaperJobs(database).inspect(bad_identity)["result"] is None + assert PaperJobs(database).run_next() is None + + +@pytest.mark.parametrize("describe", [False, True]) +@pytest.mark.parametrize("replace_after_open", [1, 2]) +def test_atomic_path_replace_cannot_mix_report_metadata_and_books( + tmp_path, monkeypatch, describe, replace_after_open +): + spec, fixture = inputs() + path = tmp_path / "recording.jsonl" + replacement = tmp_path / "replacement.jsonl" + path.write_bytes(fixture.read_bytes()) + rows = [json.loads(line) for line in fixture.read_text().splitlines()] + rows[0]["sports_market"]["outcome_team_id"] = "mlb:tb" + rows[2]["asks"] = rows[3]["asks"] = [["0.44", "3"]] + replacement.write_text("".join(json.dumps(row) + "\n" for row in rows)) + consume = describe_recording if describe else lambda path: run(spec, path) + expected = consume(path) + original_open = builtins.open + opens = 0 + + def replace_on_open(file, *args, **kwargs): + nonlocal opens + source = original_open(file, *args, **kwargs) + if file == path: + opens += 1 + if opens == replace_after_open: + replacement.replace(path) + return source + + monkeypatch.setattr(builtins, "open", replace_on_open) + if replace_after_open == 1: + with pytest.raises(ValueError, match="metadata changed"): + consume(path) + else: + # The replay already opened A: its accepted metadata and every book + # still belong to A, even though the path now resolves to B. + assert consume(path) == expected + + +def test_replay_header_and_rows_share_one_open_file(tmp_path, monkeypatch): + _, fixture = inputs() + path = tmp_path / "recording.jsonl" + replacement = tmp_path / "replacement.jsonl" + path.write_bytes(fixture.read_bytes()) + rows = [json.loads(line) for line in fixture.read_text().splitlines()] + rows[0]["sports_market"]["outcome_team_id"] = "mlb:tb" + rows[2]["asks"] = [["0.44", "3"]] + replacement.write_text("".join(json.dumps(row) + "\n" for row in rows)) + expected = list(replay_book_recording(path)) + original_open = builtins.open + + def replace_on_open(file, *args, **kwargs): + source = original_open(file, *args, **kwargs) + if file == path and replacement.exists(): + replacement.replace(path) + return source + + monkeypatch.setattr(builtins, "open", replace_on_open) + assert list(replay_book_recording(path)) == expected + + +@pytest.mark.parametrize("side", ["buy", "sell"]) +@pytest.mark.parametrize("equal", [False, True]) +def test_delayed_source_cannot_fill_before_or_at_intent_time(tmp_path, side, equal): + def delay(rows): + signal, fill, second = (2, 3, 0) if side == "buy" else (4, 5, 2) + rows[signal]["source_at"] = f"2026-09-10T18:00:{second:02d}Z" + timestamp = f"{second + 1:02d}" if equal else f"{second:02d}.500000" + rows[fill]["source_at"] = f"2026-09-10T18:00:{timestamp}Z" + + spec, path = changed_recording(tmp_path, delay) + result = run(spec, path) + row = result["trace"][2 if side == "buy" else 4] + assert row["action"] == "cancel" + assert row["reason"] == "source_not_after_intent" + assert result["position"] == ("0" if side == "buy" else "2") + assert result["realized_pnl"] == "0"