No description
  • Python 76.5%
  • TypeScript 18.8%
  • HTML 3.1%
  • Nix 1.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-24 13:15:17 +02:00
tests Distinguish cancelled scan generations in dashboard statistics 2026-10-24 13:15:17 +02:00
web Distinguish cancelled scan generations in dashboard statistics 2026-10-24 13:15:17 +02:00
.gitignore Create tennis arbitrage scanner with Matrix notifications and native Nix package 2026-10-24 10:50:17 +02:00
arbitrage.py Add Pocket ID scanner editing and exact ranked near-arbitrage markets 2026-10-24 13:00:52 +02:00
auth.py Add Pocket ID scanner editing and exact ranked near-arbitrage markets 2026-10-24 13:00:52 +02:00
bookmakers.py Scan live and upcoming matches across all sports with complete outcome arbitrage 2026-10-24 11:53:13 +02:00
config.toml Add read-only Bun and Tailwind arbitrage dashboard with persistent scan telemetry 2026-10-24 12:33:34 +02:00
dashboard.py Distinguish cancelled scan generations in dashboard statistics 2026-10-24 13:15:17 +02:00
flake.lock Create tennis arbitrage scanner with Matrix notifications and native Nix package 2026-10-24 10:50:17 +02:00
flake.nix Rename project and Matrix branding to Odds Watch 2026-10-24 11:06:11 +02:00
odds_watch.py Distinguish cancelled scan generations in dashboard statistics 2026-10-24 13:15:17 +02:00
package.nix Add read-only Bun and Tailwind arbitrage dashboard with persistent scan telemetry 2026-10-24 12:33:34 +02:00
provision_bot.py Rename project and Matrix branding to Odds Watch 2026-10-24 11:06:11 +02:00
pyproject.toml Add Pocket ID scanner editing and exact ranked near-arbitrage markets 2026-10-24 13:00:52 +02:00
README.md Add Pocket ID scanner editing and exact ranked near-arbitrage markets 2026-10-24 13:00:52 +02:00
settings.py Add Pocket ID scanner editing and exact ranked near-arbitrage markets 2026-10-24 13:00:52 +02:00
sports.py Scan live and upcoming matches across all sports with complete outcome arbitrage 2026-10-24 11:53:13 +02:00
uv.lock Rename project and Matrix branding to Odds Watch 2026-10-24 11:06:11 +02:00

Odds Watch

Live and upcoming winner/result arbitrage candidates across all 11 sports supported by the pinned OddsHarvester, with private Matrix room notifications for @x:christianscorpan.dev. No automatic betting. This is a Python background application, not a web dashboard.

Run

Use Python 3.12+ and uv on a server, not the data-limited laptop:

uv sync --locked
uv run playwright install --with-deps chromium
export MATRIX_ACCESS_TOKEN_FILE=/run/secrets/odds-watch-token
uv run odds-watch --config config.toml

On NixOS, browser installation needs a Nix-provided compatible Chromium environment; the generic Playwright installation command above is for supported Linux distributions. A native fleet deployment should be owned by a tracked NixOS module in christianscorpan-infra, with SOPS/LoadCredential for the token and infra.state for app state. This repository does not change that fleet.

The access token must belong to a dedicated bot account on the existing homeserver. Keep it outside Git. The app uses the authenticated account's Matrix server name to create a deterministic private room alias, invites the configured recipient, and persists the room ID. This room has restricted membership/history but is not end-to-end encrypted. A bot that supports encrypted rooms would require an encryption-capable Matrix client implementation.

To inspect without sending messages:

uv run odds-watch --once --dry-run
uv run odds-watch --mode live --sport football --once --dry-run
uv run odds-watch --input snapshot.json --mode live --sport tennis --dry-run
uv run python -m unittest discover -s tests -v

Saved input retains freshness checks. Historical snapshots are deliberately rejected.

How it works

Independent concurrent loops invoke the pinned OddsHarvester CLI for each configured sport and mode. The default schedules 22 jobs: 11 sports times modes = ["upcoming", "live"]. At most three jobs scrape concurrently (max_concurrent_scans = 3), each with two upstream scraping tasks. Other jobs wait for a slot; scan intervals are not a guaranteed freshness SLA for every sport. --mode and --sport restrict diagnostics. Upcoming mode includes matches starting within the next six hours, querying both UTC date listings when that window crosses midnight. It does not filter to particular leagues. Each completed match streams as NDJSON and is evaluated immediately; previous snapshots are never reused. The upstream CLI returns one combined list if saved to JSON, but internally discovers the live listing and collects each match's bookmaker table.

Setting Upcoming Live
Pause after a scan finishes 120 seconds 60 seconds
Maximum duration of a scan 600 seconds 180 seconds
Maximum record age at evaluation 180 seconds 60 seconds

