Automated edge detection
& execution pipeline
Edge-Radar scans thousands of open Kalshi markets, cross-references prices against sportsbook consensus, crypto spot, and weather forecasts — then surfaces mispriced contracts through 13 risk gates and Kelly sizing before placing limit orders. Every decision is logged for post-hoc calibration.
§1 Data Flow
§2 Pipeline
Fetch read · parallel
Pull all open Kalshi markets via the signed REST client. In parallel, query 8-12 US sportsbooks through The Odds API, ESPN, NHL/MLB stats, CoinGecko spot, and NWS hourly forecasts.
Categorize routing
Every ticker prefix is mapped to a category (moneyline, spread, total, futures, crypto, weather, S&P). The category determines which edge model runs next.
Compare fair value vs. ask
Each opportunity is scored on four independent dimensions. The composite score gates whether it continues. Details in Edge Models.
Cap dedup
Limit to top 3 per game/event so a single contest can't dominate the scan. Bracket dedup collapses same-outcome correlated markets on the same day.
Risk-Check 12 gates +Gate 3.5
Seven reject gates and two sizing caps. Every gate is logged with pass/reject reason and input values. Full table in Risk Gates.
Execute signed · limit-only
RSA-signed limit orders on Kalshi. A full trade journal row is written per order with ticker, side, price, size, fair value, edge, confidence, composite, and rationale.
KALSHI_PRIVATE_KEY envDRY_RUN=true; identical logging for backtest parityMonitor settle · calibrate
The settler scans for closed markets, realizes P&L, and appends to the trade journal. The calibration tool groups settled trades by edge bucket, sport, side, and confidence to measure Brier score and realized ROI.
make settle — realized P&L + CLV trackingmodel_calibration.py — Brier by edge bucket, sport, confidence§3 Edge Models
Moneyline (2-way de-vig)
De-vig each book's line using the multiplicative method. Take a weighted median across 8-12 books.
- Sharp books 3× (Pinnacle, Circa)
- Recreational 0.7× (DK, FD)
- Confidence from book count + range + stats
Spreads (Normal CDF)
Infer expected margin from the consensus line, model the final as Normal(μ, σ), compute P(margin > strike).
- NBA σ=13.8, NCAAB σ=12.1 (R2 bump)
- NHL σ=2.5 · MLB σ=4.025
- +Rest/B2B + weather stdev adjust
Totals (CDF + weather)
Same CDF approach with a weather overlay for outdoor NFL/MLB. Weather shifts both fair value and stdev.
- Wind > 15mph, rain > 40%, cold
- Dome = 0 adjustment (auto-detect)
- Severe +0.5 · moderate +0.3 · mild +0.1
Futures (N-way de-vig)
De-vig the full N-outcome market by distributing the overround proportionally across sportsbook futures odds.
- NFL, NBA, NHL, MLB, PGA
- Weighted median across books
- Higher liquidity threshold required
Predictions
Model-specific: crypto uses log-normal volatility, weather uses NWS ensemble, S&P uses VIX-implied vol.
- Crypto: BTC, ETH, XRP, DOGE, SOL
- Weather: 13 US cities (NWS/NOAA)
- S&P: Yahoo + VIX → strike prob
§4 Risk Gates
| # | Type | Gate | Trigger |
|---|---|---|---|
1 |
reject | Daily loss limit | Realized daily loss >= MAX_DAILY_LOSS |
2 |
reject | Open position cap | Open positions >= MAX_OPEN_POSITIONS |
3 |
reject | Edge threshold | Edge < global MIN_EDGE_THRESHOLD or per-sport override (NBA 12%, NCAAB 10%) |
3.5 |
reject R7 | Lottery-ticket floor | Market price < MIN_MARKET_PRICE (default $0.10); strict less-than |
4 |
reject | Composite score | Score < MIN_COMPOSITE_SCORE (default 6.0) |
4.5 |
reject R3 | Confidence floor | Confidence rank < MIN_CONFIDENCE (default medium). Low-confidence: 0W-3L, -105% ROI |
4.6 |
reject R1 | NO-favorite guard | NO bets with price < NO_SIDE_FAVORITE_THRESHOLD (0.25) need edge >= NO_SIDE_MIN_EDGE (0.25) AND confidence=high |
4.7 |
reject R25 | Prediction-market safety | Opportunity category in {crypto, weather, spx, mentions, companies, politics} → reject unless ALLOW_PREDICTION_BETS=true |
5 |
reject | Duplicate ticker | Already holding this market |
6 |
reject | Per-event cap | Per-event positions >= MAX_PER_EVENT (default 3) |
7 |
reject C5 | Series dedup | Matchup (sport + team pair, date-agnostic) already bet in last SERIES_DEDUP_HOURS (48 global; per-sport: MLB 72, NHL 72 — R9) |
8 |
cap | Max bet size | Computed bet > MAX_BET_SIZE → cap to limit |
9 |
cap | Bet ratio | Single bet > MAX_BET_RATIO × batch median → cap |
Kelly sizing
Batch-Kelly divides KELLY_FRACTION (0.25) by the batch size so concurrent opportunities share risk.
- Soft cap: edge above
KELLY_EDGE_CAP(15%) decayed byKELLY_EDGE_DECAY(0.5) - NO-side half-Kelly: NO bets priced <
NO_SIDE_KELLY_PRICE_FLOOR(35¢) sized at 0.5× normal Kelly
Resting-order janitor R4
Runs at the top of execute_pipeline() when execute=true AND DRY_RUN=false.
- Lists
status=restingorders - Cancels those >
RESTING_ORDER_MAX_HOURS(24) with zero fills - Partial/full fills left for the settler
§5 Risk Limits
.env.example. All overridable per environment.Position sizing
UNIT_SIZE= $1.00 (Kelly floor)KELLY_FRACTION= 0.25MAX_BET_SIZE= $100KELLY_EDGE_CAP= 0.15KELLY_EDGE_DECAY= 0.5
Portfolio caps
MAX_DAILY_LOSS= $250MAX_OPEN_POSITIONS= 10MAX_PER_EVENT= 3MAX_BET_RATIO= 3.0× batch medianSERIES_DEDUP_HOURS= 48 (MLB 72, NHL 72 via per-sport overrides — R9)
Edge thresholds
MIN_EDGE_THRESHOLD= 0.03 (global)MIN_EDGE_THRESHOLD_NBA= 0.12MIN_EDGE_THRESHOLD_NCAAB= 0.10MIN_COMPOSITE_SCORE= 6.0MIN_CONFIDENCE= medium
Lottery-ticket floor
MIN_MARKET_PRICE= $0.10- Strict less-than ($0.09 rejected)
- Set to
0to disable - Blocks sub-10¢ longshot cluster
NO-side guard
NO_SIDE_FAVORITE_THRESHOLD= 0.25NO_SIDE_MIN_EDGE= 0.25NO_SIDE_KELLY_PRICE_FLOOR= 0.35NO_SIDE_KELLY_MULTIPLIER= 0.5
Order hygiene
RESTING_ORDER_MAX_HOURS= 24- Zero-fill stale orders cancelled pre-execute
DRY_RUNdefault =true
§6 Recent Updates
data/cache/odds/scan.py invocation started with empty in-process caches and refetched all 18 sport keys from scratch; back-to-back scans (scheduler bursts, dashboard re-renders) doubled quota burn for no gain. New scripts/shared/odds_cache.py with load(sport_key, markets, ttl_seconds), store(), clear(); files at data/cache/odds/<sport_key>__<markets>.json (commas in markets sanitized to underscores; original markets string preserved inside JSON). Silent-on-error throughout — corrupt file = miss, never an exception.app/config.pyODDS_CACHE_TTL_SECONDS=300 (5 min default — longer than typical filter-fiddling, shorter than meaningful pre-game line movement; 0 disables) and ODDS_CACHE_ENABLED=true. Validates non-negative TTL. Wired into both edge_detector.fetch_odds_api() and futures_edge.fetch_outrights(); the existing in-process dicts stay so existing _odds_cache.clear() test calls still work. Hits log Odds API file cache hit for X (age Ns, M events) so cache age is visible in scan output.data/cache/odds_api_quota.json so fresh processes skip exhausted keys. R24b caches the actual sportsbook payloads. Both layered cleanly: a key-rotation 401 doesn't poison the response cache, and a stored response doesn't lie about quota state. +10 regression tests (320 → 330 passing). Offline round-trip smoke confirms call 2 (in-process dict cleared, file cache populated) returns identical events with 0 HTTP calls.os.getenv reads migrated, lint guard against regressionapp/config.py — single source of truthos.getenv calls across 14 files with type-coercion drift (MIN_EDGE_THRESHOLD read in 5 places, two type styles; DRY_RUN coerced inconsistently). Built a typed module: 10 frozen dataclasses (Kalshi/Odds/Alpaca/Telegram credentials + RiskLimits, GateThresholds, KellyConfig, PerSportOverrides, System) with from_env() coercion and Config.validate() for impossible combos. Memoized via get_config() / reset_config(). 32 unit tests. Pure addition — no scripts touched, no behavior change.get_config()os.getenv reads removed across doctor.py (9), risk_check.py (5), kalshi_client.py (8), edge_detector.py + fetch_odds.py (3), kalshi_executor.py (23 — the heavyweight, all 11 risk gates and per-sport overrides), 6 small modules (11), and webapp/services.py (6). Module-level constants kept as plain mutable globals where tests directly mutate them; only the initial source changed. or None pattern preserves None-on-unset semantics for credentials spliced into HTTP headers. Streamlit Cloud bug found and fixed: webapp/app.py puts webapp/ on sys.path[0], shadowing the app/ package; resolved by re-inserting PROJECT_ROOT at sys.path[0] inside services.py. All 292 prior tests still pass.scripts/lint/check_config_centralization.pyapp/, scripts/, webapp/ for os.getenv / os.environ. Allows app/config.py, comment-only lines, and lines tagged # config-bootstrap (reserved for the 4 Streamlit secrets-bootstrap lines in webapp/services.py). Wired into make lint-config and a pre-commit hook with always_run: true so the lint sees the whole tree, not just staged files. 5 unit tests cover codebase-clean baseline, regression detection, annotation suppression, comment-line ignore, and app/config.py exclusion.MIN_EDGE in risk_check.py was defined but never referenced; deleted. doctor.py's display of UNIT_SIZE is now :.2f-formatted, so a value of .50 in .env renders as $0.50 instead of $.50. Numeric values reaching every gate are byte-identical.model_calibration.py points at settlement sourcetrade_log (16 entries, 3 closed) instead of kalshi_settlements.json (173 entries). CLI errored with "need at least 10" despite 160 settled bets existing. Fixed by reading settlements and normalizing field names. Unblocks R12.MIN_EDGE_THRESHOLD_NBA. Also restored both NBA and NCAAB overrides in the live .env — documented in .env.example but missing from the actual env file, so both had been silently falling back to the 3% global floor. Scope intentionally minimal: most NBA bleed was High-confidence picks (fixed in R13) or sub-10¢ lottery tickets (already caught by R7)._adjust_confidence_with_stats() now drops a tier on contradicts but no-ops on supports. Applies to all three call sites (team stats, rest/B2B, sharp money). Upward bumps correlated with inflated claimed edge but worse realized outcomes. Base "high" tier still reachable via ≥8 sharp-books + tight-consensus rule. +4 regression tests (218 → 222 passing).calibration profile runs model_calibration.py --days 30 --save on day 1 of each month at 02:00 (after nightly settler). Installer extended to support MONTHLY schedules. Narrowed scripts/schedulers/ gitignore so the portable automation/ folder is now tracked.--budget, --report-dir)--budget or --report-dir. Extracted shared parse_budget_arg() helper; added both flags across all scanners; wired each to execute_pipeline(budget=…) and save_scan_report(output_dir=…).dedup_correlated_brackets was treating every team outcome in a championship as an alt-line bracket. Fix: when category=="futures", use the full ticker as the dedup key. Concentration still bounded by Gate 6 (MAX_PER_EVENT=2).FUTURES_MAP prefix-collision + semantic fixKXMLBPLAYOFFS-26-LAD matched the KXMLB prefix first via startswith; (2) playoff-qualifier and conference-winner markets pointed to championship-winner odds — fundamentally the wrong question. Fixed by switching to exact-series match and removing the 5 semantically-broken entries (KXMLBPLAYOFFS, KXNBAEAST/WEST, KXNHLEAST/WEST). Same scan now surfaces 2 real +4% edges instead of 45 bogus +30-75% "edges".fetch_outrights retry loop exited after 3 attempts and never reached the healthy key at index 5. Fixed: switched to tried: set[str] loop, added mark_exhausted() on 401, persistent quota cache at data/cache/odds_api_quota.json. Fresh processes now skip exhausted keys instantly.@st.cache_data(ttl=60))@st.cache decorators existed anywhere in webapp/. Every click of SCAN MARKETS fired a fresh Odds API fetch. 60s TTL on run_scan(); CLEAR button wipes the cache on demand.preflight_gate_status() helper checks the 5 static per-opportunity gates; returns ok / edge / price / score / conf / no-fav / pred-off. Wired into all four scanner tables.--unit-size away from executing live. Recommendation: park until rebuilt.crypto / weather / spx / mentions / companies / politics categories unless ALLOW_PREDICTION_BETS=true. Default off until R25b (TTL caches) + R25c (rebuild one model with tests) are shipped. R18's Gate column surfaces the rejection as pred-off at scan time.MIN_MARKET_PRICE floormarket_type wired through service layerrun_scan() now dispatches by market type.test_approved_clean_when_no_caps_hit. Collapsed count-specific "8 risk gates" refs to "all risk gates" linking to CLAUDE.md. Flipped Pages workflow from main to master. Promoted pandas>=2.1.4 to a first-class runtime dep.MIN_CONFIDENCE reject gate (Gate 4.5)medium.cancel_stale_resting_orders() helper runs at the top of execute_pipeline() when live-execute. 32 new tests (181 → 213 passing).trusted_edge() softly caps Kelly sizing above 15%. Raw edge still flows through gates and reports.MIN_EDGE_THRESHOLDSERIES_DEDUP_HOURS (48). Per-sport overrides added in R9 (2026-04-27): MLB and NHL bumped to 72h after F12 — a NYM/LAD pair bet 49h apart slipped the global window.--filter mlb,nhl comma-separated multi-sport scans. Dashboard deployed to Streamlit Community Cloud with password gate; inline PEM support for cloud filesystems.§7 Calibration Snapshot
Findings driving recent risk changes
- F1 — YES +93% ROI (n=48); NO -20% (n=28); NO at >=20% edge: 31% WR (n=16) → R1 Gate 4.6
- F6 — Low-confidence 0W-3L / -105% ROI → R3 Gate 4.5
- F10 — Sub-10¢ bets 1W-3L with claimed "+50% edge" → R7 Gate 3.5
- 60-70% favorite band overconfidence +18% (n=40) → R2 stdev bump
Attribution plan (R12)
R12 re-runs model_calibration.py at 100 post-baseline trades (currently 66). The window between R2's ship date and that checkpoint is the cleanest place to measure whether the probability-width fix improved Brier.