From 747598fb18b9e9f5681c560b83d6ca7e0e819e54 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:37:28 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(raptors):=20Tennis=20Raptor=20?= =?UTF-8?q?=E2=80=94=20live=20tennis=20event-state=20scout=20(observe-only?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second venue-neutral macro Raptor beside the Sports Raptor, built on the same pattern (issue #6): polls the Live Tennis API free-tier REST endpoint GET /matches?status=live (Bearer key from LIVETENNIS_API_KEY), tracks one live match (sticky by id, else freshest score), and broadcasts a Copy TennisSnapshot — sets, current-set games, serving side, tiebreak flag, and a derived break-point flag (receiver at AD, or receiver at 40 vs server below 40; never in tiebreaks) — over a watch channel, with telemetry under the fixed "tennis" health-map key. Feed health mirrors the other scouts' stale-feed-reads-as-disconnected rule: each score carries the API's own last-change timestamp, and when the tracked score is older than TENNIS_SCORE_STALENESS_SECS the raptor reports tennis_connected = false plus feed_age_secs, so a consumer widens or pulls rather than holding. Zero live matches is a healthy neutral state (tennis has quiet hours), and a missing key parks the raptor idle exactly like the Sports Raptor. Honest budget defaults in all three config templates: the free tier is 30 req/min / 100 req/day, so TENNIS_POLL_SECS defaults to 900 (all-day safe); ~60s polling is documented as develop-and-test / few-match cadence. The provider's push WebSocket and model win-probability are top-tier features and are NOT used — v1 is free-tier REST only, observe-only, not consumed by any Viper sizing. Wiring: spawned beside the Sports Raptor on the intl and us_retail paths; SquadronRaptors gains an optional tennis receiver attached post-construction so no constructor signature changes; telemetry sampler de-duplicates the slow feed like the Sports feed. 11 unit tests cover parsing, break-point edge cases, tiebreaks, null/empty score fields, tracked-match stickiness/rotation, staleness, and error bodies. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- src/api/server.rs | 113 ++++- src/cag/run.rs | 24 +- src/config.aggressive.rs.example | 30 ++ src/config.balanced.rs.example | 30 ++ src/config.conservative.rs.example | 30 ++ src/main.rs | 34 +- src/raptors/mod.rs | 8 +- src/raptors/tennis.rs | 736 +++++++++++++++++++++++++++++ src/squadron/raptors.rs | 14 +- 10 files changed, 1007 insertions(+), 17 deletions(-) create mode 100644 src/raptors/tennis.rs diff --git a/README.md b/README.md index db042d6..bfea135 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DRADIS -> **Direct Reaction And Dynamic Intelligence System** — Low-latency Rust prediction-market trading bot for Kalshi & Polymarket. Nine autonomous Viper strategies, a Raptor recon layer (Price, Funding, Derivatives, Tide "Institutional Pulse", Horizon "TradFi Velocity", and a venue-neutral Sports line-movement scout), a Squadron deployment framework, a CAG async dispatch layer with concurrent multi-asset support, a real-time Next.js Control Tower, and an LLM Advisor (Ollama local/remote, OpenAI-compatible, or Anthropic) that delivers optimization recommendations via Telegram & OpenClaw — and can propose or autonomously apply live config changes under a tiered, guard-railed autonomy policy. +> **Direct Reaction And Dynamic Intelligence System** — Low-latency Rust prediction-market trading bot for Kalshi & Polymarket. Nine autonomous Viper strategies, a Raptor recon layer (Price, Funding, Derivatives, Tide "Institutional Pulse", Horizon "TradFi Velocity", a venue-neutral Sports line-movement scout, and a venue-neutral Tennis event-state scout), a Squadron deployment framework, a CAG async dispatch layer with concurrent multi-asset support, a real-time Next.js Control Tower, and an LLM Advisor (Ollama local/remote, OpenAI-compatible, or Anthropic) that delivers optimization recommendations via Telegram & OpenClaw — and can propose or autonomously apply live config changes under a tiered, guard-railed autonomy policy. ![Rust](https://img.shields.io/badge/Rust-1.95+-orange?logo=rust&logoColor=white) ![Tokio](https://img.shields.io/badge/Tokio-async%20runtime-darkgreen?logo=rust&logoColor=white) @@ -323,6 +323,7 @@ Raptors are intentionally dumb: **fetch, normalize, broadcast** — no trading l | **Tide Raptor** | Alpaca IEX + synthetic iNAV | "Institutional Pulse" + coherence from spot-BTC-ETF (IBIT/FBTC/ARKB) premium vs iNAV — BTC-only, US-hours | `src/raptors/tide.rs` | | **Horizon Raptor** | Alpaca IEX (shared) | TradFi velocity (SPY/QQQ), macro coherence (BTC↔QQQ), VIX proxy (UVXY) — BTC-only, US-hours | `src/raptors/horizon.rs` | | **Sports Raptor** | The Odds API (h2h) | Vig-free consensus probability, line drift, book dispersion — venue-neutral (US + intl), **observe-only** | `src/raptors/sports.rs` | +| **Tennis Raptor** | Live Tennis API (REST) | Live tennis event state: score, serving side, break-point flag, feed staleness — venue-neutral, **observe-only** | `src/raptors/tennis.rs` | | *(future)* **Politics Raptor** | Polling aggregators | Approval drift, event probability shifts | — | When multiple Raptors are active, the GBoost Viper fuses every signal as model features (funding, OI/CVD, institutional pulse/coherence, TradFi velocity/VIX); Basis, Momentum and TrendCapture use them as confirmation gates; Maker and TrendCapture consume the Horizon macro signal as preventative gates (VIX-spike / coherent-TradFi-flow quote suppression, fade veto — observe-first, enforcement behind config flags); and the **Convergence** Viper opens directional positions only when the institutional + derivatives stack agrees. No single Raptor has veto power alone. @@ -331,6 +332,8 @@ The **Tide** and **Horizon** Raptors share a single Alpaca IEX WebSocket connect The **Sports Raptor** is the first non-crypto scout: a single venue-neutral instance shared by both the US and intl pipelines. It polls The Odds API (keyed on `ODDS_API_KEY`), reduces the nearest-commencing event's cross-book moneyline to a vig-free consensus, and broadcasts line drift + book dispersion. It runs **observe-only** — it publishes telemetry but no Viper consumes it for sizing yet — and degrades silently to a neutral snapshot when no API key is set. +The **Tennis Raptor** reads the event itself rather than the betting line: it polls the Live Tennis API's live-match endpoint (keyed on `LIVETENNIS_API_KEY`), tracks one live match (sticky by id, otherwise the freshest score), and broadcasts sets/games/points, the serving side, and a derived break-point flag (receiver at AD, or receiver at 40 vs a server below 40 — never in tiebreaks). Feed health follows the same stale-feed-reads-as-disconnected rule as the other scouts: a score older than `TENNIS_SCORE_STALENESS_SECS` reports `tennis_connected = false` alongside its age, so a consumer widens or pulls, never holds. Honest tier facts: the free tier is 30 req/min / 100 req/day — the default `TENNIS_POLL_SECS = 900` fits all-day polling inside the free cap, while ~60s polling gives near point-level tracking for only ~100 minutes/day (develop-and-test, or following a few matches; sustained fast polling needs a paid tier). The provider's push WebSocket and model win-probability fields are top-tier features and are **not** used — this raptor is free-tier REST only, observe-only, and degrades silently to a neutral snapshot without a key. + --- ## ✈️ Viper Wing (`src/vipers/`) diff --git a/src/api/server.rs b/src/api/server.rs index 0adbbc2..486e9b7 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -175,6 +175,43 @@ pub struct AssetRaptorHealth { /// Comma-separated bookmaker titles in the consensus (e.g. "DraftKings, FanDuel"). #[serde(default)] pub sports_books: String, + + // ── Live Tennis Raptor signal snapshot (Live Tennis API event state) ───── + /// Tennis Raptor's last poll succeeded AND the tracked score is fresh + /// (observe-only). False on failure OR staleness — a stale feed must read + /// as disconnected so a consumer widens/pulls, never holds on it. + pub tennis_connected: bool, + /// Live matches in the sample (0 = no data / nothing on court — neutral). + pub tennis_num_live: Decimal, + /// Sets won by player 1 / player 2 in the tracked match. + pub tennis_sets_p1: Decimal, + pub tennis_sets_p2: Decimal, + /// Games won in the tracked match's current set. + pub tennis_games_p1: Decimal, + pub tennis_games_p2: Decimal, + /// Serving side of the tracked match (1/2; 0 = unknown). + pub tennis_server: Decimal, + /// Receiver holds a break point (never true in a tiebreak). + pub tennis_break_point: bool, + /// The tracked match's current game is a tiebreak. + pub tennis_is_tiebreak: bool, + /// Age (seconds) of the tracked score's API timestamp (-1 = unknown). + pub tennis_feed_age_secs: Decimal, + /// Tracked match label, e.g. "C. Alcaraz vs J. Sinner". + #[serde(default)] + pub tennis_match: String, + /// Tournament name from the feed, e.g. "Cincinnati Open". + #[serde(default)] + pub tennis_tournament: String, + /// Tour of the tracked match ("atp"/"wta"/…); empty when unstated. + #[serde(default)] + pub tennis_tour: String, + /// In-game points as tennis strings, e.g. "30–40" or "AD–40". + #[serde(default)] + pub tennis_points: String, + /// ISO-8601 UTC timestamp of the tracked score (last score change). + #[serde(default)] + pub tennis_score_at: String, } // ─── Telemetry ring buffer ──────────────────────────────────────────────────── @@ -229,6 +266,28 @@ pub struct TelemetrySample { #[serde(default)] pub sports_books: String, + // ── Tennis Raptor (live event state) ── + pub tennis_connected: bool, + pub tennis_num_live: Decimal, + pub tennis_sets_p1: Decimal, + pub tennis_sets_p2: Decimal, + pub tennis_games_p1: Decimal, + pub tennis_games_p2: Decimal, + pub tennis_server: Decimal, + pub tennis_break_point: bool, + pub tennis_is_tiebreak: bool, + pub tennis_feed_age_secs: Decimal, + #[serde(default)] + pub tennis_match: String, + #[serde(default)] + pub tennis_tournament: String, + #[serde(default)] + pub tennis_tour: String, + #[serde(default)] + pub tennis_points: String, + #[serde(default)] + pub tennis_score_at: String, + // ── Horizon Raptor (TradFi velocity / VIX proxy) ── pub horizon_connected: bool, pub horizon_market_open: bool, @@ -255,6 +314,13 @@ const SPORTS_HISTORY_CAP: usize = 1440; /// series keeps advancing in time and the most-recent point stays reasonably fresh. /// 1440 points × 30 min ≈ 30 days of retained, readable movement. const SPORTS_TELEMETRY_HEARTBEAT_SECS: i64 = 1800; +/// The Tennis Raptor is another slow poller (`config::TENNIS_POLL_SECS`, 900s +/// default), so it gets the same change-or-heartbeat de-duplication as the +/// Sports feed — but with a shorter heartbeat: a live tennis score moves every +/// few points, and when it *doesn't* move the advancing heartbeat is what makes +/// the staleness visible on the chart. +const TENNIS_HISTORY_CAP: usize = 1440; +const TENNIS_TELEMETRY_HEARTBEAT_SECS: i64 = 300; /// Background task — every `TELEMETRY_SAMPLE_SECS`, snapshot the current Raptor /// signal values into the per-asset ring buffer. Spawned once by @@ -300,6 +366,32 @@ async fn run_telemetry_sampler( } } + // Same treatment for the slow Tennis feed: keep a point only when the + // event state actually changes, or once per heartbeat. + if asset == "tennis" { + let changed = match buf.back() { + Some(last) => { + last.tennis_sets_p1 != h.tennis_sets_p1 + || last.tennis_sets_p2 != h.tennis_sets_p2 + || last.tennis_games_p1 != h.tennis_games_p1 + || last.tennis_games_p2 != h.tennis_games_p2 + || last.tennis_points != h.tennis_points + || last.tennis_server != h.tennis_server + || last.tennis_break_point != h.tennis_break_point + || last.tennis_num_live != h.tennis_num_live + || last.tennis_match != h.tennis_match + || last.tennis_connected != h.tennis_connected + } + None => true, + }; + let heartbeat_due = buf.back() + .map(|last| now - last.t >= TENNIS_TELEMETRY_HEARTBEAT_SECS * 1000) + .unwrap_or(true); + if !changed && !heartbeat_due { + continue; + } + } + buf.push_back(TelemetrySample { t: now, oracle_price: h.oracle_price, @@ -332,6 +424,21 @@ async fn run_telemetry_sampler( sports_sport: h.sports_sport.clone(), sports_commence: h.sports_commence.clone(), sports_books: h.sports_books.clone(), + tennis_connected: h.tennis_connected, + tennis_num_live: h.tennis_num_live, + tennis_sets_p1: h.tennis_sets_p1, + tennis_sets_p2: h.tennis_sets_p2, + tennis_games_p1: h.tennis_games_p1, + tennis_games_p2: h.tennis_games_p2, + tennis_server: h.tennis_server, + tennis_break_point: h.tennis_break_point, + tennis_is_tiebreak: h.tennis_is_tiebreak, + tennis_feed_age_secs: h.tennis_feed_age_secs, + tennis_match: h.tennis_match.clone(), + tennis_tournament: h.tennis_tournament.clone(), + tennis_tour: h.tennis_tour.clone(), + tennis_points: h.tennis_points.clone(), + tennis_score_at: h.tennis_score_at.clone(), horizon_connected: h.horizon_connected, horizon_market_open: h.tradfi_velocity != Decimal::ZERO || h.vix_proxy != Decimal::ZERO, tradfi_velocity: h.tradfi_velocity, @@ -340,7 +447,11 @@ async fn run_telemetry_sampler( vix_velocity: h.vix_velocity, }); let len = buf.len(); - let cap = if asset == "sports" { SPORTS_HISTORY_CAP } else { TELEMETRY_HISTORY_CAP }; + let cap = match asset.as_str() { + "sports" => SPORTS_HISTORY_CAP, + "tennis" => TENNIS_HISTORY_CAP, + _ => TELEMETRY_HISTORY_CAP, + }; if len > cap { buf.drain(0..len - cap); } diff --git a/src/cag/run.rs b/src/cag/run.rs index 00a999b..f80c1cb 100644 --- a/src/cag/run.rs +++ b/src/cag/run.rs @@ -362,22 +362,26 @@ where yes_fee_bps: hourly_yes_fee_rate, no_fee_bps: hourly_no_fee_rate, }; + let mut squadron_raptors = SquadronRaptors::full( + raptor_signals.oracle.clone(), + raptor_signals.velocity.clone(), + raptor_signals.drift.clone(), + raptor_signals.funding.clone().expect("funding raptor always present"), + raptor_signals.derivatives.clone().expect("derivatives raptor always present"), + raptor_signals.tide.clone(), + raptor_signals.horizon.clone(), + raptor_signals.sports.clone(), + ); + // The observe-only Tennis Raptor feed rides along when deployed + // (attached post-construction so `full()`'s signature stays stable). + squadron_raptors.tennis = raptor_signals.tennis.clone(); let mut squadron = Squadron::new( patrol_ctx.crypto_filter.parse::().unwrap_or(CryptoAsset::Btc), SquadronConfig::full_wing( format!("Full Wing — {}", patrol_ctx.crypto_filter.to_uppercase()) ), hourly_market_config_for_squadron, - SquadronRaptors::full( - raptor_signals.oracle.clone(), - raptor_signals.velocity.clone(), - raptor_signals.drift.clone(), - raptor_signals.funding.clone().expect("funding raptor always present"), - raptor_signals.derivatives.clone().expect("derivatives raptor always present"), - raptor_signals.tide.clone(), - raptor_signals.horizon.clone(), - raptor_signals.sports.clone(), - ), + squadron_raptors, ); // Load squadron-scoped config, preserving operator edits made via the diff --git a/src/config.aggressive.rs.example b/src/config.aggressive.rs.example index 86a4c8f..9fcabc9 100644 --- a/src/config.aggressive.rs.example +++ b/src/config.aggressive.rs.example @@ -480,6 +480,36 @@ pub const SPORTS_ODDS_REGIONS: &str = "us"; pub const SPORTS_POLL_SECS: u64 = 300; pub const SPORTS_ODDS_LOW_BUDGET_WARN: i64 = 50; +// ── Tennis Raptor (Live Tennis API live event-state feed) ──────────────────── +// +/// Env var carrying the Live Tennis API (livetennisapi.com) key. Absent ⇒ the +/// Tennis Raptor runs idle/observe-only: it seeds a neutral snapshot, reports +/// `tennis_connected = false`, and publishes no event-state signal. +pub const TENNIS_API_KEY_ENV: &str = "LIVETENNIS_API_KEY"; + +/// Optional tour filter for the live-match poll: `atp`, `wta`, `challenger`, +/// `itf` or `juniors`. Empty ⇒ all tours (the raptor then tracks the live match +/// with the freshest score). Narrow this to the tour whose markets you trade. +pub const TENNIS_TOUR: &str = ""; + +/// Seconds between Tennis Raptor polls. Honest budget note — the free tier +/// allows 30 req/min and 100 req/day: the 900s default fits ALL-DAY polling +/// inside the free cap (≤96 req/day); ~60s gives near point-level tracking but +/// burns the free day cap in ~100 minutes (fine for develop-and-test or a few +/// tracked matches; sustained all-day fast polling needs a paid tier). The top +/// tier's push WebSocket is NOT used by this raptor — v1 is REST-only, a WS +/// lane would arrive later as a separate config-gated option. +pub const TENNIS_POLL_SECS: u64 = 900; + +/// Max age (seconds) of the tracked match's score timestamp before the feed is +/// reported stale (`tennis_connected = false`, so consumers widen/pull rather +/// than hold). Tennis pauses legitimately — changeovers ~90s, set breaks +/// ~120s+ — so this sits well above those to avoid flapping. +pub const TENNIS_SCORE_STALENESS_SECS: u64 = 600; + +/// Warn when the API's X-RateLimit remaining-request count drops to this. +pub const TENNIS_LOW_BUDGET_WARN: i64 = 20; + // ============================================================================ // BASIS STRATEGY MAKER ORDER PARAMETERS diff --git a/src/config.balanced.rs.example b/src/config.balanced.rs.example index 7869800..e535e87 100644 --- a/src/config.balanced.rs.example +++ b/src/config.balanced.rs.example @@ -469,6 +469,36 @@ pub const SPORTS_ODDS_REGIONS: &str = "us"; pub const SPORTS_POLL_SECS: u64 = 300; pub const SPORTS_ODDS_LOW_BUDGET_WARN: i64 = 50; +// ── Tennis Raptor (Live Tennis API live event-state feed) ──────────────────── +// +/// Env var carrying the Live Tennis API (livetennisapi.com) key. Absent ⇒ the +/// Tennis Raptor runs idle/observe-only: it seeds a neutral snapshot, reports +/// `tennis_connected = false`, and publishes no event-state signal. +pub const TENNIS_API_KEY_ENV: &str = "LIVETENNIS_API_KEY"; + +/// Optional tour filter for the live-match poll: `atp`, `wta`, `challenger`, +/// `itf` or `juniors`. Empty ⇒ all tours (the raptor then tracks the live match +/// with the freshest score). Narrow this to the tour whose markets you trade. +pub const TENNIS_TOUR: &str = ""; + +/// Seconds between Tennis Raptor polls. Honest budget note — the free tier +/// allows 30 req/min and 100 req/day: the 900s default fits ALL-DAY polling +/// inside the free cap (≤96 req/day); ~60s gives near point-level tracking but +/// burns the free day cap in ~100 minutes (fine for develop-and-test or a few +/// tracked matches; sustained all-day fast polling needs a paid tier). The top +/// tier's push WebSocket is NOT used by this raptor — v1 is REST-only, a WS +/// lane would arrive later as a separate config-gated option. +pub const TENNIS_POLL_SECS: u64 = 900; + +/// Max age (seconds) of the tracked match's score timestamp before the feed is +/// reported stale (`tennis_connected = false`, so consumers widen/pull rather +/// than hold). Tennis pauses legitimately — changeovers ~90s, set breaks +/// ~120s+ — so this sits well above those to avoid flapping. +pub const TENNIS_SCORE_STALENESS_SECS: u64 = 600; + +/// Warn when the API's X-RateLimit remaining-request count drops to this. +pub const TENNIS_LOW_BUDGET_WARN: i64 = 20; + // ============================================================================ // BASIS STRATEGY MAKER ORDER PARAMETERS diff --git a/src/config.conservative.rs.example b/src/config.conservative.rs.example index b6cedd9..4bb0e6e 100644 --- a/src/config.conservative.rs.example +++ b/src/config.conservative.rs.example @@ -496,6 +496,36 @@ pub const SPORTS_ODDS_REGIONS: &str = "us"; pub const SPORTS_POLL_SECS: u64 = 300; pub const SPORTS_ODDS_LOW_BUDGET_WARN: i64 = 50; +// ── Tennis Raptor (Live Tennis API live event-state feed) ──────────────────── +// +/// Env var carrying the Live Tennis API (livetennisapi.com) key. Absent ⇒ the +/// Tennis Raptor runs idle/observe-only: it seeds a neutral snapshot, reports +/// `tennis_connected = false`, and publishes no event-state signal. +pub const TENNIS_API_KEY_ENV: &str = "LIVETENNIS_API_KEY"; + +/// Optional tour filter for the live-match poll: `atp`, `wta`, `challenger`, +/// `itf` or `juniors`. Empty ⇒ all tours (the raptor then tracks the live match +/// with the freshest score). Narrow this to the tour whose markets you trade. +pub const TENNIS_TOUR: &str = ""; + +/// Seconds between Tennis Raptor polls. Honest budget note — the free tier +/// allows 30 req/min and 100 req/day: the 900s default fits ALL-DAY polling +/// inside the free cap (≤96 req/day); ~60s gives near point-level tracking but +/// burns the free day cap in ~100 minutes (fine for develop-and-test or a few +/// tracked matches; sustained all-day fast polling needs a paid tier). The top +/// tier's push WebSocket is NOT used by this raptor — v1 is REST-only, a WS +/// lane would arrive later as a separate config-gated option. +pub const TENNIS_POLL_SECS: u64 = 900; + +/// Max age (seconds) of the tracked match's score timestamp before the feed is +/// reported stale (`tennis_connected = false`, so consumers widen/pull rather +/// than hold). Tennis pauses legitimately — changeovers ~90s, set breaks +/// ~120s+ — so this sits well above those to avoid flapping. +pub const TENNIS_SCORE_STALENESS_SECS: u64 = 600; + +/// Warn when the API's X-RateLimit remaining-request count drops to this. +pub const TENNIS_LOW_BUDGET_WARN: i64 = 20; + // ============================================================================ // BASIS STRATEGY MAKER ORDER PARAMETERS diff --git a/src/main.rs b/src/main.rs index c65c81c..d8a0c4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -400,6 +400,17 @@ async fn run() -> Result<()> { Arc::clone(&shared_http), us_sports_tx, Arc::clone(&raptor_health_tx), )); + // ── Tennis Raptor (venue-neutral, observe-only) ─────────────────────── + // Spawned beside the Sports Raptor so its live event-state telemetry + // ("tennis" health key) is available on the US build too. Degrades to + // Default without LIVETENNIS_API_KEY. Observe-only: telemetry now, + // squadron wiring when a consumer earns it. + let (us_tennis_tx, _us_tennis_rx) = + watch::channel(dradis::raptors::tennis::TennisSnapshot::default()); + tokio::spawn(dradis::raptors::tennis::run_tennis_raptor( + Arc::clone(&shared_http), us_tennis_tx, Arc::clone(&raptor_health_tx), + )); + // ── Connect the custodial US retail venue + run the arb loop (Step 3c) ── // Best-effort connect: a failure (missing creds, gateway down) is logged // but does not crash the process — the Control Tower API stays up so the @@ -686,6 +697,24 @@ async fn run() -> Result<()> { }); } + // ── Tennis Raptor (venue-neutral, observe-only) ─────────────────────────── + // A single shared instance beside the Sports Raptor — live tennis event + // state (score, server, break point) is not a per-crypto-asset signal. + // Publishes telemetry under the "tennis" key and degrades to Default when + // LIVETENNIS_API_KEY is unset. Not consumed by Viper sizing (telemetry + // observation phase, same status as the Tide and Sports Raptors). + let (tennis_tx, tennis_rx) = + watch::channel(dradis::raptors::tennis::TennisSnapshot::default()); + { + let http = Arc::clone(&shared_http); + let health = Arc::clone(&raptor_health_tx); + spawn_supervised("tennis-raptor", move || { + dradis::raptors::tennis::run_tennis_raptor( + Arc::clone(&http), tennis_tx.clone(), Arc::clone(&health), + ) + }); + } + for asset in assets.iter() { // ── Per-asset raptor signal feeds ───────────────────────────────────── let (oracle_tx, oracle_rx) = watch::channel(dec!(0)); @@ -766,7 +795,10 @@ async fn run() -> Result<()> { (None, None) }; - let raptor_signals = SquadronRaptors::full(oracle_rx, velocity_rx, drift_rx, funding_rx, deriv_rx, tide_rx, horizon_rx, Some(sports_rx.clone())); + let mut raptor_signals = SquadronRaptors::full(oracle_rx, velocity_rx, drift_rx, funding_rx, deriv_rx, tide_rx, horizon_rx, Some(sports_rx.clone())); + // Attach the venue-neutral Tennis Raptor feed (observe-only) the same + // way the US general wing attaches its sports feed. + raptor_signals.tennis = Some(tennis_rx.clone()); // ── Per-asset session state ──────────────────────────────────────────── // startup_balance is the real wallet balance at process start — used as diff --git a/src/raptors/mod.rs b/src/raptors/mod.rs index e0db38c..4764627 100644 --- a/src/raptors/mod.rs +++ b/src/raptors/mod.rs @@ -31,15 +31,16 @@ /// │ Tide Raptor │ Binance oracle + IEX │ ETF "Institutional Pulse" + coherence │ /// /// │ Sports Raptor │ The Odds API (h2h) │ line drift, consensus prob, book spread │ +/// │ Tennis Raptor │ Live Tennis API │ live score, server, break-point state │ /// /// Future Raptors (not yet implemented) /// ───────────────────────────────────── /// │ Politics Raptor │ Polling aggregators │ approval drift, event probability shifts │ /// │ Horizon Raptor │ Alpaca IEX WS │ TradFi velocity (SPY/QQQ), VIX proxy │ /// -/// The Sports and Horizon Raptors are venue-neutral (shared by all pipelines) and, -/// like the Tide Raptor, run observe-only: they publish telemetry but no Viper -/// consumes them for sizing yet. +/// The Sports, Tennis and Horizon Raptors are venue-neutral (shared by all +/// pipelines) and, like the Tide Raptor, run observe-only: they publish telemetry +/// but no Viper consumes them for sizing yet. /// /// When multiple Raptors are active the GBoost and Basis strategies fuse their /// signals as features — no single Raptor has veto power alone. @@ -48,4 +49,5 @@ pub mod funding; pub mod derivatives; pub mod tide; pub mod sports; +pub mod tennis; pub mod horizon; diff --git a/src/raptors/tennis.rs b/src/raptors/tennis.rs new file mode 100644 index 0000000..fe61db8 --- /dev/null +++ b/src/raptors/tennis.rs @@ -0,0 +1,736 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// DRADIS — autonomous trading engine for crypto prediction markets. +// Copyright (C) 2026 Michael Bordash +// +// This file is part of DRADIS. DRADIS is free software: you can redistribute it +// and/or modify it under the terms of the GNU Affero General Public License, +// version 3, as published by the Free Software Foundation. +// +// DRADIS is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +// A PARTICULAR PURPOSE. See the GNU Affero General Public License for details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see . + +/// Tennis Raptor — live tennis event-state signal (score, server, break point). +/// +/// A *macro*, venue-neutral Raptor beside the Sports Raptor. Where the Sports +/// Raptor reads the public betting market's slow line drift across all sports, +/// the Tennis Raptor reads the **event itself**: the live score of one tracked +/// tennis match — sets, games, in-game points, who is serving, and whether the +/// receiver holds a break point. Both venues list tennis match markets; this is +/// the event-specific state a 300s generic line sample cannot carry. +/// +/// ── Source ────────────────────────────────────────────────────────────────── +/// The Live Tennis API (livetennisapi.com) REST surface, keyed on env +/// `LIVETENNIS_API_KEY` (`config::TENNIS_API_KEY_ENV`). Each poll fetches +/// `GET /matches?status=live` (FREE tier), picks a **tracked match** — sticky on +/// the previously tracked id while it stays live, otherwise the match with the +/// freshest score timestamp — and derives: +/// +/// │ Field │ Derivation │ +/// │────────────────│───────────────────────────────────────────────────────────│ +/// │ num_live │ live matches in the sample (0 = nothing on court) │ +/// │ sets/games │ tracked match's sets won + games in the current set │ +/// │ server │ serving side (1/2; 0 = unknown) │ +/// │ break_point │ receiver at AD, or receiver at 40 vs server <40; never in │ +/// │ │ tiebreaks │ +/// │ feed_age_secs │ now − tracked score's `timestamp` (staleness measure) │ +/// +/// ── Budget (honest numbers) ───────────────────────────────────────────────── +/// The free tier allows 30 req/min and 100 req/day. The default +/// `TENNIS_POLL_SECS = 900` fits all-day polling inside the free day cap +/// (≤96 req/day); dropping to ~60s gives near point-level tracking but burns +/// the free day cap in ~100 minutes — fine for develop-and-test or following a +/// few matches, while sustained all-day fast polling needs a paid tier. The +/// top tier's push WebSocket and model win-probability fields are NOT used +/// here — v1 is free-tier REST only (a WS lane can arrive later, config-gated). +/// +/// ── Feed health / staleness ───────────────────────────────────────────────── +/// Every score carries the API's own `timestamp` (last score change, UTC). When +/// the tracked match's score is older than `TENNIS_SCORE_STALENESS_SECS` the +/// raptor reports `tennis_connected = false` while still publishing the last +/// values plus `feed_age_secs`, so a consumer treats a stale feed exactly like +/// a missing one — widen or pull, never hold on it. Tennis pauses legitimately +/// (changeovers ~90s, set breaks ~120s+), so the threshold sits well above +/// those. Zero live matches is a *healthy* state (`tennis_connected = true`, +/// `num_live = 0`), not a failure — tennis has quiet hours every day. +/// +/// ── Observe-only status ───────────────────────────────────────────────────── +/// Wired **observe-only** exactly like the Tide and Sports Raptors: it +/// publishes to telemetry but is NOT consumed by any Viper sizing. Without +/// `LIVETENNIS_API_KEY` it degrades silently to its `Default` (all-zero, +/// `tennis_connected = false`); consumers treat a zero snapshot as neutral. +/// Telemetry is published under the fixed `"tennis"` health-map key. +use std::collections::HashMap; +use std::sync::Arc; + +use rust_decimal::Decimal; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::api::server::AssetRaptorHealth; +use crate::config; + +/// Fixed health-map key under which the (venue-neutral) Tennis Raptor publishes +/// its telemetry, alongside "sports" and the per-asset crypto entries. +pub const TENNIS_HEALTH_KEY: &str = "tennis"; + +/// Normalised tennis event-state snapshot broadcast to every consuming Squadron. +/// +/// `Copy` so the `watch` channel hands out cheap value clones, and `Default` +/// (all-zero, `num_live = 0` meaning "no data") so the channel can be seeded +/// before the first successful poll and off-source reads are unambiguous. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TennisSnapshot { + /// Live matches in the sample. `0` = no data OR nothing on court — either + /// way there is no event state to act on (neutral). + pub num_live: Decimal, + /// Sets won by player 1 / player 2 in the tracked match. + pub sets_p1: Decimal, + pub sets_p2: Decimal, + /// Games won in the tracked match's current set. + pub games_p1: Decimal, + pub games_p2: Decimal, + /// Serving side of the tracked match: `1` or `2`; `0` = unknown. + pub server: Decimal, + /// True when the receiver holds a break point: receiver at AD, or receiver + /// at 40 while the server is below 40. Never true in a tiebreak. + pub break_point: bool, + /// True while the tracked match's current game is a tiebreak. + pub is_tiebreak: bool, + /// Age (seconds) of the tracked score's API timestamp at poll time. + /// `-1` when unknown (no tracked match, or no timestamp on the score). + pub feed_age_secs: Decimal, +} + +pub async fn run_tennis_raptor( + http: Arc, + tennis_tx: watch::Sender, + raptor_health_tx: Arc>>, +) { + let api_key = std::env::var(config::TENNIS_API_KEY_ENV).ok().filter(|k| !k.is_empty()); + let Some(api_key) = api_key else { + info!( + "🎾 Tennis Raptor idle — {} not set (observe-only; no event-state signal)", + config::TENNIS_API_KEY_ENV + ); + // Seed a neutral snapshot + offline telemetry, then park. Receivers stay + // valid; consumers read a zero snapshot as neutral. + let _ = tennis_tx.send(TennisSnapshot::default()); + raptor_health_tx.send_modify(|map| { + map.entry(TENNIS_HEALTH_KEY.to_string()).or_default().tennis_connected = false; + }); + std::future::pending::<()>().await; + return; + }; + + let url = live_matches_url(config::TENNIS_TOUR); + + // Track the previously tracked match id so the raptor stays on the SAME + // match while it remains live and rotates cleanly when it finishes. + let mut tracked: Option = None; + let mut consecutive_failures: u32 = 0; + + loop { + match try_fetch_live_state(&http, &url, &api_key, tracked).await { + Ok(sample) => { + consecutive_failures = 0; + tracked = (sample.match_id != 0).then_some(sample.match_id); + + let snap = TennisSnapshot { + num_live: Decimal::from(sample.num_live), + sets_p1: Decimal::from(sample.sets_p1), + sets_p2: Decimal::from(sample.sets_p2), + games_p1: Decimal::from(sample.games_p1), + games_p2: Decimal::from(sample.games_p2), + server: Decimal::from(sample.server), + break_point: sample.break_point, + is_tiebreak: sample.is_tiebreak, + feed_age_secs: Decimal::from(sample.feed_age_secs), + }; + let _ = tennis_tx.send(snap); + // Stage-4 semantics: a stale feed reads as NOT connected, so any + // consumer widens/pulls exactly as if the feed were missing. + let connected = !sample.stale; + raptor_health_tx.send_modify(|map| { + let h = map.entry(TENNIS_HEALTH_KEY.to_string()).or_default(); + h.tennis_connected = connected; + h.tennis_num_live = snap.num_live; + h.tennis_sets_p1 = snap.sets_p1; + h.tennis_sets_p2 = snap.sets_p2; + h.tennis_games_p1 = snap.games_p1; + h.tennis_games_p2 = snap.games_p2; + h.tennis_server = snap.server; + h.tennis_break_point = snap.break_point; + h.tennis_is_tiebreak = snap.is_tiebreak; + h.tennis_feed_age_secs = snap.feed_age_secs; + h.tennis_match = sample.match_label.clone(); + h.tennis_tournament = sample.tournament.clone(); + h.tennis_tour = sample.tour.clone(); + h.tennis_points = sample.points_label.clone(); + h.tennis_score_at = sample.score_at.clone(); + }); + if sample.stale { + warn!( + "🎾 Tennis Raptor [{}]: score stale ({}s > {}s limit) — reporting disconnected \ + (consumers widen/pull, never hold)", + sample.match_label, sample.feed_age_secs, config::TENNIS_SCORE_STALENESS_SECS, + ); + } else if sample.match_id != 0 { + info!( + "🎾 Tennis Raptor [{}]: sets {}-{} games {}-{} pts {} server={}{}{} live={}", + sample.match_label, sample.sets_p1, sample.sets_p2, + sample.games_p1, sample.games_p2, sample.points_label, sample.server, + if sample.break_point { " BREAK-POINT" } else { "" }, + if sample.is_tiebreak { " TIEBREAK" } else { "" }, + sample.num_live, + ); + } else { + debug!("🎾 Tennis Raptor: no live matches (feed healthy; snapshot neutral)"); + } + } + Err(reason) => { + consecutive_failures += 1; + raptor_health_tx.send_modify(|map| { + map.entry(TENNIS_HEALTH_KEY.to_string()).or_default().tennis_connected = false; + }); + if consecutive_failures == 1 { + warn!("⚠️ Tennis Raptor poll failed: {reason} (will retry silently; signal treated as neutral)"); + } else { + debug!("🎾 Tennis Raptor unavailable (attempt {}): {reason}", consecutive_failures); + } + } + } + tokio::time::sleep(std::time::Duration::from_secs(config::TENNIS_POLL_SECS)).await; + } +} + +/// Build the live-matches poll URL, with the optional tour filter +/// (`atp` / `wta` / `challenger` / `itf` / `juniors`; empty = all tours). +/// The API key travels in the `Authorization: Bearer` header, never the URL. +fn live_matches_url(tour: &str) -> String { + let base = "https://api.livetennisapi.com/api/public/v1/matches?status=live"; + if tour.is_empty() { + base.to_string() + } else { + format!("{base}&tour={tour}") + } +} + +/// Parsed event state for the tracked live match in a poll response. +#[derive(Debug)] +struct TennisSample { + /// Tracked match id (`0` = none tracked, e.g. nothing live). + match_id: i64, + /// Human label, e.g. "C. Alcaraz vs J. Sinner". + match_label: String, + /// Tournament name from the feed. + tournament: String, + /// Tour ("atp"/"wta"/…), empty when the feed never stated one. + tour: String, + /// In-game points as the feed's tennis strings, e.g. "30–40" or "AD–40". + points_label: String, + /// ISO-8601 UTC timestamp of the tracked score (empty when absent). + score_at: String, + num_live: u32, + sets_p1: u32, + sets_p2: u32, + games_p1: u32, + games_p2: u32, + /// Serving side (1/2; 0 = unknown). + server: u32, + break_point: bool, + is_tiebreak: bool, + /// Age of the score timestamp in seconds (`-1` = unknown). + feed_age_secs: i64, + /// True when the tracked score is older than `TENNIS_SCORE_STALENESS_SECS`. + stale: bool, +} + +/// Fetch the live-match list and reduce it to the tracked match's event state. +/// +/// Returns `Err(reason)` on any failure so the caller can log *why* the poll +/// produced no signal (bad key, quota exhausted, error payload, bad JSON, …). +async fn try_fetch_live_state( + http: &reqwest::Client, + url: &str, + api_key: &str, + tracked: Option, +) -> Result { + let resp = tokio::time::timeout( + std::time::Duration::from_secs(8), + http.get(url).bearer_auth(api_key).send(), + ) + .await + .map_err(|_| "request timed out after 8s".to_string())? + .map_err(|e| format!("transport error: {e}"))?; + + let status = resp.status(); + // The API documents X-RateLimit-* headers on every response (the daily + // quota itself lives on GET /usage, which is quota-exempt). Read the + // remaining count opportunistically so budget draw-down is visible in the + // logs and the free-tier cap never arrives as a surprise 429. + let remaining = resp + .headers() + .get("x-ratelimit-remaining") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim().parse::().ok()); + match remaining { + Some(r) if r <= config::TENNIS_LOW_BUDGET_WARN => warn!( + "⚠️ Tennis Raptor: Live Tennis API budget low — {r} requests remaining in the current window. \ + Consider raising TENNIS_POLL_SECS (free tier: 30 req/min, 100 req/day)." + ), + Some(r) => debug!("🎾 Tennis Raptor budget: {r} requests remaining in the current window"), + None => {} + } + let body = resp.text().await.map_err(|e| format!("failed reading body: {e}"))?; + if !status.is_success() { + // Errors arrive as JSON `{ "error": code, "detail": ... }` — surface a + // truncated snippet so 401 (bad key) / 403 (upgrade_required) / 429 + // (rate_limited / daily quota) are obvious in the logs. + let snippet: String = body.chars().take(200).collect(); + return Err(format!("HTTP {status}: {snippet}")); + } + + reduce_live_matches(&body, chrono::Utc::now(), tracked) +} + +/// Reduce a `GET /matches?status=live` response body to the tracked match's +/// event state. Pure (no I/O) so the parse/derivation logic is unit-testable. +fn reduce_live_matches( + body: &str, + now: chrono::DateTime, + tracked: Option, +) -> Result { + let parsed: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?; + // List endpoints return `{data, meta}`; an error object carries `error`. + if let Some(code) = parsed.get("error").and_then(|e| e.as_str()) { + let detail = parsed.get("detail").and_then(|d| d.as_str()).unwrap_or(""); + return Err(format!("API error '{code}': {detail}")); + } + let matches = parsed + .get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| { + let snippet: String = body.chars().take(200).collect(); + format!("expected {{data: [...]}} match list, got: {snippet}") + })?; + + let num_live = matches.len() as u32; + + // Neutral (but healthy) sample when nothing is on court — tennis has quiet + // hours every day, so an empty live list is a real state, not a failure. + let neutral = |num_live: u32| TennisSample { + match_id: 0, + match_label: String::new(), + tournament: String::new(), + tour: String::new(), + points_label: String::new(), + score_at: String::new(), + num_live, + sets_p1: 0, sets_p2: 0, games_p1: 0, games_p2: 0, + server: 0, + break_point: false, + is_tiebreak: false, + feed_age_secs: -1, + stale: false, + }; + if matches.is_empty() { + return Ok(neutral(0)); + } + + // Pick the tracked match: sticky on the previously tracked id while it is + // still in the live list (so state reads as one continuous match), else the + // match with the freshest score timestamp (ties broken by lowest id — + // timestamps are ISO-8601 UTC "Z", so lexical order == chronological order). + // Matches whose `score` is null carry no event state and are not selectable. + let still_tracked = tracked.and_then(|id| { + matches + .iter() + .find(|m| match_id_of(m) == Some(id) && m.get("score").is_some_and(|s| s.is_object())) + }); + let event = still_tracked.or_else(|| { + matches + .iter() + .filter(|m| m.get("score").is_some_and(|s| s.is_object()) && match_id_of(m).is_some()) + .max_by_key(|m| { + let ts = score_timestamp(m).unwrap_or_default().to_string(); + (ts, std::cmp::Reverse(match_id_of(m).unwrap_or(i64::MAX))) + }) + }); + let Some(event) = event else { + // Live matches listed but none carries a score object yet. + return Ok(neutral(num_live)); + }; + + let match_id = match_id_of(event).ok_or_else(|| "tracked match missing id".to_string())?; + let p1 = player_name(event, "p1"); + let p2 = player_name(event, "p2"); + let match_label = format!("{p1} vs {p2}"); + let tournament = event + .get("tournament") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + let tour = event.get("tour").and_then(|t| t.as_str()).unwrap_or("").to_string(); + + let score = event + .get("score") + .ok_or_else(|| format!("match '{match_label}' has no score object"))?; + + // Sets won: `score.sets` is `[sets_p1, sets_p2]`. + let (sets_p1, sets_p2) = int_pair(score.get("sets")); + // Games: `score.games` is `[[per-set games p1], [per-set games p2]]` — the + // current set is the LAST entry of each list. Completed matches have been + // observed live with empty games arrays; read those as 0, never panic. + let games_p1 = last_game_count(score.get("games"), 0); + let games_p2 = last_game_count(score.get("games"), 1); + + // In-game points arrive as tennis strings ("0", "15", "30", "40", "AD"); + // entries can be NULL per the API spec, so decode defensively. + let pts_p1 = point_str(score.get("points"), 0); + let pts_p2 = point_str(score.get("points"), 1); + let server = score + .get("server") + .and_then(|s| s.as_u64()) + .filter(|s| *s == 1 || *s == 2) + .unwrap_or(0) as u32; + let is_tiebreak = score.get("is_tiebreak").and_then(|t| t.as_bool()).unwrap_or(false); + let break_point = is_break_point(server, pts_p1.as_deref(), pts_p2.as_deref(), is_tiebreak); + let points_label = match (&pts_p1, &pts_p2) { + (Some(a), Some(b)) => format!("{a}–{b}"), + _ => String::new(), + }; + + // Staleness: the score's `timestamp` is the API's own last-change instant. + let score_at = score + .get("timestamp") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + let feed_age_secs = chrono::DateTime::parse_from_rfc3339(&score_at) + .map(|t| (now - t.with_timezone(&chrono::Utc)).num_seconds().max(0)) + .unwrap_or(-1); + let stale = feed_age_secs > config::TENNIS_SCORE_STALENESS_SECS as i64; + + Ok(TennisSample { + match_id, + match_label, + tournament, + tour, + points_label, + score_at, + num_live, + sets_p1, + sets_p2, + games_p1, + games_p2, + server, + break_point, + is_tiebreak, + feed_age_secs, + stale, + }) +} + +/// Break-point derivation from the feed's tennis point strings. +/// +/// The receiver holds a break point when they are one point from winning the +/// server's service game: receiver at `AD`, or receiver at `40` while the +/// server is below 40 (`0`/`15`/`30`). Deuce (40–40) and server-advantage are +/// NOT break points, and a tiebreak never is — there is no service game to +/// break (mini-breaks are a different concept, deliberately not derived here). +/// Unknown server or missing point strings read as `false`, never a guess. +fn is_break_point(server: u32, pts_p1: Option<&str>, pts_p2: Option<&str>, is_tiebreak: bool) -> bool { + if is_tiebreak { + return false; + } + let (Some(p1), Some(p2)) = (pts_p1, pts_p2) else { return false }; + let (server_pts, receiver_pts) = match server { + 1 => (p1, p2), + 2 => (p2, p1), + _ => return false, + }; + match (server_pts, receiver_pts) { + (_, "AD") => true, + (s, "40") => matches!(s, "0" | "15" | "30"), + _ => false, + } +} + +/// The match's integer `id`, when present. +fn match_id_of(m: &serde_json::Value) -> Option { + m.get("id").and_then(|i| i.as_i64()) +} + +/// The score's ISO-8601 `timestamp` string, when present. +fn score_timestamp(m: &serde_json::Value) -> Option<&str> { + m.get("score")?.get("timestamp")?.as_str() +} + +/// A player's display name from `players.p1` / `players.p2`. +fn player_name(m: &serde_json::Value, side: &str) -> String { + m.get("players") + .and_then(|p| p.get(side)) + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("?") + .to_string() +} + +/// Decode a `[a, b]` integer pair (e.g. `score.sets`), defaulting to 0. +fn int_pair(v: Option<&serde_json::Value>) -> (u32, u32) { + let get = |i: usize| { + v.and_then(|a| a.as_array()) + .and_then(|a| a.get(i)) + .and_then(|n| n.as_u64()) + .unwrap_or(0) as u32 + }; + (get(0), get(1)) +} + +/// Games won in the current set for one side: the LAST entry of that side's +/// per-set list in `score.games`. Empty/missing lists read as 0. +fn last_game_count(games: Option<&serde_json::Value>, side: usize) -> u32 { + games + .and_then(|g| g.as_array()) + .and_then(|g| g.get(side)) + .and_then(|s| s.as_array()) + .and_then(|s| s.last()) + .and_then(|n| n.as_u64()) + .unwrap_or(0) as u32 +} + +/// One side's in-game point string from `score.points`; entries can be NULL. +fn point_str(points: Option<&serde_json::Value>, side: usize) -> Option { + points + .and_then(|p| p.as_array()) + .and_then(|p| p.get(side)) + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + /// A live `/matches?status=live` body with `n` matches. Match ids, score + /// timestamps and point states are taken by the callers below. + fn body_with(matches: &[serde_json::Value]) -> String { + serde_json::json!({ + "data": matches, + "meta": { "limit": 50, "offset": 0, "count": matches.len(), "total": matches.len(), "has_more": false } + }) + .to_string() + } + + fn live_match( + id: i64, + ts: &str, + server: serde_json::Value, + points: serde_json::Value, + is_tiebreak: bool, + ) -> serde_json::Value { + serde_json::json!({ + "id": id, + "tournament": "Cincinnati Open", + "tour": "atp", + "status": "live", + "is_doubles": false, + "players": { + "p1": { "id": 10, "name": "C. Alcaraz" }, + "p2": { "id": 11, "name": "J. Sinner" } + }, + "score": { + "sets": [1, 0], + "games": [[6, 3], [4, 5]], + "points": points, + "server": server, + "is_tiebreak": is_tiebreak, + "timestamp": ts + } + }) + } + + fn now() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 8, 16, 14, 0, 30).unwrap() + } + + #[test] + fn parses_live_match_state() { + let body = body_with(&[live_match( + 42, + "2026-08-16T14:00:10Z", + serde_json::json!(1), + serde_json::json!(["30", "40"]), + false, + )]); + let s = reduce_live_matches(&body, now(), None).unwrap(); + assert_eq!(s.match_id, 42); + assert_eq!(s.match_label, "C. Alcaraz vs J. Sinner"); + assert_eq!(s.tournament, "Cincinnati Open"); + assert_eq!(s.tour, "atp"); + assert_eq!(s.num_live, 1); + assert_eq!((s.sets_p1, s.sets_p2), (1, 0)); + // Current set = LAST entry of each per-set games list. + assert_eq!((s.games_p1, s.games_p2), (3, 5)); + assert_eq!(s.server, 1); + assert_eq!(s.points_label, "30–40"); + // Server 1 at 30, receiver (p2) at 40 → break point. + assert!(s.break_point); + assert!(!s.is_tiebreak); + assert_eq!(s.feed_age_secs, 20); + assert!(!s.stale); + } + + /// One break-point derivation case: (server, p1 pts, p2 pts, tiebreak, expected). + type BpCase = (u32, Option<&'static str>, Option<&'static str>, bool, bool); + + #[test] + fn break_point_matrix() { + let cases: &[BpCase] = &[ + // Receiver at 40 vs server below 40 → BP. + (1, Some("0"), Some("40"), false, true), + (1, Some("15"), Some("40"), false, true), + (1, Some("30"), Some("40"), false, true), + (2, Some("40"), Some("15"), false, true), + // Deuce is NOT a break point. + (1, Some("40"), Some("40"), false, false), + // Receiver advantage → BP; server advantage → not. + (1, Some("40"), Some("AD"), false, true), + (1, Some("AD"), Some("40"), false, false), + (2, Some("AD"), Some("40"), false, true), + // Server ahead or level below 40 → not. + (1, Some("40"), Some("30"), false, false), + (1, Some("15"), Some("15"), false, false), + // Never in a tiebreak, even with BP-shaped strings. + (1, Some("30"), Some("40"), true, false), + // Unknown server / missing points → never a guess. + (0, Some("30"), Some("40"), false, false), + (1, None, Some("40"), false, false), + (1, Some("30"), None, false, false), + ]; + for &(server, p1, p2, tb, expected) in cases { + assert_eq!( + is_break_point(server, p1, p2, tb), + expected, + "server={server} p1={p1:?} p2={p2:?} tiebreak={tb}" + ); + } + } + + #[test] + fn tiebreak_state_is_reported_but_never_a_break_point() { + let body = body_with(&[live_match( + 7, + "2026-08-16T14:00:25Z", + serde_json::json!(2), + serde_json::json!(["6", "5"]), + true, + )]); + let s = reduce_live_matches(&body, now(), None).unwrap(); + assert!(s.is_tiebreak); + assert!(!s.break_point); + assert_eq!(s.points_label, "6–5"); + } + + #[test] + fn null_points_and_empty_games_do_not_panic() { + // Observed live on completed matches: null point entries + empty games. + let mut m = live_match(9, "2026-08-16T13:00:00Z", serde_json::Value::Null, serde_json::json!([null, null]), false); + m["score"]["games"] = serde_json::json!([[], []]); + let s = reduce_live_matches(&body_with(&[m]), now(), None).unwrap(); + assert_eq!((s.games_p1, s.games_p2), (0, 0)); + assert_eq!(s.server, 0); + assert!(!s.break_point); + assert_eq!(s.points_label, ""); + } + + #[test] + fn tracks_freshest_score_then_sticks_to_it() { + let older = live_match(1, "2026-08-16T13:59:00Z", serde_json::json!(1), serde_json::json!(["0", "0"]), false); + let fresher = live_match(2, "2026-08-16T14:00:20Z", serde_json::json!(2), serde_json::json!(["15", "30"]), false); + let body = body_with(&[older.clone(), fresher.clone()]); + + // No prior tracked match → the freshest score wins. + let s = reduce_live_matches(&body, now(), None).unwrap(); + assert_eq!(s.match_id, 2); + assert_eq!(s.num_live, 2); + + // A previously tracked match stays tracked even when another is fresher. + let s = reduce_live_matches(&body, now(), Some(1)).unwrap(); + assert_eq!(s.match_id, 1); + + // Tracked match gone from the live list → rotate to the freshest. + let body_rotated = body_with(&[fresher]); + let s = reduce_live_matches(&body_rotated, now(), Some(1)).unwrap(); + assert_eq!(s.match_id, 2); + } + + #[test] + fn stale_score_is_flagged_not_hidden() { + // Score last moved 20 minutes ago — beyond TENNIS_SCORE_STALENESS_SECS. + let body = body_with(&[live_match( + 3, + "2026-08-16T13:40:30Z", + serde_json::json!(1), + serde_json::json!(["15", "0"]), + false, + )]); + let s = reduce_live_matches(&body, now(), None).unwrap(); + assert_eq!(s.feed_age_secs, 1200); + assert!(s.stale); + // The state itself is still published alongside the stale flag. + assert_eq!(s.match_id, 3); + } + + #[test] + fn missing_timestamp_reads_as_unknown_age_not_stale() { + let mut m = live_match(4, "", serde_json::json!(1), serde_json::json!(["0", "0"]), false); + m["score"]["timestamp"] = serde_json::Value::Null; + let s = reduce_live_matches(&body_with(&[m]), now(), None).unwrap(); + assert_eq!(s.feed_age_secs, -1); + assert!(!s.stale); + } + + #[test] + fn empty_live_list_is_healthy_neutral() { + let s = reduce_live_matches(&body_with(&[]), now(), None).unwrap(); + assert_eq!(s.num_live, 0); + assert_eq!(s.match_id, 0); + assert!(!s.stale); + assert_eq!(s.feed_age_secs, -1); + } + + #[test] + fn api_error_object_is_surfaced() { + let body = r#"{"error":"rate_limited","detail":"Daily quota exhausted"}"#; + let err = reduce_live_matches(body, now(), None).unwrap_err(); + assert!(err.contains("rate_limited"), "got: {err}"); + assert!(err.contains("Daily quota exhausted"), "got: {err}"); + } + + #[test] + fn malformed_bodies_are_errors_not_panics() { + assert!(reduce_live_matches("not json", now(), None).is_err()); + assert!(reduce_live_matches(r#"{"unexpected": true}"#, now(), None).is_err()); + assert!(reduce_live_matches(r#"[1,2,3]"#, now(), None).is_err()); + } + + #[test] + fn live_url_carries_optional_tour_filter() { + assert_eq!( + live_matches_url(""), + "https://api.livetennisapi.com/api/public/v1/matches?status=live" + ); + assert_eq!( + live_matches_url("wta"), + "https://api.livetennisapi.com/api/public/v1/matches?status=live&tour=wta" + ); + } +} diff --git a/src/squadron/raptors.rs b/src/squadron/raptors.rs index 1c43960..7133208 100644 --- a/src/squadron/raptors.rs +++ b/src/squadron/raptors.rs @@ -32,6 +32,7 @@ use tokio::sync::watch; use crate::raptors::derivatives::DerivativesSnapshot; use crate::raptors::tide::TideSnapshot; use crate::raptors::sports::SportsSnapshot; +use crate::raptors::tennis::TennisSnapshot; use crate::raptors::horizon::HorizonSnapshot; /// All Raptor signal receivers available to a squadron. @@ -72,6 +73,14 @@ pub struct SquadronRaptors { /// pipelines — not yet consumed by Viper sizing (telemetry observation phase). /// `None` when the Sports Raptor is not deployed for this squadron. pub sports: Option>, + + /// Live tennis event-state snapshot from the Tennis Raptor (Live Tennis + /// API). A *macro / observe-only* signal shared by all pipelines — not yet + /// consumed by Viper sizing (telemetry observation phase). `None` when the + /// Tennis Raptor is not deployed for this squadron. Attached after + /// construction (`raptors.tennis = Some(rx)`), the same way the US general + /// wing attaches its sports feed, so the constructor signatures stay stable. + pub tennis: Option>, // ── Future Raptors ──────────────────────────────────────────────────────── // pub politics: Option>, } @@ -99,6 +108,7 @@ impl SquadronRaptors { tide, horizon, sports, + tennis: None, } } @@ -109,7 +119,7 @@ impl SquadronRaptors { velocity: watch::Receiver<(Decimal, Decimal, Decimal)>, drift: watch::Receiver<(Decimal, Decimal, Decimal)>, ) -> Self { - Self { oracle, velocity, drift, funding: None, derivatives: None, tide: None, horizon: None, sports: None } + Self { oracle, velocity, drift, funding: None, derivatives: None, tide: None, horizon: None, sports: None, tennis: None } } /// Compose a sports-only bundle for Admiral Adama sports market squadrons. @@ -127,6 +137,7 @@ impl SquadronRaptors { tide: None, horizon: None, sports: Some(sports), + tennis: None, } } @@ -145,6 +156,7 @@ impl SquadronRaptors { tide: None, horizon: None, sports: None, + tennis: None, } } } From c68e1b36be70627e114354cb736f2e5ccc495ca3 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:49:35 +0300 Subject: [PATCH 2/2] review: telemetry heartbeat, US-build tennis receiver, doc + .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #7 review: - Telemetry heartbeat 300s → 1800s to match the Sports feed. The raptor polls every 900s, so nothing can change between polls — the shorter heartbeat only re-stored identical points and cut retained history to ~5 days instead of ~30. Comment rewritten to state the real rationale. - US build: thread the tennis receiver through run_us_trader → run_wing → trade_one_market → register_us_squadron, attached to both wings' SquadronRaptors exactly like us_sports_rx, so the channel stays live for the first consumer instead of relying on a scope-held binding. - tennis.rs header doc: state up front that the tracked match is sticky on the previous id, else freshest score, and is NOT yet tied to a specific venue market (left to the first consumer). - .env.example: LIVETENNIS_API_KEY entry per the ODDS_API_KEY template, for headless runs without the UI. cargo test green (142 passed), us_retail + kalshi feature checks clean. Co-Authored-By: Claude Fable 5 --- .env.example | 10 ++++++++++ src/api/server.rs | 9 +++++---- src/main.rs | 8 +++++--- src/raptors/tennis.rs | 6 ++++++ src/venues/us/trader.rs | 40 +++++++++++++++++++++++++++------------- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 790d758..ec8713d 100644 --- a/.env.example +++ b/.env.example @@ -209,6 +209,16 @@ RUST_LOG=info,dradis=info # run the Sports Raptor idle/observe-only (neutral snapshot, "Sports Raptor" pill shows offline). # ODDS_API_KEY=your_the_odds_api_key_here +# ── Optional: Live Tennis API key for the Tennis Raptor ────────────────────── +# Powers the venue-neutral Tennis Raptor's live event-state feed — one tracked +# live match reduced to sets/games/points, the serving side, and a derived +# break-point flag, with feed-staleness reporting. Free tier at +# https://livetennisapi.com (30 req/min, 100 req/day; the default 900s poll +# stays inside the day cap and the raptor logs remaining budget each poll). +# Leave unset to run the Tennis Raptor idle/observe-only (neutral snapshot, +# tennis telemetry reports offline). +# LIVETENNIS_API_KEY=your_livetennisapi_key_here + # ── Optional: LLM Advisor provider ──────────────────────────────────────────── # Default is local/remote Ollama (no key). Set LLM_PROVIDER to use a hosted API: # ollama — OLLAMA_URL / OLLAMA_MODEL (defaults from config.rs) diff --git a/src/api/server.rs b/src/api/server.rs index 486e9b7..135b7bb 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -316,11 +316,12 @@ const SPORTS_HISTORY_CAP: usize = 1440; const SPORTS_TELEMETRY_HEARTBEAT_SECS: i64 = 1800; /// The Tennis Raptor is another slow poller (`config::TENNIS_POLL_SECS`, 900s /// default), so it gets the same change-or-heartbeat de-duplication as the -/// Sports feed — but with a shorter heartbeat: a live tennis score moves every -/// few points, and when it *doesn't* move the advancing heartbeat is what makes -/// the staleness visible on the chart. +/// Sports feed, with the same heartbeat: nothing can change between polls, so +/// a heartbeat shorter than the poll interval would only re-store identical +/// points and shrink the retained window. 1440 points × ≥30 min spans the same +/// ~30 days of readable movement as the Sports feed. const TENNIS_HISTORY_CAP: usize = 1440; -const TENNIS_TELEMETRY_HEARTBEAT_SECS: i64 = 300; +const TENNIS_TELEMETRY_HEARTBEAT_SECS: i64 = 1800; /// Background task — every `TELEMETRY_SAMPLE_SECS`, snapshot the current Raptor /// signal values into the per-asset ring buffer. Spawned once by diff --git a/src/main.rs b/src/main.rs index d8a0c4d..0304347 100644 --- a/src/main.rs +++ b/src/main.rs @@ -403,9 +403,10 @@ async fn run() -> Result<()> { // ── Tennis Raptor (venue-neutral, observe-only) ─────────────────────── // Spawned beside the Sports Raptor so its live event-state telemetry // ("tennis" health key) is available on the US build too. Degrades to - // Default without LIVETENNIS_API_KEY. Observe-only: telemetry now, - // squadron wiring when a consumer earns it. - let (us_tennis_tx, _us_tennis_rx) = + // Default without LIVETENNIS_API_KEY. Its receiver is threaded into the + // US trader's SquadronRaptors like the Sports feed, so the channel stays + // live for the first consumer. + let (us_tennis_tx, us_tennis_rx) = watch::channel(dradis::raptors::tennis::TennisSnapshot::default()); tokio::spawn(dradis::raptors::tennis::run_tennis_raptor( Arc::clone(&shared_http), us_tennis_tx, Arc::clone(&raptor_health_tx), @@ -460,6 +461,7 @@ async fn run() -> Result<()> { Arc::clone(&markets_tx), Arc::clone(&process_heartbeat_secs), us_sports_rx, + us_tennis_rx, cancel, ).await; } diff --git a/src/raptors/tennis.rs b/src/raptors/tennis.rs index fe61db8..09ba11e 100644 --- a/src/raptors/tennis.rs +++ b/src/raptors/tennis.rs @@ -23,6 +23,12 @@ /// receiver holds a break point. Both venues list tennis match markets; this is /// the event-specific state a 300s generic line sample cannot carry. /// +/// The tracked match is chosen for signal liveness, not market linkage: sticky +/// on the previously tracked id while it stays live, otherwise whichever live +/// match has the freshest score. It is NOT yet tied to a specific venue +/// market — matching the tracked match to a listed tennis market is +/// deliberately left to the first consumer. +/// /// ── Source ────────────────────────────────────────────────────────────────── /// The Live Tennis API (livetennisapi.com) REST surface, keyed on env /// `LIVETENNIS_API_KEY` (`config::TENNIS_API_KEY_ENV`). Each poll fetches diff --git a/src/venues/us/trader.rs b/src/venues/us/trader.rs index f8eb866..9e17393 100644 --- a/src/venues/us/trader.rs +++ b/src/venues/us/trader.rs @@ -68,6 +68,7 @@ use crate::squadron::{CryptoAsset, Squadron, SquadronConfig, SquadronRaptors, Sq use crate::raptors::derivatives::DerivativesSnapshot; use crate::raptors::horizon::HorizonSnapshot; use crate::raptors::sports::SportsSnapshot; +use crate::raptors::tennis::TennisSnapshot; use crate::raptors::tide::TideSnapshot; use crate::state::{ TradeScope, @@ -357,6 +358,7 @@ enum MarketOutcome { /// its own schedule) rather than the hourly-crypto cadence. The shared /// [`MarketConfig::phase`] classifier and the squadron RTB/stand-down state /// machine are reused so close semantics are identical across venues. +#[allow(clippy::too_many_arguments)] pub async fn run_us_trader( venue: Arc, cag: Cag, @@ -364,6 +366,7 @@ pub async fn run_us_trader( markets_tx: Arc>>, process_heartbeat_secs: Arc, sports_rx: watch::Receiver, + tennis_rx: watch::Receiver, cancel: CancellationToken, ) { let filter = std::env::var(ENV_MARKET_FILTER).ok().filter(|s| !s.is_empty()); @@ -376,11 +379,11 @@ pub async fn run_us_trader( tokio::join!( run_wing( Wing::General, &venue, &cag, &raptor_health_tx, &markets_tx, - &process_heartbeat_secs, &sports_rx, &filter, &cancel, + &process_heartbeat_secs, &sports_rx, &tennis_rx, &filter, &cancel, ), run_wing( Wing::Crypto, &venue, &cag, &raptor_health_tx, &markets_tx, - &process_heartbeat_secs, &sports_rx, &filter, &cancel, + &process_heartbeat_secs, &sports_rx, &tennis_rx, &filter, &cancel, ), ); } @@ -396,6 +399,7 @@ async fn run_wing( markets_tx: &Arc>>, process_heartbeat_secs: &Arc, sports_rx: &watch::Receiver, + tennis_rx: &watch::Receiver, filter: &Option, cancel: &CancellationToken, ) { @@ -450,6 +454,7 @@ async fn run_wing( markets_tx, process_heartbeat_secs, sports_rx, + tennis_rx, &market_cancel, wing, pair, @@ -580,6 +585,7 @@ async fn trade_one_market( markets_tx: &Arc>>, process_heartbeat_secs: &AtomicU64, sports_rx: &watch::Receiver, + tennis_rx: &watch::Receiver, cancel: &CancellationToken, wing: Wing, pair: super::markets::UsMarketPair, @@ -619,7 +625,7 @@ async fn trade_one_market( // The US venue runs a standalone arb loop (no intl-style patrol), but the // dashboard reads squadrons from the CAG registry — so without this the UI // shows zero squadrons even though the venue is live. - let squadron = register_us_squadron(cag, &pair, sports_rx.clone(), wing, raptors.as_ref(), strike_price); + let squadron = register_us_squadron(cag, &pair, sports_rx.clone(), tennis_rx.clone(), wing, raptors.as_ref(), strike_price); let squadron_id = squadron.id.clone(); // Seed the squadron's Viper config so the detail view's strategy cards render. @@ -1370,21 +1376,28 @@ fn register_us_squadron( cag: &Cag, pair: &super::markets::UsMarketPair, sports_rx: watch::Receiver, + tennis_rx: watch::Receiver, wing: Wing, crypto_raptors: Option<&CryptoRaptors>, strike_price: Option, ) -> Squadron { let raptors = match crypto_raptors { - Some(r) => SquadronRaptors::full( - r.oracle.clone(), - r.velocity.clone(), - r.drift.clone(), - r.funding.clone(), - r.derivatives.clone(), - r.tide.clone(), - r.horizon.clone(), - Some(sports_rx), - ), + Some(r) => { + let mut r2 = SquadronRaptors::full( + r.oracle.clone(), + r.velocity.clone(), + r.drift.clone(), + r.funding.clone(), + r.derivatives.clone(), + r.tide.clone(), + r.horizon.clone(), + Some(sports_rx), + ); + // The venue-neutral Tennis Raptor rides along observe-only, same + // post-construction attach as the general wing below. + r2.tennis = Some(tennis_rx); + r2 + } None => { // Placeholder signal channels (the general wing reads prices from // the WS feed). Receivers stay valid after the senders drop. @@ -1395,6 +1408,7 @@ fn register_us_squadron( // attach it so its observe-only line-movement signal is available. let mut r = SquadronRaptors::price_only(oracle_rx, velocity_rx, drift_rx); r.sports = Some(sports_rx); + r.tennis = Some(tennis_rx); r } };