Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Read relevant memory files before starting work to avoid re-learning prior conte

| Domain | Coverage | Data Sources |
|:-------|:---------|:-------------|
| **Sports Betting** | NBA, NHL, MLB, NFL, NCAA, MLS, World Cup, soccer, UFC, boxing, F1, NASCAR, PGA, IPL, esports (28 filters) | The Odds API, ESPN, NHL/MLB Stats, NWS |
| **Sports Betting** | NBA, NHL, MLB, NFL, NCAA, MLS, World Cup, soccer, UFC, boxing, F1, NASCAR, PGA, IPL, Wimbledon tennis, esports (30 filters) | The Odds API, ESPN, NHL/MLB Stats, NWS |
| **Prediction Markets** | Crypto (BTC, ETH, XRP, DOGE, SOL), weather (13 cities), S&P 500 | CoinGecko, Yahoo Finance, NWS |
| **Championship Futures** | NFL, NBA, NHL, MLB, PGA | Sportsbook futures odds |
| **Execution Pipeline** | Unified scan → risk-check → size → execute | Kalshi API (RSA-signed) |
Expand Down
51 changes: 51 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,57 @@

---

## 2026-06-28 -- Wimbledon Tennis Sport Coverage Added

### Why

Wimbledon 2026 starts June 29. The scanner had no tennis mapping, so Kalshi's
Wimbledon match-winner markets were invisible. Tennis was deferred from the
2026-06-20 World Cup release because markets weren't open yet (Kalshi API
confirmed 0 open markets on June 20 for all tested prefixes).

### What landed

- **Tennis wired as a new h2h-only sport** — match-winner (`game` category) only;
no spread or total markets on Kalshi for tennis. `KXATPMATCH` → `tennis_atp_wimbledon`
and `KXWTAMATCH` → `tennis_wta_wimbledon` added to `CATEGORY_MAP` and
`KALSHI_TO_ODDS_SPORT`. New `wimbledon` and `tennis` filter shortcuts.
- **Player-name extraction** — extended `extract_event_teams()` with a tennis-specific
regex for the "If [Player A] wins this match against [Player B]" rules_primary
pattern used by Kalshi tennis markets. All existing team-sport patterns unchanged.
- **Display wiring** — `KXATP`/`KXWTA` prefixes → sport label "Tennis" in
`ticker_display.py`. Player abbreviations in ticker suffixes pass through raw
(not in the US-sport team alias table, which is correct).
- **No edge-math changes** — tennis uses the existing de-vigged h2h moneyline
path (`detect_edge_game`). No spread/total stdev entries needed.

### Ticker prefix caveat

The Kalshi API was egress-blocked in the cloud environment during implementation,
so `KXATPMATCH`/`KXWTAMATCH` are best-guess. **Must be verified locally:**

```bash
python scripts/scan.py sports --filter wimbledon --top 30
```

If the real prefix differs (e.g. `KXATPGAME`, `KXWTAGAME`, `KXWIMMEN`), update
`CATEGORY_MAP`, `KALSHI_TO_ODDS_SPORT`, and `FILTER_SHORTCUTS` in
`scripts/kalshi/edge_detector.py`, plus `_SPORT_PREFIXES` in
`scripts/shared/ticker_display.py`.

### Verification

+10 tests (`TestTennisMappings` in `tests/test_edge_detection.py`; `TestTennisDisplay`
in `tests/test_ticker_display.py`) → **503 passing** (was 493).

### Files

`scripts/kalshi/edge_detector.py`, `scripts/shared/ticker_display.py`,
`tests/test_edge_detection.py`, `tests/test_ticker_display.py`,
`CLAUDE.md`, `docs/kalshi/kalshi-sports-betting/SPORTS_GUIDE.md`, `docs/CHANGELOG.md`.

---

## 2026-06-24 -- C4: retire the base "high" confidence tier's composite-score premium

### Why
Expand Down
15 changes: 15 additions & 0 deletions docs/kalshi/kalshi-sports-betting/SPORTS_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ Use `--filter` to target a specific sport. Supports comma-separated values for m
| `f1` | Formula 1 | Drivers + constructors championship | No (not on Odds API) |
| `nascar` | NASCAR | Race winners | No (not on Odds API) |

### Tennis

| Filter | Sport | Key Markets | Edge Detection |
|--------|-------|-------------|----------------|
| `wimbledon` | Wimbledon (ATP + WTA) | Match winner (h2h only) | Yes -- game |
| `tennis` | Wimbledon (ATP + WTA) | Match winner (h2h only) | Yes -- game |

