Skip to content
Merged
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
215 changes: 214 additions & 1 deletion action_network_layer.py
Original file line number Diff line number Diff line change
@@ -1 +1,214 @@
placeholder — will be overwritten by localPath
"""
action_network_layer.py
=======================
PropIQ — Action Network data layer.

Fetches MLB prop projections, game sentiment, and sharp money signals
from Action Network's internal API (cookie-authenticated).

Required env var: ACTION_NETWORK_COOKIE — raw token (no "Bearer" prefix).

PR #585: pitcher_walks key corrected to walks_allowed;
implied-prob clamp widened to max(0.30, min(0.70, ...)) to allow
real sharp flow beyond the old ±10pp cap.
"""

from __future__ import annotations

import logging
import os
from typing import Optional

Check warning on line 20 in action_network_layer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

action_network_layer.py#L20

'typing.Optional' imported but unused (F401)

logger = logging.getLogger("propiq.action_network")

_AN_COOKIE = os.environ.get("ACTION_NETWORK_COOKIE", "")

# Market name → canonical prop_type
_AN_MARKET: dict[str, str] = {
"pitcher strikeouts": "strikeouts",
"strikeouts": "strikeouts",
"hits": "hits",
"total bases": "total_bases",
"total_bases": "total_bases",
"hits runs rbis": "hits_runs_rbis",
"hits + runs + rbis": "hits_runs_rbis",
"h+r+rbi": "hits_runs_rbis",
"batter strikeouts": "hitter_strikeouts",
"hitter strikeouts": "hitter_strikeouts",
"pitching outs": "pitching_outs",
"outs": "pitching_outs",
"hits allowed": "hits_allowed",
"earned runs": "earned_runs",
"walks allowed": "walks_allowed",
"pitcher walks": "walks_allowed", # PR #585: was pitcher_walks (wrong)
"walks": "walks_allowed",
}

_HEADERS: dict = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "application/json",
"Origin": "https://www.actionnetwork.com",
"Referer": "https://www.actionnetwork.com/",
}


def _auth_headers() -> dict:
h = dict(_HEADERS)
if _AN_COOKIE:
h["x-auth-token"] = _AN_COOKIE
return h


def fetch_mlb_prop_projections() -> list[dict]:
"""
Fetch MLB player prop projections with over/under ticket % and money %.
Returns list of dicts with player, prop_type, line, over_ticket_pct, etc.
Returns [] on any failure.
"""
if not _AN_COOKIE:
logger.debug("[AN] No ACTION_NETWORK_COOKIE — prop projections skipped.")
return []
try:
return _fetch_prop_proj_rest()
except Exception as exc:
logger.warning("[AN] fetch_mlb_prop_projections failed: %s", exc)
return []


def _fetch_prop_proj_rest() -> list[dict]:
"""REST fallback via Action Network internal API."""
import requests # noqa: PLC0415
url = "https://api.actionnetwork.com/web/v2/games?league=mlb&market=player_props&bookIds=15,30"
try:
resp = requests.get(url, headers=_auth_headers(), timeout=12)
if resp.status_code != 200:
logger.debug("[AN] prop proj REST: HTTP %d", resp.status_code)
return []
data = resp.json()
except Exception as exc:
logger.debug("[AN] prop proj REST request failed: %s", exc)
return []