These are polling intervals, not promised update frequencies: a 90-second live scan plus a 60-second pause starts once every 150 seconds. Each arriving record is checked against the current time, including records produced late in a scan. The source timestamp records scraping time, not when the bookmaker originally published the price; quotes within a record are not guaranteed simultaneous. A timeout stops the scraper process group; candidates already evaluated from fresh completed records remain valid observations. Empty live listings are normal; failed/partial scrapes are logged. The loop waits at least 60 seconds after completion or failure before starting another run. This is polling, not a real-time bookmaker feed.

Market definitions and source fields are explicit in sports.py:

Sport Selected market Required outcomes
Football, rugby league, rugby union, ice hockey, American football, handball Regulation-time 1X2 Home, draw, away (3)
Tennis Full-match winner Player 1, player 2 (2)
Basketball Winner including overtime Home, away (2)
Baseball Winner including extra innings Home, away (2)
Volleyball Full-match winner Home, away (2)
Cricket Recognised limited-overs winner; upstream FullIncludingOT label Home, away (2)

These are winner/result markets across all supported sports, not every totals/handicap/set-score market. Cricket Test, first-class, and unrecognised formats are excluded because two prices do not cover a possible draw. A sport/market can have no eligible odds from the source. Full-time and overtime, different sports, different events and different market types are never combined. Suspended (blocked_outcomes) prices are ignored, and every required outcome must have an available price. A missing football draw is rejected, not reinterpreted as two-way.

For N mutually exclusive, exhaustive priced outcomes, S = sum(1 / odds_i). An opportunity requires S < 1. Continuous equal-payout stakes would be bankroll / (odds_i * S), giving ROI 1/S - 1. The production calculation uses exact rational arithmetic and maximises the minimum payout with integer-cent stakes. It searches each leg's attainable payout ladder and checks that all required cent stakes fit the budget. Payouts are conservatively floored to whole cents before testing positive profit and minimum ROI. Tests compare this solver with an independent exhaustive search. Formula and implementation: arbitrage.py.

Only reviewed labels of enabled bookmaker brands (or explicitly configured custom exact names) qualify. A combination requires at least two distinct brands. Regional variants of one brand cannot masquerade as independent bookmakers or be mixed under one brand within a combination. Best prices and alternative brands are compared without exhaustively enumerating every bookmaker permutation; this can conservatively omit combinations involving incompatible regional variants. Budget is per opportunity, in the same currency as every account; there is no balance or overlapping-exposure management. The profit calculation assumes exactly one priced outcome settles as a win. Abandonment, void/refund outcomes, settlement differences, fees and tax require manual verification and can reduce returns to zero or a loss.

Alerts include LIVE/UPCOMING, sport, exact market/period, every outcome, bookmakers, decimal prices, stakes, estimated minimum profit, snapshot time and source link. SQLite stores cooldowns per sport, mode, market, period and event; an upcoming alert cannot suppress a later live alert. Matrix transaction IDs are persisted before sending so retries reuse the ID. Repeated events are eligible again after the cooldown. Each loop has its own SQLite connection and scraper process. State belongs in --state-dir (default state/), and only one daemon process should use a state directory. Coverage summaries report observed labels and eligible labels separately, so enabling a brand is distinguishable from receiving its odds.

Bookmakers and limitations

The default enables 38 brand groups, with EU headquarters or an identified EU operating entity, documented with primary company/regulator sources in bookmakers.py, reviewed on 2026-10-24. This is an operator-based selection, not a claim that every worldwide entity behind a brand is EU-based. It includes Unibet, Betclic, Winamax, Betsson, Betsafe, NordicBet, Tipico, Interwetten, Novibet, Betano, Stoiximan, Superbet, Fortuna, Casa Pariurilor, PSK, TOPsport, OlyBet, Optibet, TonyBet, Coolbet, Paf, Sisal, SNAI, Danske Spil, Svenska Spel, Veikkaus, ParionsSport, PMU, Tipsport, Chance, TIPOS, Tipwin, Fenixbet, Vivatbet, 20Bet and 22Bet, plus the reviewed EU variants of bwin and NetBet.

Aliases such as Unibet.fr match the Unibet group, case-insensitively. Unknown suffixes are not accepted automatically. Generic bwin is excluded because its international operator is in Gibraltar; only the reviewed .fr, .es and .gr labels qualify. NetBet is limited to its reviewed French labels. The two Unibet regional variants, for example, can never be treated as independent bookmakers for an arbitrage pair. These are label candidates, not a claim that OddsPortal carries every brand or spelling. The configured list is not a verified list of accounts available in your jurisdiction. Actual source coverage varies by match and region, and the scan can legitimately produce no results.