> **Tennis specifics:** Match-winner only (no spread or total Kalshi markets). ATP men's singles routes to `tennis_atp_wimbledon`; WTA women's singles routes to `tennis_wta_wimbledon` via the Odds API. Player names are extracted from `rules_primary` via the "wins this match against" pattern. Player abbreviations in the ticker suffix pass through as-is (they aren't in the US-sport team alias table).
>
> **Ticker prefix caveat:** The wiring uses `KXATPMATCH` (ATP) and `KXWTAMATCH` (WTA). Kalshi may instead use `KXATPGAME`/`KXWTAGAME`, `KXWIMMEN`, or other variants — verify locally by running `python scripts/scan.py sports --filter wimbledon --top 30`. If the prefix differs, update `CATEGORY_MAP`, `KALSHI_TO_ODDS_SPORT`, and `FILTER_SHORTCUTS` in `scripts/kalshi/edge_detector.py` and the `_SPORT_PREFIXES` map in `scripts/shared/ticker_display.py`.

### Other Sports

| Filter | Sport | Key Markets | Edge Detection |
Expand Down Expand Up @@ -209,6 +220,10 @@ The system cross-references Kalshi prices against these Odds API sport keys:
| KXBOXING | `boxing_boxing` | Moneyline (h2h) |
| KXPGATOUR | `golf_{us_open,pga_championship,masters_tournament,the_open_championship}_winner` (outrights; resolved per-major by `futures_edge.py`) | Major tournament winner (4 majors only; weekly stops have no odds) |
| KXIPL | `cricket_ipl` | Moneyline (h2h) |
| KXATPMATCH | `tennis_atp_wimbledon` | Moneyline (h2h) |
| KXWTAMATCH | `tennis_wta_wimbledon` | Moneyline (h2h) |

> **Tennis prefix caveat:** Kalshi ticker prefixes for tennis were not directly verifiable (API egress-blocked in cloud). `KXATPMATCH`/`KXWTAMATCH` are best-guess — verify locally and update if needed.

> **Soccer is 3-way.** Soccer h2h returns home/draw/away. The Kalshi "team to win?" market is binary, so fair value is the team's devigged **win** share and a draw resolves to the NO side. (Before the 2026-06-03 fix, 3-outcome markets were silently skipped, so soccer produced no edges.)

Expand Down
22 changes: 22 additions & 0 deletions scripts/kalshi/edge_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
"KXUFCFIGHT": "game",
"KXBOXING": "game",
"KXIPL": "game",
# --- Tennis (Wimbledon) ---
"KXATPMATCH": "game",
"KXWTAMATCH": "game",
# --- Spread ---
"KXNBASPREAD": "spread",
"KXNHLSPREAD": "spread",
Expand Down Expand Up @@ -173,6 +176,12 @@
# futures_edge.py using the `outrights` market type.
# --- Cricket ---
"KXIPL": "cricket_ipl",
# --- Tennis (Wimbledon) ---
# h2h match-winner only; no spread/total on Kalshi.
# NOTE: verify these prefixes locally — Kalshi API was egress-blocked during
# the initial implementation (2026-06-28). Also try: KXATPGAME, KXWTAGAME.
"KXATPMATCH": "tennis_atp_wimbledon",
"KXWTAMATCH": "tennis_wta_wimbledon",
}


Expand Down Expand Up @@ -1112,6 +1121,13 @@ def extract_event_teams(market: dict) -> tuple[str, str] | None:
return _clean_team(match.group(1)), _clean_team(match.group(2))
# Fallback: simpler pattern
match = re.search(r"in the (.+?) (?:vs\.?|at) (.+?) (?:game|match)", rules, re.IGNORECASE)
if match:
return _clean_team(match.group(1)), _clean_team(match.group(2))
# Tennis h2h: "If [Player A] wins this match against [Player B]"
match = re.search(
r"If (.+?) wins this (?:[\w\s]*?)?match against (.+?)(?:\s+(?:at|in)\b|[,.]|$)",
rules, re.IGNORECASE,
)
if match:
return _clean_team(match.group(1)), _clean_team(match.group(2))
return None
Expand Down Expand Up @@ -1976,6 +1992,12 @@ def detect_edge_spread_analysis(market: dict) -> Opportunity | None:
"pga": ["__FUTURES__golf-futures"],
# --- Cricket ---
"ipl": ["KXIPL"],
# --- Tennis (Wimbledon) ---
# Match-winner only (no spread/total). Both ATP and WTA singles.
# NOTE: verify KXATPMATCH/KXWTAMATCH against live Kalshi markets.
# Alternate prefixes to try: KXATPGAME, KXWTAGAME, KXWIMMEN, KXWMENSINGLES.
"wimbledon": ["KXATPMATCH", "KXWTAMATCH"],
"tennis": ["KXATPMATCH", "KXWTAMATCH"],
# --- Esports ---
"cs2": ["KXCS2MAP", "KXCS2GAME"],
"lol": ["KXLOLMAP", "KXLOLGAME"],
Expand Down
4 changes: 3 additions & 1 deletion scripts/shared/ticker_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ def _resolve_team_abbr(abbr: str, ticker: str = "") -> str:
"KXGOLF": "golf", "KXPGA": "golf",
"KXNASCAR": "nascar", "KXIPL": "ipl",
"KXESPORT": "esports",
"KXATPMATCH": "tennis", "KXATP": "tennis",
"KXWTAMATCH": "tennis", "KXWTA": "tennis",
}