results = []
for game in data.get("games", []) or []:
for prop in game.get("player_props", []) or []:
player = (prop.get("player_name") or prop.get("player") or "").strip()
market = (prop.get("market_name") or prop.get("type") or "").lower().strip()
prop_type = _AN_MARKET.get(market, market)
line = prop.get("line") or prop.get("value")
if not player or not prop_type:
continue
over_t = prop.get("over_ticket_pct", prop.get("over_bets_pct", 50))
under_t = prop.get("under_ticket_pct", prop.get("under_bets_pct", 50))
over_m = prop.get("over_money_pct", 50)
under_m = prop.get("under_money_pct", 50)
# PR #585: widened clamp from ±10pp to ±20pp around 50%
_raw = float(over_m or over_t or 50)
implied = max(0.30, min(0.70, _raw / 100.0))
results.append({
"player": player,
"prop_type": prop_type,
"line": float(line) if line is not None else None,
"over_ticket_pct": float(over_t or 50),
"under_ticket_pct": float(under_t or 50),
"over_money_pct": float(over_m or 50),
"under_money_pct": float(under_m or 50),
Comment on lines +106 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of the or operator for default values with numeric percentages (where 0 is a valid and significant value) will cause 0 to be incorrectly replaced by the fallback value (e.g., 50). In betting data, 0% money or tickets is a very strong signal that should not be normalized to 50%. This pattern is repeated for over_t, under_t, over_m, and under_m.

Suggested change
_raw = float(over_m or over_t or 50)
implied = max(0.30, min(0.70, _raw / 100.0))
results.append({
"player": player,
"prop_type": prop_type,
"line": float(line) if line is not None else None,
"over_ticket_pct": float(over_t or 50),
"under_ticket_pct": float(under_t or 50),
"over_money_pct": float(over_m or 50),
"under_money_pct": float(under_m or 50),
_raw = float(over_m if over_m is not None else (over_t if over_t is not None else 50))
implied = max(0.30, min(0.70, _raw / 100.0))
results.append({
"player": player,
"prop_type": prop_type,
"line": float(line) if line is not None else None,
"over_ticket_pct": float(over_t if over_t is not None else 50),
"under_ticket_pct": float(under_t if under_t is not None else 50),
"over_money_pct": float(over_m if over_m is not None else 50),
"under_money_pct": float(under_m if under_m is not None else 50),
"implied_prob": round(implied, 4),

"implied_prob": round(implied, 4),
"rlm_signal": False,
"rlm_direction": None,
"source": "action_network",
})
logger.debug("[AN] prop proj REST: %d props", len(results))
return results


def fetch_mlb_game_sentiment() -> dict:
"""
Fetch per-team game sentiment (over/under ticket % and money %).
Returns dict keyed by team_name → {over_ticket_pct, over_money_pct, ...}.
Returns {} on any failure.
"""
if not _AN_COOKIE:
return {}
try:
import requests # noqa: PLC0415
resp = requests.get(
"https://api.actionnetwork.com/web/v2/games?league=mlb",
headers=_auth_headers(), timeout=10,
)
if resp.status_code != 200:
return {}
data = resp.json()
result: dict = {}
for game in data.get("games", []) or []:
for side in ("away", "home"):
team = game.get(f"{side}_team", {})
name = team.get("abbr") or team.get("name") or ""
if not name:
continue
result[name] = {
"over_ticket_pct": game.get("over_bets_pct", 50),
"over_money_pct": game.get("over_money_pct", 50),
"under_ticket_pct": game.get("under_bets_pct", 50),
"under_money_pct": game.get("under_money_pct",50),
}
return result
except Exception as exc:
logger.warning("[AN] fetch_mlb_game_sentiment failed: %s", exc)
return {}


def build_sharp_report() -> dict:
"""
Build a sharp-money report dict: player → {sharp_side, rlm, steam}.
Returns {} on failure.
"""
try:
props = fetch_mlb_prop_projections()
report: dict = {}
for p in props:
player = p.get("player", "")
pt = p.get("prop_type", "")
if not player or not pt:
continue
over_t = float(p.get("over_ticket_pct", 50))
over_m = float(p.get("over_money_pct", 50))
# Sharp on Over when money% > ticket% by 10pp+
_sharp_over = (over_m - over_t) >= 10
_sharp_under = (over_t - over_m) >= 10
report[f"{player.lower()}:{pt}"] = {
"sharp_side": "OVER" if _sharp_over else ("UNDER" if _sharp_under else None),
"rlm": p.get("rlm_signal", False),
"over_ticket_pct": over_t,
"over_money_pct": over_m,
}
return report
except Exception as exc:
logger.warning("[AN] build_sharp_report failed: %s", exc)
return {}


def fetch_live_projections() -> list[dict]:
"""Alias for fetch_mlb_prop_projections — live projections from AN."""
return fetch_mlb_prop_projections()


def fetch_all_projections() -> dict:
"""
Return projections split into batters/pitchers DataFrames-like structure.
Used by DraftEdge iteration path.
"""
try:
import pandas as _pd # noqa: PLC0415
props = fetch_mlb_prop_projections()
batters = [p for p in props if p.get("prop_type") in {"hits", "total_bases", "hits_runs_rbis", "hitter_strikeouts"}]
pitchers = [p for p in props if p.get("prop_type") in {"strikeouts", "pitching_outs", "hits_allowed", "earned_runs", "walks_allowed"}]
return {
"batters": _pd.DataFrame(batters) if batters else _pd.DataFrame(),
"pitchers": _pd.DataFrame(pitchers) if pitchers else _pd.DataFrame(),
}
except ImportError:
return {"batters": [], "pitchers": []}
except Exception as exc:
logger.warning("[AN] fetch_all_projections failed: %s", exc)
return {"batters": [], "pitchers": []}
Comment on lines +211 to +214
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The error handling paths return empty lists [] for batters and pitchers, but the success path (lines 207-208) returns pandas.DataFrame objects. Downstream callers (such as tasklets.py line 1396) that expect DataFrames and call attributes like .empty will raise an AttributeError if an exception occurs here.

Suggested change
return {"batters": [], "pitchers": []}
except Exception as exc:
logger.warning("[AN] fetch_all_projections failed: %s", exc)
return {"batters": [], "pitchers": []}
except ImportError:
return {"batters": _pd.DataFrame(), "pitchers": _pd.DataFrame()}
except Exception as exc:
logger.warning("[AN] fetch_all_projections failed: %s", exc)
return {"batters": _pd.DataFrame(), "pitchers": _pd.DataFrame()}

174 changes: 173 additions & 1 deletion sportsbook_reference_layer.py
Original file line number Diff line number Diff line change
@@ -1 +1,173 @@
placeholder
"""
sportsbook_reference_layer.py
=============================
PropIQ — Sportsbook sharp-line reference layer.

Fallback chain:
Tier 0 — DraftKings Internal API (6 prop types, no auth)
Tier 0.5 — FanDuel Internal API (5 pitcher types)
Tier 1 — The Odds API (Pinnacle+DK+FD+MGM)
Tier 1.5b — odds-api.net (bet365+betr)
Tier 4 — DraftEdge projections
Tier 5 — ActionNetwork money%
Tier 6 — TheRundown (all 9 MLB prop markets)
Tier 7 — VegasInsider scrape
Tier 8 — RotoWire supplement

PR #585: ActionNetwork always-run supplement + TheRundown all 9 markets.
"""

from __future__ import annotations

import logging
import os
from typing import Optional

Check warning on line 24 in sportsbook_reference_layer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

sportsbook_reference_layer.py#L24

'typing.Optional' imported but unused (F401)

logger = logging.getLogger("propiq.sportsbook_reference")


def build_sportsbook_reference() -> dict:
"""
Build sharp-line reference dict keyed by (player_name_lower, prop_type).
Returns dict with implied-prob values from sharp books.
Returns empty dict on any failure so callers degrade gracefully.
"""
try:
return _build()
except Exception as exc:
logger.warning("[SBRef] build_sportsbook_reference failed: %s", exc)
return {}


def _build() -> dict:
"""Internal — assemble reference data from all available tiers."""
_mem_ref: dict = {}

# ── Tier 0: DraftKings Internal API ─────────────────────────────────────
try:
from draftkings_layer import get_dk_sharp_prob as _dk_prob # noqa: PLC0415
_dk_result = _dk_prob()

Check failure on line 49 in sportsbook_reference_layer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

sportsbook_reference_layer.py#L49

No value for argument 'player_name' in function call
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No value for argument 'player_name' in function call


A required function parameter isn't provided while calling the function. This is an error.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No value for argument 'prop_type' in function call


A required function parameter isn't provided while calling the function. This is an error.

if isinstance(_dk_result, dict):
for k, v in _dk_result.items():
_mem_ref.setdefault(k, v)
logger.debug("[SBRef] Tier 0 DK: %d entries", len(_dk_result) if isinstance(_dk_result, dict) else 0)
except Exception as _e0:
logger.debug("[SBRef] Tier 0 DK failed: %s", _e0)

# ── Tier 0.5: FanDuel Internal API ──────────────────────────────────────
try:
from fanduel_layer import get_fd_sharp_prob as _fd_prob # noqa: PLC0415
_fd_result = _fd_prob()
if isinstance(_fd_result, dict):
for k, v in _fd_result.items():
_mem_ref.setdefault(k, v)
logger.debug("[SBRef] Tier 0.5 FD: %d entries", len(_fd_result) if isinstance(_fd_result, dict) else 0)
except Exception as _e05:
logger.debug("[SBRef] Tier 0.5 FD failed: %s", _e05)

# ── Tier 1: The Odds API ─────────────────────────────────────────────────
try:
import redis as _redis_mod # noqa: PLC0415
import json as _json # noqa: PLC0415
from datetime import datetime # noqa: PLC0415
import pytz # noqa: PLC0415
_r_url = os.environ.get("REDIS_URL") or os.environ.get("REDIS_PUBLIC_URL")
if _r_url:
_r = _redis_mod.from_url(_r_url, decode_responses=True)
_date = datetime.now(pytz.timezone("America/Los_Angeles")).strftime("%Y%m%d")
_cached = _r.get(f"sb_ref_{_date}")
if _cached:
_cached_dict = _json.loads(_cached)
for k, v in _cached_dict.items():
_mem_ref.setdefault(k, v)
logger.debug("[SBRef] Tier 1 Redis cache: %d entries", len(_cached_dict))
except Exception as _e1:
logger.debug("[SBRef] Tier 1 Redis read failed: %s", _e1)

# ── Tier 5 (always-run supplement): ActionNetwork ────────────────────────
try:
from action_network_layer import ( # noqa: PLC0415
fetch_mlb_prop_projections as _an_fetch,
)
_an_props = _an_fetch()
_an_count = 0
for p in (_an_props or []):
if not isinstance(p, dict):
continue
_player = (p.get("player") or "").strip().lower()
_pt = (p.get("prop_type") or "").strip().lower()
if not _player or not _pt:
continue
_over_pct = p.get("over_ticket_pct") or p.get("over_pct")
_money_pct = p.get("over_money_pct")
if _over_pct is None and _money_pct is None:
continue
_ref_pct = float(_over_pct or _money_pct or 50)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the issue in action_network_layer.py, using or for numeric defaults will treat a valid 0 percentage as falsy and fall back to 50.

Suggested change
_ref_pct = float(_over_pct or _money_pct or 50)
_ref_pct = float(_over_pct if _over_pct is not None else (_money_pct if _money_pct is not None else 50))

_implied = max(0.30, min(0.70, _ref_pct / 100.0))
key = f"{_player}:{_pt}"
_mem_ref.setdefault(key, {"implied_prob": _implied, "source": "action_network"})
_an_count += 1
if _an_count:
logger.debug("[SBRef] Tier 5 AN supplement: %d entries", _an_count)
except Exception as _e5:
logger.debug("[SBRef] Tier 5 AN supplement failed: %s", _e5)

# ── Tier 6 (always-run supplement): TheRundown ───────────────────────────
_RUNDOWN_MARKETS: dict[str, int] = {
"strikeouts": 19,
"hits": 53,
"total_bases": 54,
"hits_runs_rbis": 55,
"hitter_strikeouts": 56,
"pitching_outs": 57,
"hits_allowed": 58,
"earned_runs": 59,
"walks_allowed": 60,
}
_rundown_key = os.environ.get("RUNDOWN_API_KEY", "")
if _rundown_key:
try:
import requests as _req # noqa: PLC0415
_added = 0
for _prop_type, _market_id in _RUNDOWN_MARKETS.items():
try:
_resp = _req.get(
f"https://therundown-therundown-v1.p.rapidapi.com/sports/3/odds/market/{_market_id}",
headers={
"X-RapidAPI-Key": _rundown_key,
"X-RapidAPI-Host": "therundown-therundown-v1.p.rapidapi.com",
},
timeout=8,
)
if _resp.status_code != 200:
continue
data = _resp.json()
for event in data.get("events", []) or []:
for line in event.get("lines", {}).values():
_pname = (line.get("player_name") or "").strip().lower()
if not _pname:
continue
_over_ml = line.get("over")
_under_ml = line.get("under")
if _over_ml is None:
continue
try:
_op = abs(_over_ml)
_over_dec = (100 / _op + 1) if _over_ml < 0 else (_op / 100 + 1)
_up = abs(_under_ml) if _under_ml else _op
_under_dec = (100 / _up + 1) if (_under_ml or 0) < 0 else (_up / 100 + 1)
_fair = (1 / _over_dec) / (1 / _over_dec + 1 / _under_dec)
except Exception:
_fair = 0.5
_key = f"{_pname}:{_prop_type}"
if _key not in _mem_ref:
_mem_ref[_key] = {"implied_prob": round(_fair, 4), "source": "therundown"}
_added += 1
except Exception:

Check warning on line 166 in sportsbook_reference_layer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

sportsbook_reference_layer.py#L166

Try, Except, Continue detected.
continue
if _added:
logger.debug("[SBRef] Tier 6 TheRundown supplement: %d entries", _added)
except Exception as _e6:
logger.debug("[SBRef] Tier 6 TheRundown failed: %s", _e6)

return _mem_ref
Comment on lines +42 to +173
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The _build function (and consequently build_sportsbook_reference) has significant performance and resource management issues.

  1. API Quota Exhaustion: It makes 9 blocking HTTP requests to TheRundown API (Tier 6) every time it is called. Since tasklets.py calls this function inside a loop for every prop (line 5945), a single cycle with 200 props would trigger 1,800 API calls, likely exhausting RapidAPI quotas and causing massive latency.
  2. Connection Pooling: It creates a new Redis connection via from_url (line 76) on every call, which will quickly lead to connection pool exhaustion or file descriptor leaks.

This function must be memoized or the result should be cached in Redis/memory with a reasonable TTL (e.g., 5-10 minutes).

Loading