A mathematical candidate is not a guaranteed return. Confirm identical regulation/overtime, retirement/walkover, abandoned-match, tie and void rules, current executable prices, stake limits, currency, fees and tax. OddsPortal snapshots may already lag bookmaker prices; freshness is measured from upstream record time, not the original bookmaker quote. In-play suspension, limited coverage, region restrictions, queued scans, timeouts and scraping blocks can prevent usable alerts. Respect upstream access rules. No CAPTCHA bypass or direct bet execution is implemented.

Upstream

OddsHarvester, MIT, pinned to commit b401809986762f0690d2a976c1d63a6b12e3e60e. uv.lock pins its transitive Python dependencies. See its README for browser requirements and the recommended minimum live sampling interval.

Native Nix build and bot provisioning

nix build / nix flake check build and test the native app with matching Nix-provided Playwright browsers. Do this on the VPS. The Python dependency lock is for uv installations; Nix uses its locked nixpkgs package set and relaxes upstream minimum versions where needed.

The tracked fleet integration uses provision_bot.py to create stable application-service tokens in a root-only runtime identity directory. Continuwuity loads a generated runtime TOML and reconciles a narrowly scoped odds-watch application service at startup. In this mode set application_service = true under [matrix]; the scanner idempotently registers the sender account before creating its room. No public registration, account password or administrator token is required by the scanner. Its token is restricted to its own Matrix namespace.

The infra package pins an exact public Forgejo source archive with a fixed-output hash. To update it, commit and push the app, update the revision and hash in pkgs/odds-watch/default.nix in the infra repository, validate both fleet outputs on the VPS, then commit and deploy through the normal infra workflow. No generated credentials or runtime data belong in source archives.

The initial deployment used the internal Matrix localpart/application-service ID tennis-arb and state directories /var/lib/tennis-arb and /var/lib/tennis-arb-identity. Those internal identities remain stable to preserve the existing room, invitation, credentials and cooldowns. User-visible branding, the room alias, commands, project and service names use Odds Watch. Matrix user IDs are immutable; the existing bot's display name inside the alerts room is Odds Watch.

Web dashboard

https://bet.christianscorpan.dev serves a TypeScript/HTML/Tailwind dashboard. Bun bundles the TypeScript; the pinned Nixpkgs Tailwind CLI builds the CSS, with TypeScript checked during the build. No frontend dependency installation or CDN is needed. The Python service binds to loopback behind Caddy; ODDS_WEB_HOST and ODDS_WEB_PORT are supplied by NixOS. [web].enabled controls the listener.

GET /api/dashboard returns explicitly allowlisted configuration, per-worker status, cumulative scan statistics and the most recent 1,000 distinct candidates. It never exposes Matrix credentials, invite targets, local paths or private room IDs. Public reads stay available; authenticated administration uses the configuration API. Candidates are ranked by calculated ROI (or profit / recency), with expired prices hidden by default. Expand a match to see all outcome stakes and the source link. Amounts use the scanner bankroll's units; no currency or exchange rate is inferred.

New telemetry lives alongside existing alert state in SQLite. Statistics begin at first dashboard deployment, count repeated match observations across scans, and survive restarts. Alert counts represent distinct delivered cooldown keys, rather than an invented historical total of messages. Source coverage distinguishes observed labels from eligible labels and configured brand groups. A partial scan retains matches already received; failures do not manufacture zero-risk results.

Editable scanner settings and near misses

Sign in with Pocket ID on the Configuration page to change scanner timing, concurrency, bankroll, target ROI, sports, modes and reviewed bookmaker selections. NixOS provisions the odds-watch public S256 PKCE client and restricts edits to the deployment's verified administrator email. Same-origin checks and a session-bound CSRF token protect writes. Sessions use Secure, HttpOnly cookies; public requests can still read the market monitor. Identity endpoints, credentials and complete market definitions remain deployment-owned.

The application is the single owner of runtime scanner configuration: the tracked TOML seeds defaults, while a validated, versioned override in application SQLite holds UI changes. Edits survive restarts and rebuilds. Restore deployment defaults removes their effect through the same API. Concurrent tabs receive a conflict instead of silently overwriting newer edits. Saving stops the old scan generation and launches new workers; the HTTP server and login stay available. Snapshots from an older configuration revision are expired, including when bankroll or books change.

Closest to arbitrage lists complete comparable markets below the alert threshold, ranked by their exact whole-cent worst-case ROI. Negative returns are shown as losses; positive returns below your minimum are labelled below alert threshold. The gap is in ROI percentage points, not a predicted probability or required price increase. Incomplete, suspended, incompatible or stale input is never scored. Near misses do not send Matrix messages. The dashboard filters and expiry rules apply to both lists; it displays the top 100 matching near misses from at most 1,000 latest saved markets. Old alert cooldown state is retained when the latest market observation moves between qualified and near-miss lists.