Expand All @@ -105,7 +107,7 @@ def _detect_sport(ticker: str) -> str | None:
"ncaab": "NCAAB", "ncaaf": "NCAAF", "soccer": "Soccer", "mls": "MLS",
"worldcup": "World Cup",
"ufc": "UFC", "boxing": "Boxing", "golf": "Golf", "nascar": "NASCAR",
"ipl": "IPL", "esports": "Esports",
"ipl": "IPL", "esports": "Esports", "tennis": "Tennis",
}


Expand Down
50 changes: 50 additions & 0 deletions tests/test_edge_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1297,3 +1297,53 @@ def test_missing_last_update_excluded_on_live_game(self):
assert _is_bookmaker_stale(bad_lu, live, 1200) is True
assert _is_bookmaker_stale(no_lu, pregame, 1200) is False



class TestTennisMappings:
"""Wimbledon tennis (KXATPMATCH / KXWTAMATCH) wiring — added 2026-06-28.

Tennis is h2h match-winner only (no spread/total). ATP and WTA singles
route to their respective Odds API keys. These tests assert only the
prefix/category wiring; live odds-matching requires local verification
(Kalshi API was egress-blocked in the cloud environment at implementation
time — run: python scripts/scan.py sports --filter wimbledon --top 30).

NOTE: if the live ticker prefix turns out to be KXATPGAME or KXWTAGAME
(not KXATPMATCH / KXWTAMATCH), update these constants and re-run the suite.
"""

def test_atp_match_categorized_as_game(self):
assert CATEGORY_MAP["KXATPMATCH"] == "game"

def test_wta_match_categorized_as_game(self):
assert CATEGORY_MAP["KXWTAMATCH"] == "game"

def test_atp_odds_sport_key(self):
assert KALSHI_TO_ODDS_SPORT["KXATPMATCH"] == "tennis_atp_wimbledon"

def test_wta_odds_sport_key(self):
assert KALSHI_TO_ODDS_SPORT["KXWTAMATCH"] == "tennis_wta_wimbledon"

def test_tennis_extract_event_teams_against_pattern(self):
market = {
"ticker": "KXATPMATCH-26JUL01DJOKMED-DJOK",
"rules_primary": (
"If Novak Djokovic wins this Wimbledon match against Carlos Alcaraz "
"in the men's singles tournament, this market resolves Yes."
),
}
result = extract_event_teams(market)
assert result is not None
player_a, player_b = result
assert "Djokovic" in player_a
assert "Alcaraz" in player_b

def test_tennis_extract_event_teams_simple_against(self):
market = {
"ticker": "KXWTAMATCH-26JUL01SWISWIA-SWI",
"rules_primary": "If Iga Swiatek wins this match against Elena Rybakina.",
}
result = extract_event_teams(market)
assert result is not None
assert "Swiatek" in result[0]
assert "Rybakina" in result[1]
22 changes: 22 additions & 0 deletions tests/test_ticker_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,25 @@ def test_date_only_ticker_never_started(self):

def test_unparseable_ticker_not_started(self):
assert is_game_started("RANDOM-TICKER") is False


# ── Tennis (Wimbledon) ───────────────────────────────────────────────────────

class TestTennisDisplay:
"""Sport detection and label for Wimbledon ATP/WTA tickers."""

def test_atp_ticker_sport_is_tennis(self):
from ticker_display import sport_from_ticker
assert sport_from_ticker("KXATPMATCH-26JUL01DJOKMED-DJOK") == "Tennis"

def test_wta_ticker_sport_is_tennis(self):
from ticker_display import sport_from_ticker
assert sport_from_ticker("KXWTAMATCH-26JUL01SWIRYB-SWI") == "Tennis"

def test_atp_player_abbr_returned_raw(self):
# Player abbreviations aren't in TEAM_NAMES, so parse_pick_team returns
# the raw suffix rather than mis-resolving it as a US team.
assert parse_pick_team("KXATPMATCH-26JUL01DJOKMED-DJOK") == "DJOK"

def test_wta_player_abbr_returned_raw(self):
assert parse_pick_team("KXWTAMATCH-26JUL01SWIRYB-SWI") == "SWI"