import re import ast import os import time from da...
Created on: January 14, 2026
Answered using GPT-5.2 Thinking by Chat01
Created on: January 14, 2026
Answered using GPT-5.2 Thinking by Chat01
import re
import ast
import os
import time
from dataclasses import dataclass
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
from collections import deque
from pathlib import Path
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import StaleElementReferenceException
from openpyxl import Workbook, load_workbook
from openpyxl.chart import LineChart, Reference
URL = "https://www.nowgoal.com/basketball"
POLL_SECONDS = 5
STAKE = 1.0
HEADLESS = True
HALFTIME_WAIT_SECONDS = 15
DIFF_BANDS = [
(20.0, 30.0, "20-30"),
(30.0, 40.0, "30-40"),
(40.0, 50.0, "40-50"),
(50.0, None, "50+"),
]
SCROLL_EVERY_N_LOOPS = 12
EXCEL_PATH = os.getenv("EXCEL_PATH", "nowgoal_monitor.xlsx")
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "8025557030:AAHgZX_pCOvQu7Gmi33X3iqe0oY7Y67yBMA")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "6667940236")
SNAPSHOT_DIR = "snapshots" # сюда будут писаться файлы, которые можно открывать в Excel
SNAPSHOT_EVERY_SECONDS = 60 # снимок раз в минуту (можешь поставить 30)
AUTOSAVE_SUFFIX = "_AUTOSAVE.xlsx" # если основной файл открыт -> сохраняем сюда
SAVE_RETRIES = 3
SAVE_RETRY_SLEEP = 1.0
_last_snapshot_ts = 0.0
_excel_lock_notified = False
def tg_ready() -> bool:
return bool(TELEGRAM_TOKEN and TELEGRAM_CHAT_ID)
def tg_send(text: str) -> None:
"""Отправка сообщения в Telegram + лог в консоль."""
print(f"[TG] -> {text.replace('\n', ' | ')}", flush=True)
if not tg_ready():
print("[TG] Token/ChatId не заданы (пропускаю отправку).", flush=True)
return
texttry: r = requests.post( f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage", data={"chat_id": TELEGRAM_CHAT_ID, "text": text, "disable_web_page_preview": True}, timeout=10, ) if not r.ok: print(f"[TG] Ошибка отправки: {r.status_code} {r.text[:300]}", flush=True) except Exception as e: print(f"[TG] Exception: {e}", flush=True)
@dataclass
class Team:
name: str
tag: Optional[str] = None
@dataclass
class LineOU:
total: Optional[float] = None
over_odds: Optional[float] = None # decimal
under_odds: Optional[float] = None # decimal
def now_ts() -> str:
return datetime.now().strftime("%H:%M:%S")
def now_dt() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def txt(el) -> str:
if el is None:
return ""
return (el.get_attribute("textContent") or "").strip()
def safe_float(x: Optional[str]) -> Optional[float]:
if not x:
return None
x = x.strip().replace(",", ".")
try:
return float(x)
except ValueError:
return None
def hk_to_decimal(odds: Optional[float]) -> Optional[float]:
# HK 0.83 -> Decimal 1.83
if odds is None:
return None
if odds >= 1.2:
return odds
return odds + 1.0
def read_odds_decimal(span) -> Optional[float]:
"""odds2 может быть текст 1.80, а может быть пусто, но есть data-o=0.80 (HK)."""
if span is None:
return None
textt = (span.get_attribute("textContent") or "").strip() v = safe_float(t) if v is not None: return hk_to_decimal(v) v2 = safe_float(span.get_attribute("data-o")) if v2 is not None: return hk_to_decimal(v2) return None
def safe_save(wb: Workbook, path: str) -> bool:
"""
Пытаемся сохранить в основной файл.
Если Excel держит лок (Windows) -> не падаем, сохраняем в AUTOSAVE.
"""
global _excel_lock_notified
textfor i in range(1, SAVE_RETRIES + 1): try: wb.save(path) # если раньше предупреждали про лок — сбросим флаг, т.к. снова всё ок _excel_lock_notified = False return True except PermissionError: print(f"[{now_ts()}] Excel LOCKED: {path} (try {i}/{SAVE_RETRIES})", flush=True) time.sleep(SAVE_RETRY_SLEEP) except OSError as e: print(f"[{now_ts()}] Save OSError: {e} (try {i}/{SAVE_RETRIES})", flush=True) time.sleep(SAVE_RETRY_SLEEP) base, _ext = os.path.splitext(path) fallback = base + AUTOSAVE_SUFFIX try: wb.save(fallback) print(f"[{now_ts()}] Saved to AUTOSAVE: {fallback}", flush=True) # предупреждение в TG только один раз, чтобы не спамить if (not _excel_lock_notified) and tg_ready(): tg_send( "⚠️ Excel-файл открыт и заблокирован.\n" f"Не могу записать в: {os.path.abspath(path)}\n" f"Сохраняю во временный: {os.path.abspath(fallback)}\n" f"И делаю snapshots в папку: {os.path.abspath(SNAPSHOT_DIR)}" ) _excel_lock_notified = True return False except Exception as e: print(f"[{now_ts()}] AUTOSAVE failed: {e}", flush=True) if tg_ready(): tg_send(f"❌ Не могу сохранить Excel ни в основной, ни в AUTOSAVE: {e}") return False
def maybe_write_snapshot(wb: Workbook, base_path: str) -> None:
"""
Пишем снимок в отдельный файл с уникальным именем.
Такой файл можно держать открытым в Excel — бот не будет блокироваться.
"""
global _last_snapshot_ts
now = time.time()
if now - _last_snapshot_ts < SNAPSHOT_EVERY_SECONDS:
return
_last_snapshot_ts = now
texttry: Path(SNAPSHOT_DIR).mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") snap_path = os.path.join(SNAPSHOT_DIR, f"nowgoal_{stamp}.xlsx") wb.save(snap_path) print(f"[{now_ts()}] Snapshot saved: {snap_path}", flush=True) except Exception as e: print(f"[{now_ts()}] Snapshot save failed: {e}", flush=True)
OU_TOTAL_RE = re.compile(r"\b[OU]\s*([0-9]+(?:.[0-9]+)?)", re.IGNORECASE)
def read_total_from_cell(cell) -> Optional[float]:
"""Тотал берём из span.odds1, а если он пропал/пустой — выковыриваем из innerText."""
odds1 = cell.find_elements(By.CSS_SELECTOR, "span.odds1")
if odds1:
v = safe_float(txt(odds1[0]))
if v is not None:
return v
textit = (cell.get_attribute("innerText") or "").strip() m = OU_TOTAL_RE.search(it) if m: return safe_float(m.group(1)) return None
def split_team_and_tag(s: str) -> Team:
s = (s or "").strip()
if s.endswith("]") and "[" in s:
i = s.rfind("[")
name = s[:i].strip()
tag = s[i + 1:-1].strip() or None
if name:
return Team(name=name, tag=tag)
return Team(name=s, tag=None)
def parse_analysis_onclick(onclick: str) -> Optional[Tuple[int, str, str, str]]:
if not onclick or "basketInPage.analysis" not in onclick:
return None
try:
args_str = onclick[onclick.find("(") + 1: onclick.rfind(")")]
args = ast.literal_eval("(" + args_str + ")")
return int(args[0]), str(args[1]), str(args[2]), str(args[3])
except Exception:
return None
def scroll_load_all(driver, max_rounds: int = 25, pause: float = 0.35):
last = 0
stable = 0
for _ in range(max_rounds):
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(pause)
cur = len(driver.find_elements(By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]'))
if cur == last:
stable += 1
if stable >= 3:
break
else:
stable = 0
last = cur
driver.execute_script("window.scrollTo(0, 0);")
time.sleep(0.15)
def pick_band(abs_diff: float) -> Optional[str]:
for lo, hi, label in DIFF_BANDS:
if abs_diff >= lo and (hi is None or abs_diff < hi):
return label
return None
def line_complete(line: LineOU) -> bool:
return (line.total is not None and line.over_odds is not None and line.under_odds is not None)
LIVE_PAT = re.compile(r"\b\d+(?:st|nd|rd|th)\s+Qtr\b|\bOT\b|\bOvertime\b|\bLive\b", re.IGNORECASE)
TIME_PAT = re.compile(r"^\s*\d{1,2}:\d{2}\s*(?:AM|PM)?\s*$", re.IGNORECASE)
def is_finished(status: Optional[str]) -> bool:
return (status or "").strip().lower() in {"finished", "final", "ft", "ended"}
def is_halftime(status: Optional[str]) -> bool:
return "halftime" in (status or "").strip().lower()
def is_live(status: Optional[str]) -> bool:
s = (status or "").strip()
if not s:
return False
if is_finished(s) or is_halftime(s):
return True
return bool(LIVE_PAT.search(s))
def is_pregame(status: Optional[str]) -> bool:
"""
pregame = всё что НЕ live/halftime/finished и НЕ отменено/перенесено.
Pend. считаем pregame (игра задерживается).
"""
s = (status or "").strip()
if not s:
return True
if TIME_PAT.match(s):
return True
sl = s.lower()
if is_live(s) or is_halftime(s) or is_finished(s):
return False
if any(k in sl for k in ["postp", "cancel", "abandon", "void"]):
return False
if "pend" in sl:
return True
return True
def status_bucket(status: Optional[str]) -> str:
if is_halftime(status):
return "halftime"
if is_finished(status):
return "finished"
if is_live(status):
return "live"
if is_pregame(status):
return "pregame"
return "other"
def pick_best_line(current: LineOU, st_rec: Dict[str, Any], status: Optional[str]) -> LineOU:
"""
Если текущая линия неполная — берём последнюю полную из истории (или ещё более раннюю).
Приоритет: тот же bucket статуса.
"""
if line_complete(current):
return current
textbucket = status_bucket(status) hist = st_rec.get("line_hist") if not hist: return current for item in reversed(hist): if item["bucket"] == bucket and line_complete(item["line"]): return item["line"] for item in reversed(hist): if line_complete(item["line"]): return item["line"] return current
def empty_stats() -> Dict[str, Any]:
return {"bets": 0, "wins": 0, "losses": 0, "pushes": 0, "profit": 0.0}
def ensure_stats(d: Dict[str, Dict[str, Any]], key: str) -> Dict[str, Any]:
if key not in d:
d[key] = empty_stats()
return d[key]
def ensure_nested_stats(d: Dict[str, Dict[str, Dict[str, Any]]], k1: str, k2: str) -> Dict[str, Any]:
if k1 not in d:
d[k1] = {}
if k2 not in d[k1]:
d[k1][k2] = empty_stats()
return d[k1][k2]
def bet_profit(decimal_odds: float, stake: float, result: str) -> float:
if result == "push":
return 0.0
if result == "loss":
return -stake
return (decimal_odds - 1.0) * stake
def stats_line(s: Dict[str, Any]) -> str:
return f"Bets={s['bets']} W={s['wins']} L={s['losses']} P={s['pushes']} Profit={s['profit']:+.2f}"
def stats_block_extended(
stats_total: Dict[str, Any],
stats_by_band: Dict[str, Dict[str, Any]],
stats_by_league: Dict[str, Dict[str, Any]],
stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]],
focus_league: Optional[str] = None,
focus_band: Optional[str] = None,
) -> str:
lines = []
lines.append(f"TOTAL: {stats_line(stats_total)}")
for _, _, label in DIFF_BANDS:
lines.append(f"{label}: {stats_line(stats_by_band[label])}")
textif focus_league: ls = ensure_stats(stats_by_league, focus_league) lines.append("") lines.append(f"LEAGUE: {focus_league}") lines.append(stats_line(ls)) if focus_band: lbs = ensure_nested_stats(stats_by_league_band, focus_league, focus_band) lines.append(f"LEAGUE×BAND ({focus_band}): {stats_line(lbs)}") return "\n".join(lines)
BETS_HEADERS = [
"bet_uid", "match_id",
"league", "home", "away",
"band", "side",
"pre_total", "half_total", "diff", "abs_diff",
"odds", "stake",
"placed_at", "status_at_bet",
"final_total", "result", "profit", "settled_at",
]
def excel_open_or_create(path: str):
if os.path.exists(path):
wb = load_workbook(path)
else:
wb = Workbook()
text# Bets sheet if "Bets" not in wb.sheetnames: ws = wb.active ws.title = "Bets" ws.append(BETS_HEADERS) else: ws = wb["Bets"] if ws.max_row < 1: ws.append(BETS_HEADERS) else: header = [ws.cell(row=1, column=c).value for c in range(1, ws.max_column + 1)] if header[: len(BETS_HEADERS)] != BETS_HEADERS: for i, h in enumerate(BETS_HEADERS, start=1): ws.cell(row=1, column=i).value = h # Ensure other sheets for sname in ["Stats_Total", "Stats_Band", "Stats_League", "Stats_League_Band", "Monthly"]: if sname not in wb.sheetnames: wb.create_sheet(sname) # mapping bet_uid -> row ws_bets = wb["Bets"] uid_to_row: Dict[str, int] = {} for r in range(2, ws_bets.max_row + 1): uid = ws_bets.cell(row=r, column=1).value if uid: uid_to_row[str(uid)] = r safe_save(wb, path) return wb, uid_to_row
def excel_append_bet(wb: Workbook, uid_to_row: Dict[str, int], path: str, row: Dict[str, Any]) -> None:
ws = wb["Bets"]
values = [row.get(h) for h in BETS_HEADERS]
ws.append(values)
uid_to_row[str(row["bet_uid"])] = ws.max_row
safe_save(wb, path)
def excel_update_settle(
wb: Workbook,
uid_to_row: Dict[str, int],
path: str,
bet_uid: str,
final_total: int,
result: str,
profit: float,
settled_at: str,
) -> None:
ws = wb["Bets"]
r = uid_to_row.get(str(bet_uid))
if not r:
return
ws.cell(row=r, column=16).value = int(final_total)
ws.cell(row=r, column=17).value = str(result)
ws.cell(row=r, column=18).value = float(profit)
ws.cell(row=r, column=19).value = str(settled_at)
safe_save(wb, path)
def _clear_sheet_keep_header(ws, headers: Optional[list] = None):
ws.delete_rows(1, ws.max_row)
if headers:
ws.append(headers)
def excel_write_stats(
wb: Workbook,
path: str,
stats_total: Dict[str, Any],
stats_by_band: Dict[str, Dict[str, Any]],
stats_by_league: Dict[str, Dict[str, Any]],
stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]],
):
ws = wb["Stats_Total"]
_clear_sheet_keep_header(ws, ["metric", "value"])
for k in ["bets", "wins", "losses", "pushes", "profit"]:
ws.append([k, stats_total[k]])
textws = wb["Stats_Band"] _clear_sheet_keep_header(ws, ["band", "bets", "wins", "losses", "pushes", "profit"]) for _, _, band in DIFF_BANDS: s = stats_by_band[band] ws.append([band, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) ws = wb["Stats_League"] _clear_sheet_keep_header(ws, ["league", "bets", "wins", "losses", "pushes", "profit"]) for league, s in sorted(stats_by_league.items(), key=lambda kv: (-kv[1]["bets"], kv[0])): ws.append([league, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) ws = wb["Stats_League_Band"] _clear_sheet_keep_header(ws, ["league", "band", "bets", "wins", "losses", "pushes", "profit"]) rows = [] for league, bands in stats_by_league_band.items(): for band, s in bands.items(): rows.append((league, band, s)) for league, band, s in sorted(rows, key=lambda x: (-x[2]["bets"], x[0], x[1])): ws.append([league, band, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) safe_save(wb, path)
def excel_write_monthly(wb: Workbook, path: str) -> None:
ws_bets = wb["Bets"]
ws_m = wb["Monthly"]
textws_m.delete_rows(1, ws_m.max_row) ws_m.append(["month", "bets_settled", "profit", "cum_profit"]) header = [ws_bets.cell(row=1, column=c).value for c in range(1, ws_bets.max_column + 1)] def col_idx(name: str) -> int: return header.index(name) + 1 try: c_profit = col_idx("profit") c_settled_at = col_idx("settled_at") except ValueError: safe_save(wb, path) return monthly_profit: Dict[str, float] = {} monthly_bets: Dict[str, int] = {} for r in range(2, ws_bets.max_row + 1): settled_at = ws_bets.cell(row=r, column=c_settled_at).value prof = ws_bets.cell(row=r, column=c_profit).value if not settled_at or prof is None: continue month = str(settled_at)[:7] monthly_profit[month] = monthly_profit.get(month, 0.0) + float(prof) monthly_bets[month] = monthly_bets.get(month, 0) + 1 cum = 0.0 for month in sorted(monthly_profit.keys()): p = float(monthly_profit[month]) b = int(monthly_bets.get(month, 0)) cum += p ws_m.append([month, b, p, cum]) ws_m._charts = [] if ws_m.max_row >= 2: chart = LineChart() chart.title = "Monthly Profit Trend" chart.y_axis.title = "Profit" chart.x_axis.title = "Month" data = Reference(ws_m, min_col=3, max_col=4, min_row=1, max_row=ws_m.max_row) cats = Reference(ws_m, min_col=1, min_row=2, max_row=ws_m.max_row) chart.add_data(data, titles_from_data=True) chart.set_categories(cats) ws_m.add_chart(chart, "F2") safe_save(wb, path)
def make_driver(headless: bool = True):
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless=new")
options.add_argument("--window-size=1400,900")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument(
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
)
return webdriver.Chrome(options=options)
def _parse_ou_for_mid(driver, mid: int) -> LineOU:
over_total = under_total = None
over_odds = under_odds = None
textcells = [] ht = driver.find_elements(By.ID, f"ht_{mid}") gt = driver.find_elements(By.ID, f"gt_{mid}") if ht: cells.append(ht[0]) if gt: cells.append(gt[0]) if not cells: cells = driver.find_elements(By.CSS_SELECTOR, f'td.ouOdds[onclick*="oddscomp({mid}"]') for cell in cells: raw = (cell.get_attribute("innerText") or txt(cell)).strip().upper() odds2 = cell.find_elements(By.CSS_SELECTOR, "span.odds2") total_val = read_total_from_cell(cell) odds_val = read_odds_decimal(odds2[0]) if odds2 else None if raw.startswith("O"): over_total = total_val over_odds = odds_val elif raw.startswith("U"): under_total = total_val under_odds = odds_val line = LineOU(over_odds=over_odds, under_odds=under_odds) if over_total is not None and under_total is not None: if abs(over_total - under_total) < 1e-9: line.total = over_total else: line.total = (over_total + under_total) / 2.0 else: line.total = over_total if over_total is not None else under_total return line
def _get_snapshot_once(driver, do_scroll: bool) -> Dict[int, Dict[str, Any]]:
wait = WebDriverWait(driver, 30)
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]')))
if do_scroll:
scroll_load_all(driver)
textgames: Dict[int, Dict[str, Any]] = {} analysis_tds = driver.find_elements(By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]') for td in analysis_tds: parsed = parse_analysis_onclick(td.get_attribute("onclick") or "") if not parsed: continue mid, home_s, away_s, league_s = parsed games[mid] = { "match_id": mid, "league": league_s, "home": split_team_and_tag(home_s), "away": split_team_and_tag(away_s), "status": None, "line": LineOU(), "home_score": None, "away_score": None, } for mid in list(games.keys()): status_els = driver.find_elements(By.ID, f"zt_{mid}") if status_els: games[mid]["status"] = txt(status_els[0]) or None games[mid]["line"] = _parse_ou_for_mid(driver, mid) h_els = driver.find_elements(By.ID, f"hzf_{mid}") g_els = driver.find_elements(By.ID, f"gzf_{mid}") if h_els and g_els: hs = safe_float(txt(h_els[0])) gs = safe_float(txt(g_els[0])) games[mid]["home_score"] = int(hs) if hs is not None else None games[mid]["away_score"] = int(gs) if gs is not None else None return games
def get_snapshot(driver, do_scroll: bool, retries: int = 3) -> Dict[int, Dict[str, Any]]:
for attempt in range(1, retries + 1):
try:
return _get_snapshot_once(driver, do_scroll=do_scroll)
except StaleElementReferenceException:
print(f"[{now_ts()}] snapshot stale -> retry {attempt}/{retries}", flush=True)
time.sleep(0.25)
return _get_snapshot_once(driver, do_scroll=do_scroll)
def main():
print(f"[{now_ts()}] Starting monitor. poll={POLL_SECONDS}s stake={STAKE}", flush=True)
print(f"[{now_ts()}] Bands: " + ", ".join([lbl for _, _, lbl in DIFF_BANDS]), flush=True)
print(f"[{now_ts()}] Excel: {os.path.abspath(EXCEL_PATH)}", flush=True)
print(f"[{now_ts()}] Snapshots dir: {os.path.abspath(SNAPSHOT_DIR)} every {SNAPSHOT_EVERY_SECONDS}s", flush=True)
print(f"[{now_ts()}] Halftime wait: {HALFTIME_WAIT_SECONDS}s (first halftime only)", flush=True)
textwb, uid_to_row = excel_open_or_create(EXCEL_PATH) tg_send( "🟢 NowGoal monitor STARTED\n" f"poll={POLL_SECONDS}s | stake={STAKE}\n" f"bands: " + ", ".join([lbl for _, _, lbl in DIFF_BANDS]) + "\n" f"halftime_wait={HALFTIME_WAIT_SECONDS}s (first halftime only)\n" f"time={now_dt()}\n" f"excel(main)={os.path.abspath(EXCEL_PATH)}\n" f"snapshots={os.path.abspath(SNAPSHOT_DIR)}" ) driver = make_driver(headless=HEADLESS) state: Dict[int, Dict[str, Any]] = {} # stats (начинаются с нуля — как ты и сказал) stats_total = empty_stats() stats_by_band = {label: empty_stats() for _, _, label in DIFF_BANDS} stats_by_league: Dict[str, Dict[str, Any]] = {} stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]] = {} loop_n = 0 try: driver.get(URL) WebDriverWait(driver, 30).until( EC.presence_of_element_located((By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]')) ) while True: loop_n += 1 t0 = time.time() do_scroll = (loop_n == 1) or (loop_n % SCROLL_EVERY_N_LOOPS == 0) try: driver.refresh() time.sleep(0.35) except Exception as e: print(f"[{now_ts()}] refresh error: {e} -> reopen", flush=True) driver.get(URL) try: snap = get_snapshot(driver, do_scroll=do_scroll, retries=3) except Exception as e: print(f"[{now_ts()}] snapshot error: {e}", flush=True) time.sleep(POLL_SECONDS) continue # summary counts cnt_total = len(snap) cnt_live = cnt_half = cnt_finished = 0 for g in snap.values(): st = g.get("status") if is_finished(st): cnt_finished += 1 elif is_halftime(st): cnt_half += 1 elif is_live(st): cnt_live += 1 print( f"[{now_ts()}] loop={loop_n} games={cnt_total} live={cnt_live} halftime={cnt_half} finished={cnt_finished} " f"| TOTAL profit={stats_total['profit']:+.2f} bets={stats_total['bets']}", flush=True ) for mid, g in snap.items(): st_rec = state.setdefault(mid, { "league": g["league"], "home": g["home"], "away": g["away"], "pregame_total_last": None, "pregame_total_frozen": None, "bet": None, "bet_uid": None, "settled": False, "last_status": None, "line_hist": deque(maxlen=10), # ✅ NEW: halftime tracking "halftime_count": 0, # сколько раз вошли в halftime (переходом) "halftime_first_ts": None, # time.time() первого появления halftime }) status = g.get("status") line_raw: LineOU = g.get("line", LineOU()) line = pick_best_line(line_raw, st_rec, status) if line_complete(line): st_rec["line_hist"].append({ "ts": now_dt(), "bucket": status_bucket(status), "line": LineOU(line.total, line.over_odds, line.under_odds), }) # keep previous status for transitions prev_status = st_rec["last_status"] if status != prev_status: st_rec["last_status"] = status if status: parts = [] if line.total is not None: parts.append(f"OU={line.total:g}") if line.over_odds is not None: parts.append(f"O@{line.over_odds:.2f}") if line.under_odds is not None: parts.append(f"U@{line.under_odds:.2f}") line_str = " | " + (" ".join(parts) if parts else "OU=-") print( f" [{now_ts()}] status id={mid} | {st_rec['home'].name} vs {st_rec['away'].name} | {status}{line_str}", flush=True ) # ✅ NEW: detect FIRST entry into halftime (transition into halftime) if is_halftime(status) and not is_halftime(prev_status): st_rec["halftime_count"] += 1 if st_rec["halftime_count"] == 1: st_rec["halftime_first_ts"] = time.time() print( f" [{now_ts()}] FIRST HALFTIME detected id={mid} -> waiting {HALFTIME_WAIT_SECONDS}s for odds to stabilize", flush=True ) else: # second+ halftime (often OT) -> ignore for betting logic print( f" [{now_ts()}] HALFTIME повторно (#{st_rec['halftime_count']}) id={mid} -> IGNORE (OT/extra break)", flush=True ) # 1) pregame -> сохраняем последний pregame total if is_pregame(status): if line.total is not None: st_rec["pregame_total_last"] = float(line.total) # 2) first live -> freeze pregame total if is_live(status) and st_rec["pregame_total_frozen"] is None and st_rec["pregame_total_last"] is not None: st_rec["pregame_total_frozen"] = float(st_rec["pregame_total_last"]) print(f" [{now_ts()}] pregame frozen id={mid} pre_total={st_rec['pregame_total_frozen']:g}", flush=True) # 3) halftime -> bet (✅ only on FIRST halftime + ✅ wait 15s) if ( is_halftime(status) and st_rec["bet"] is None and st_rec["pregame_total_frozen"] is not None and st_rec["halftime_count"] == 1 # ✅ only first halftime ): # ✅ wait after first halftime appears ht_ts = st_rec.get("halftime_first_ts") if ht_ts is None: st_rec["halftime_first_ts"] = time.time() ht_ts = st_rec["halftime_first_ts"] if (time.time() - float(ht_ts)) < HALFTIME_WAIT_SECONDS: continue pre_total = float(st_rec["pregame_total_frozen"]) half_total = line.total if half_total is None: continue diff = float(half_total) - pre_total abs_diff = abs(diff) band = pick_band(abs_diff) if band is None: continue if diff > 0: side = "UNDER" odds = line.under_odds else: side = "OVER" odds = line.over_odds if odds is None: continue bet_uid = f"{mid}-{int(time.time()*1000)}" st_rec["bet_uid"] = bet_uid st_rec["bet"] = { "band": band, "side": side, "line": float(half_total), "odds": float(odds), "stake": float(STAKE), "pre_total": float(pre_total), "half_total": float(half_total), "diff": float(diff), "abs_diff": float(abs_diff), "placed_at": now_dt(), "status_at_bet": status, } league = st_rec["league"] stats_total["bets"] += 1 stats_by_band[band]["bets"] += 1 ensure_stats(stats_by_league, league)["bets"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["bets"] += 1 excel_append_bet( wb, uid_to_row, EXCEL_PATH, { "bet_uid": bet_uid, "match_id": mid, "league": league, "home": st_rec["home"].name, "away": st_rec["away"].name, "band": band, "side": side, "pre_total": float(pre_total), "half_total": float(half_total), "diff": float(diff), "abs_diff": float(abs_diff), "odds": float(odds), "stake": float(STAKE), "placed_at": st_rec["bet"]["placed_at"], "status_at_bet": status, "final_total": None, "result": None, "profit": None, "settled_at": None, } ) excel_write_stats(wb, EXCEL_PATH, stats_total, stats_by_band, stats_by_league, stats_by_league_band) excel_write_monthly(wb, EXCEL_PATH) print( f" [{now_ts()}] BET PLACED [{band}] id={mid} {side} {half_total:g} @ {odds:.2f} " f"(pre {pre_total:g}, diff {diff:+g})", flush=True ) block = stats_block_extended( stats_total, stats_by_band, stats_by_league, stats_by_league_band, focus_league=league, focus_band=band ) tg_send( f"✅ BET PLACED (TOTAL #{stats_total['bets']}) [{band}]\n" f"{league}\n" f"{st_rec['home'].name} vs {st_rec['away'].name}\n" f"Status: {status}\n" f"(waited {HALFTIME_WAIT_SECONDS}s after first halftime)\n" f"Pre total: {pre_total:g}\n" f"Halftime line: {half_total:g} (diff {diff:+g})\n" f"BET: {side} {half_total:g} @ {float(odds):.2f}\n" f"Stake: {STAKE}\n" f"\n📊 STATS\n{block}\n" f"id={mid}\n" f"bet_uid={bet_uid}" ) # 4) finished -> settle bet if is_finished(status) and st_rec["bet"] is not None and not st_rec["settled"]: hs = g.get("home_score") gs = g.get("away_score") if hs is None or gs is None: continue final_total = int(hs) + int(gs) band = st_rec["bet"]["band"] league = st_rec["league"] side = st_rec["bet"]["side"] line_bet = float(st_rec["bet"]["line"]) odds = float(st_rec["bet"]["odds"]) stake = float(st_rec["bet"]["stake"]) if abs(final_total - line_bet) < 1e-9: result = "push" stats_total["pushes"] += 1 stats_by_band[band]["pushes"] += 1 ensure_stats(stats_by_league, league)["pushes"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["pushes"] += 1 else: win = (final_total < line_bet) if side == "UNDER" else (final_total > line_bet) if win: result = "win" stats_total["wins"] += 1 stats_by_band[band]["wins"] += 1 ensure_stats(stats_by_league, league)["wins"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["wins"] += 1 else: result = "loss" stats_total["losses"] += 1 stats_by_band[band]["losses"] += 1 ensure_stats(stats_by_league, league)["losses"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["losses"] += 1 prof = bet_profit(odds, stake, result) stats_total["profit"] += prof stats_by_band[band]["profit"] += prof ensure_stats(stats_by_league, league)["profit"] += prof ensure_nested_stats(stats_by_league_band, league, band)["profit"] += prof st_rec["settled"] = True bet_uid = st_rec.get("bet_uid") if bet_uid: excel_update_settle( wb, uid_to_row, EXCEL_PATH, bet_uid, final_total, result.upper(), float(prof), now_dt() ) excel_write_stats(wb, EXCEL_PATH, stats_total, stats_by_band, stats_by_league, stats_by_league_band) excel_write_monthly(wb, EXCEL_PATH) block = stats_block_extended( stats_total, stats_by_band, stats_by_league, stats_by_league_band, focus_league=league, focus_band=band ) print( f" [{now_ts()}] BET SETTLED [{band}] id={mid} {result.upper()} final_total={final_total} " f"bet={side} {line_bet:g} @ {odds:.2f} P/L={prof:+.2f}\n{block}", flush=True ) tg_send( f"🏁 BET RESULT: {result.upper()} [{band}]\n" f"{league}\n" f"{st_rec['home'].name} {hs} : {gs} {st_rec['away'].name}\n" f"Final total: {final_total}\n" f"Bet: {side} {line_bet:g} @ {odds:.2f} | stake {stake}\n" f"P/L: {prof:+.2f}\n\n" f"📊 STATS\n{block}\n" f"id={mid}\n" f"bet_uid={bet_uid}" ) # делаем snapshot раз в SNAPSHOT_EVERY_SECONDS (можно смотреть в Excel без блокировок) maybe_write_snapshot(wb, EXCEL_PATH) elapsed = time.time() - t0 time.sleep(max(0.2, POLL_SECONDS - elapsed)) finally: driver.quit() print(f"[{now_ts()}] Driver closed.", flush=True)
if name == "main":
main() по работе этого кода у меня такой вот результат, excel файл, нудно изучить этот файл, найти проблемные зоны в ставках где я минусую, найти хорошие зоны где есть профит, и переделать код, так чтобы он делал ставки только по плюсовой стратегии
Я открыл твой Excel /mnt/data/nowgoal_20260114_082711.xlsx (лист Bets, 251 ставка — все рассчитаны/settled) и посчитал, где именно ты «льёшь», а где есть плюс.
| Band | Side | Bets | Profit | ROI |
|---|---|---|---|---|
| 20-30 | OVER | 91 | −15.67 | −17.22% |
| 30-40 | UNDER | 26 | −4.10 | −15.77% |
| 40-50 | UNDER | 6 | −0.45 | −7.50% |
| 40-50 | OVER | 2 | −0.17 | −8.50% |
Ключевое: OVER почти полностью убивает итог (−13.50 по всем OVER), UNDER около нуля (−2.62).
Если разбить abs_diff 20–30 на поддиапазоны по 2 пункта:
👉 То есть у тебя не просто “band 20-30”, а внутри него есть дыра 22–26, которая даёт основной минус.
Если делать только UNDER и убрать abs_diff 22–26, оставив:
то на твоей истории получается:
⚠️ Это бэктест по тем же данным → есть риск переобучения, но по цифрам видно, что дырка 22–26 статистически токсичная, и её логично вырезать уже сейчас.
Ниже минимальная и безопасная переделка: добавляем правила стратегии и проверку перед размещением ставки.
python# ===================== STRATEGY FILTER ===================== USE_STRATEGY_FILTER = True # Плюсовая стратегия по твоему Excel: # ставим ТОЛЬКО UNDER в band 20-30 и только если abs_diff в [20,22) или [26,30) STRATEGY_RULES = [ {"band": "20-30", "side": "UNDER", "abs_min": 20.0, "abs_max": 22.0}, {"band": "20-30", "side": "UNDER", "abs_min": 26.0, "abs_max": 30.0}, ]
pythondef strategy_allows(league: str, band: str, side: str, abs_diff: float) -> bool: """ True = ставку можно делать, False = пропускаем. abs_max считается как верхняя граница EXCLUSIVE (abs_diff < abs_max). """ if not USE_STRATEGY_FILTER: return True for r in STRATEGY_RULES: if r.get("band") and band != r["band"]: continue if r.get("side") and side != r["side"]: continue # опционально, если захочешь правила под конкретные лиги: # if r.get("league_re") and not re.search(r["league_re"], league or "", re.I): # continue abs_min = r.get("abs_min") abs_max = r.get("abs_max") if abs_min is not None and abs_diff < float(abs_min): continue if abs_max is not None and abs_diff >= float(abs_max): continue return True return False
# 3) halftime -> bet)Найди место после вычисления band, side, odds — и добавь:
pythonleague = st_rec["league"] # ✅ Strategy filter: ставим только если правило разрешает if not strategy_allows(league, band, side, abs_diff): # можно залогировать, но без TG (чтобы не спамить) print( f" [{now_ts()}] SKIP (strategy) id={mid} {league} [{band}] {side} abs_diff={abs_diff:g}", flush=True ) continue
После этого бот перестанет ставить:
Если хочешь, я могу сделать ещё более “устойчиво”:
а почему по лигам не разделил? может какие-то лиги тоже стоит убрать или нет?
pythonimport pandas as pd, openpyxl, os, math, numpy as np, re, statistics path="/mnt/data/nowgoal_20260114_082711.xlsx" wb=openpyxl.load_workbook(path, data_only=True) wb.sheetnames ['Bets', 'Stats_Total', 'Stats_Band', 'Stats_League', 'Stats_League_Band', 'Monthly']
pythonws=wb["Bets"] # read to dataframe data=[] headers=[cell.value for cell in ws[1]] for row in ws.iter_rows(min_row=2, values_only=True): if all(v is None for v in row): continue data.append(row[:len(headers)]) df=pd.DataFrame(data, columns=headers) df.head(), df.shape, df.columns ( bet_uid match_id league \ 0 663973-1767501590274 663973 National Basketball Association 1 659463-1767506023947 659463 National Basketball League(Japan) 2 658877-1767506082278 658877 Korea Basketball League 3 658308-1767506504688 658308 Basketball Japan League 4 658311-1767506647375 658311 Basketball Japan League home away band side pre_total \ 0 Los Angeles Clippers Boston Celtics 20-30 UNDER 220.0 1 Kumamoto Volters Iwate Big Bulls 20-30 UNDER 147.5 2 Busan KTF Magicwings Changwon LG Sakers 20-30 OVER 151.5 3 Saga Ballooners Gunma Crane Thunders 20-30 OVER 160.5 4 Hitachi Sunrockers Nagoya Fighting Eagles 20-30 UNDER 159.5 half_total diff abs_diff odds stake placed_at status_at_bet \ 0 241.5 21.5 21.5 1.83 1 2026-01-04 13:39:50 Halftime 1 167.5 20.0 20.0 1.83 1 2026-01-04 14:53:43 Halftime 2 131.5 -20.0 20.0 1.83 1 2026-01-04 14:54:42 Halftime 3 137.5 -23.0 23.0 1.86 1 2026-01-04 15:01:44 Halftime 4 184.5 25.0 25.0 1.80 1 2026-01-04 15:04:07 Halftime final_total result profit settled_at 0 261 LOSS -1.00 2026-01-04 14:47:06 1 153 WIN 0.83 2026-01-04 16:07:08 2 151 WIN 0.83 2026-01-04 16:04:30 3 132 LOSS -1.00 2026-01-04 16:02:45 4 175 WIN 0.80 2026-01-04 16:16:43 , (251, 19), Index(['bet_uid', 'match_id', 'league', 'home', 'away', 'band', 'side', 'pre_total', 'half_total', 'diff', 'abs_diff', 'odds', 'stake', 'placed_at', 'status_at_bet', 'final_total', 'result', 'profit', 'settled_at'], dtype='object'))
pythondf['profit']=pd.to_numeric(df['profit'], errors='coerce') df['stake']=pd.to_numeric(df['stake'], errors='coerce').fillna(1.0) overall_bets=len(df) overall_profit=df['profit'].sum() overall_stake=df['stake'].sum() roi=overall_profit/overall_stake overall_bets, overall_profit, roi (251, -16.12, -0.06422310756972112)
pythondef agg_table(d): out=[] for league, g in d.groupby('league'): bets=len(g) prof=g['profit'].sum() stake=g['stake'].sum() roi=prof/stake if stake else np.nan wins=(g['result'].astype(str).str.upper()=='WIN').sum() losses=(g['result'].astype(str).str.upper()=='LOSS').sum() pushes=(g['result'].astype(str).str.upper()=='PUSH').sum() winrate=wins/(wins+losses) if (wins+losses)>0 else np.nan out.append((league,bets,prof,roi,wins,losses,pushes,winrate)) tab=pd.DataFrame(out, columns=['league','bets','profit','roi','wins','losses','pushes','winrate']) tab=tab.sort_values(['bets','profit'], ascending=[False,True]) return tab league_tab=agg_table(df) league_tab.head(20) league bets profit roi \ 76 National Committee Association America 33 -3.07 -0.093030 77 National Committee Association America W 11 -3.74 -0.340000 70 National Basketball Association 9 -1.52 -0.168889 74 National Basketball League(China) 5 -1.37 -0.274000 52 Italy Serie B 5 -1.34 -0.268000 5 Argentina La Liga 5 -1.31 -0.262000 3 Angola Campeonato Nacional 5 2.32 0.464000 92 Romania Liga 1 4 -2.17 -0.542500 22 Chinese Taiwan Professional Basketball League 4 -0.34 -0.085000 69 Macau NBL 4 -0.34 -0.085000 66 Ligue Feminine de Basketball 4 1.43 0.357500 20 Chinese Basketball Association 4 1.49 0.372500 96 Slovak Extraliga Basketball 4 1.49 0.372500 35 Finland Division 1A 3 -3.00 -1.000000 87 Portugal Nacional 1A 3 -3.00 -1.000000 71 National Basketball League (Bulgaria) 3 -1.20 -0.400000 49 Israeli Liga Leumit 3 -1.17 -0.390000 30 El Salvador Fesabal U21 3 -1.14 -0.380000 88 Portugal Nacional 2A 3 -1.14 -0.380000 13 Basketball Japan League 3 0.63 0.210000 wins losses pushes winrate 76 16 17 0 0.484848 77 4 7 0 0.363636 70 4 5 0 0.444444 74 2 3 0 0.400000 52 2 3 0 0.400000 5 2 3 0 0.400000 3 4 1 0 0.800000 92 1 3 0 0.250000 22 2 2 0 0.500000 69 2 2 0 0.500000 66 3 1 0 0.750000 20 3 1 0 0.750000 96 3 1 0 0.750000 35 0 3 0 0.000000 87 0 3 0 0.000000 71 1 2 0 0.333333 49 1 2 0 0.333333 30 1 2 0 0.333333 88 1 2 0 0.333333 13 2 1 0 0.666667
pythontop_leagues=league_tab.sort_values('bets',ascending=False).head(20) top_leagues league bets profit roi \ 76 National Committee Association America 33 -3.07 -0.093030 77 National Committee Association America W 11 -3.74 -0.340000 70 National Basketball Association 9 -1.52 -0.168889 74 National Basketball League(China) 5 -1.37 -0.274000 52 Italy Serie B 5 -1.34 -0.268000 5 Argentina La Liga 5 -1.31 -0.262000 3 Angola Campeonato Nacional 5 2.32 0.464000 66 Ligue Feminine de Basketball 4 1.43 0.357500 96 Slovak Extraliga Basketball 4 1.49 0.372500 20 Chinese Basketball Association 4 1.49 0.372500 69 Macau NBL 4 -0.34 -0.085000 22 Chinese Taiwan Professional Basketball League 4 -0.34 -0.085000 92 Romania Liga 1 4 -2.17 -0.542500 13 Basketball Japan League 3 0.63 0.210000 102 Tunisia League 3 2.49 0.830000 93 Saudi Arabia Premier League 3 2.49 0.830000 82 Philippine Basketball Cup 3 2.49 0.830000 73 National Basketball League(Australia) 3 0.77 0.256667 34 Estonian–Latvian Basketball League 3 0.66 0.220000 14 Basketball League of Serbia 3 0.66 0.220000 wins losses pushes winrate 76 16 17 0 0.484848 77 4 7 0 0.363636 70 4 5 0 0.444444 74 2 3 0 0.400000 52 2 3 0 0.400000 5 2 3 0 0.400000 3 4 1 0 0.800000 66 3 1 0 0.750000 96 3 1 0 0.750000 20 3 1 0 0.750000 69 2 2 0 0.500000 22 2 2 0 0.500000 92 1 3 0 0.250000 13 2 1 0 0.666667 102 3 0 0 1.000000 93 3 0 0 1.000000 82 3 0 0 1.000000 73 2 1 0 0.666667 34 2 1 0 0.666667 14 2 1 0 0.666667
pythonleague10=league_tab[league_tab['bets']>=10].sort_values('roi') league10 league bets profit roi wins \ 77 National Committee Association America W 11 -3.74 -0.34000 4 76 National Committee Association America 33 -3.07 -0.09303 16 losses pushes winrate 77 7 0 0.363636 76 17 0 0.484848
pythonleague5=league_tab[league_tab['bets']>=5].sort_values('roi') league5[['league','bets','profit','roi','winrate']] league bets profit roi winrate 77 National Committee Association America W 11 -3.74 -0.340000 0.363636 74 National Basketball League(China) 5 -1.37 -0.274000 0.400000 52 Italy Serie B 5 -1.34 -0.268000 0.400000 5 Argentina La Liga 5 -1.31 -0.262000 0.400000 70 National Basketball Association 9 -1.52 -0.168889 0.444444 76 National Committee Association America 33 -3.07 -0.093030 0.484848 3 Angola Campeonato Nacional 5 2.32 0.464000 0.800000
python# Proposed strategy filter mask = (df['band']=='20-30') & (df['side']=='UNDER') & ( ((df['abs_diff']>=20.0) & (df['abs_diff']<22.0)) | ((df['abs_diff']>=26.0) & (df['abs_diff']<30.0)) ) df_strat=df[mask].copy() len(df_strat), df_strat['profit'].sum(), df_strat['profit'].sum()/df_strat['stake'].sum() (61, 10.660000000000002, 0.17475409836065578)
pythonleague_strat=agg_table(df_strat) league_strat.sort_values('bets',ascending=False).head(20) league bets profit roi wins \ 30 National Committee Association America 10 5.00 0.500 8 28 National Basketball League(China) 3 0.63 0.210 2 10 Finland Division 1A 2 -2.00 -1.000 0 36 Romania Liga 1 2 -2.00 -1.000 0 1 Argentina La Liga 2 -0.17 -0.085 1 21 Liga Nacional de Bosquetbol 2 -0.17 -0.085 1 31 National Committee Association America W 2 -0.17 -0.085 1 3 British Championship Basketball 2 1.69 0.845 2 24 Ligue Nationale de Basket Pro B 1 0.83 0.830 1 9 Estonian–Latvian Basketball League 1 0.83 0.830 1 15 Iceland Cup Women 1 0.83 0.830 1 16 Israel Premier League Women 1 0.83 0.830 1 17 Israeli Basketball Premier League 1 0.83 0.830 1 20 Lebanon FLB 1 0.83 0.830 1 23 Ligue Feminine de Basketball 1 0.83 0.830 1 37 Saudi Arabia Premier League 1 0.83 0.830 1 29 National Basketball League(Japan) 1 0.83 0.830 1 32 Norway Ligaen Women 1 0.83 0.830 1 33 Philippine Basketball Cup 1 0.83 0.830 1 5 Chinese Basketball Association 1 0.83 0.830 1 losses pushes winrate 30 2 0 0.800000 28 1 0 0.666667 10 2 0 0.000000 36 2 0 0.000000 1 1 0 0.500000 21 1 0 0.500000 31 1 0 0.500000 3 0 0 1.000000 24 0 0 1.000000 9 0 0 1.000000 15 0 0 1.000000 16 0 0 1.000000 17 0 0 1.000000 20 0 0 1.000000 23 0 0 1.000000 37 0 0 1.000000 29 0 0 1.000000 32 0 0 1.000000 33 0 0 1.000000 5 0 0 1.000000
pythondef league_side_band(df_in, league): g=df_in[df_in['league']==league] tab=g.groupby(['band','side']).agg(bets=('bet_uid','count'), profit=('profit','sum'), stake=('stake','sum')).reset_index() tab['roi']=tab['profit']/tab['stake'] return tab.sort_values('bets', ascending=False) league_side_band(df, "National Committee Association America W") band side bets profit stake roi 0 20-30 OVER 5 -1.37 5 -0.274 1 20-30 UNDER 2 -0.17 2 -0.085 2 30-40 OVER 2 -0.20 2 -0.100 3 30-40 UNDER 2 -2.00 2 -1.000
pythonleague_side_band(df, "National Committee Association America") band side bets profit stake roi 1 20-30 UNDER 15 3.76 15 0.250667 0 20-30 OVER 14 -4.73 14 -0.337857 2 30-40 OVER 2 -0.10 2 -0.050000 3 30-40 UNDER 2 -2.00 2 -1.000000
pythonleague_side_band(df, "National Basketball Association") band side bets profit stake roi 0 20-30 OVER 8 -0.52 8 -0.065 1 20-30 UNDER 1 -1.00 1 -1.000
pythonleague3=league_tab[league_tab['bets']>=3].copy() pos=league3.sort_values('profit',ascending=False).head(15)[['league','bets','profit','roi','winrate']] neg=league3.sort_values('profit',ascending=True).head(15)[['league','bets','profit','roi','winrate']] pos, neg ( league bets profit roi \ 102 Tunisia League 3 2.49 0.830000 82 Philippine Basketball Cup 3 2.49 0.830000 93 Saudi Arabia Premier League 3 2.49 0.830000 3 Angola Campeonato Nacional 5 2.32 0.464000 96 Slovak Extraliga Basketball 4 1.49 0.372500 20 Chinese Basketball Association 4 1.49 0.372500 66 Ligue Feminine de Basketball 4 1.43 0.357500 73 National Basketball League(Australia) 3 0.77 0.256667 14 Basketball League of Serbia 3 0.66 0.220000 34 Estonian–Latvian Basketball League 3 0.66 0.220000 13 Basketball Japan League 3 0.63 0.210000 22 Chinese Taiwan Professional Basketball League 4 -0.34 -0.085000 69 Macau NBL 4 -0.34 -0.085000 30 El Salvador Fesabal U21 3 -1.14 -0.380000 88 Portugal Nacional 2A 3 -1.14 -0.380000 winrate 102 1.000000 82 1.000000 93 1.000000 3 0.800000 96 0.750000 20 0.750000 66 0.750000 73 0.666667 14 0.666667 34 0.666667 13 0.666667 22 0.500000 69 0.500000 30 0.333333 88 0.333333 , league bets profit roi \ 77 National Committee Association America W 11 -3.74 -0.340000 76 National Committee Association America 33 -3.07 -0.093030 87 Portugal Nacional 1A 3 -3.00 -1.000000 35 Finland Division 1A 3 -3.00 -1.000000 92 Romania Liga 1 4 -2.17 -0.542500 70 National Basketball Association 9 -1.52 -0.168889 74 National Basketball League(China) 5 -1.37 -0.274000 52 Italy Serie B 5 -1.34 -0.268000 5 Argentina La Liga 5 -1.31 -0.262000 71 National Basketball League (Bulgaria) 3 -1.20 -0.400000 49 Israeli Liga Leumit 3 -1.17 -0.390000 30 El Salvador Fesabal U21 3 -1.14 -0.380000 88 Portugal Nacional 2A 3 -1.14 -0.380000 69 Macau NBL 4 -0.34 -0.085000 22 Chinese Taiwan Professional Basketball League 4 -0.34 -0.085000 winrate 77 0.363636 76 0.484848 87 0.000000 35 0.000000 92 0.250000 70 0.444444 74 0.400000 52 0.400000 5 0.400000 71 0.333333 49 0.333333 30 0.333333 88 0.333333 69 0.500000 22 0.500000 )
pythonneg league bets profit roi \ 77 National Committee Association America W 11 -3.74 -0.340000 76 National Committee Association America 33 -3.07 -0.093030 87 Portugal Nacional 1A 3 -3.00 -1.000000 35 Finland Division 1A 3 -3.00 -1.000000 92 Romania Liga 1 4 -2.17 -0.542500 70 National Basketball Association 9 -1.52 -0.168889 74 National Basketball League(China) 5 -1.37 -0.274000 52 Italy Serie B 5 -1.34 -0.268000 5 Argentina La Liga 5 -1.31 -0.262000 71 National Basketball League (Bulgaria) 3 -1.20 -0.400000 49 Israeli Liga Leumit 3 -1.17 -0.390000 30 El Salvador Fesabal U21 3 -1.14 -0.380000 88 Portugal Nacional 2A 3 -1.14 -0.380000 69 Macau NBL 4 -0.34 -0.085000 22 Chinese Taiwan Professional Basketball League 4 -0.34 -0.085000 winrate 77 0.363636 76 0.484848 87 0.000000 35 0.000000 92 0.250000 70 0.444444 74 0.400000 52 0.400000 5 0.400000 71 0.333333 49 0.333333 30 0.333333 88 0.333333 69 0.500000 22 0.500000
pythondf_over2030=df[(df['band']=='20-30') & (df['side']=='OVER')].copy() len(df_over2030), df_over2030['profit'].sum() (91, -15.67)
pythonover_league=agg_table(df_over2030).sort_values('profit') over_league.head(15), over_league.sort_values('bets',ascending=False).head(10) ( league bets profit roi wins \ 38 National Committee Association America 14 -4.73 -0.337857 5 6 Basketball Bundesliga 2 -2.00 -1.000000 0 20 France Nationale 1 2 -2.00 -1.000000 0 56 West Asia Super League 2 -2.00 -1.000000 0 39 National Committee Association America W 5 -1.37 -0.274000 2 18 Finland Korisliiga Women 1 -1.00 -1.000000 0 24 Italy Cup Women 1 -1.00 -1.000000 0 25 Japan Basketball Mikado Cup 1 -1.00 -1.000000 0 29 Liga Femenina de Baloncesto 1 -1.00 -1.000000 0 30 Liga Nationala (Women´´s basketball) 1 -1.00 -1.000000 0 31 Liga Portuguesa de Basquetebol 1 -1.00 -1.000000 0 45 Romania Liga 1 1 -1.00 -1.000000 0 41 Osterreichische Basketball Bundesliga 1 -1.00 -1.000000 0 17 Finland Division 1A 1 -1.00 -1.000000 0 53 Turkey TB2L 1 -1.00 -1.000000 0 losses pushes winrate 38 9 0 0.357143 6 2 0 0.000000 20 2 0 0.000000 56 2 0 0.000000 39 3 0 0.400000 18 1 0 0.000000 24 1 0 0.000000 25 1 0 0.000000 29 1 0 0.000000 30 1 0 0.000000 31 1 0 0.000000 45 1 0 0.000000 41 1 0 0.000000 17 1 0 0.000000 53 1 0 0.000000 , league bets profit roi wins \ 38 National Committee Association America 14 -4.73 -0.337857 5 34 National Basketball Association 8 -0.52 -0.065000 4 39 National Committee Association America W 5 -1.37 -0.274000 2 20 France Nationale 1 2 -2.00 -1.000000 0 6 Basketball Bundesliga 2 -2.00 -1.000000 0 47 Serie A2 Gold Basket 2 1.66 0.830000 2 12 Chinese Basketball Association 2 -0.17 -0.085000 1 28 Lietuvos krepsinio lyga 2 -0.17 -0.085000 1 44 Portugal Nacional Liga Women 2 -0.17 -0.085000 1 56 West Asia Super League 2 -2.00 -1.000000 0 losses pushes winrate 38 9 0 0.357143 34 4 0 0.500000 39 3 0 0.400000 20 2 0 0.000000 6 2 0 0.000000 47 0 0 1.000000 12 1 0 0.500000 28 1 0 0.500000 44 1 0 0.500000 56 2 0 0.000000 )
pythondf_under2030=df[(df['band']=='20-30') & (df['side']=='UNDER')].copy() df_under2030['profit'].sum(), len(df_under2030) (1.9300000000000015, 110)
pythonunder_league=agg_table(df_under2030).sort_values('bets',ascending=False) under_league.head(15) league bets profit roi \ 47 National Committee Association America 15 3.76 0.250667 0 Angola Campeonato Nacional 4 1.49 0.372500 44 National Basketball League (Bulgaria) 3 -1.20 -0.400000 31 Italy Serie B 3 -1.17 -0.390000 58 Romania Liga 1 3 -1.17 -0.390000 45 National Basketball League(China) 3 0.63 0.210000 62 Tunisia League 3 2.49 0.830000 56 Portugal Nacional 2A 2 -0.14 -0.070000 8 British Championship Basketball 2 1.69 0.845000 59 Saudi Arabia Premier League 2 1.66 0.830000 51 Philippine Basketball Cup 2 1.66 0.830000 13 Chinese Taiwan Professional Basketball League 2 1.66 0.830000 11 Chinese Basketball Association 2 1.66 0.830000 4 Basketball Japan League 2 1.63 0.815000 5 Basketball League of Serbia 2 1.66 0.830000 wins losses pushes winrate 47 10 5 0 0.666667 0 3 1 0 0.750000 44 1 2 0 0.333333 31 1 2 0 0.333333 58 1 2 0 0.333333 45 2 1 0 0.666667 62 3 0 0 1.000000 56 1 1 0 0.500000 8 2 0 0 1.000000 59 2 0 0 1.000000 51 2 0 0 1.000000 13 2 0 0 1.000000 11 2 0 0 1.000000 4 2 0 0 1.000000 5 2 0 0 1.000000
pythonagg_table(df_bad).sort_values('bets',ascending=False).head(15) league bets profit roi wins \ 21 National Committee Association America 5 -1.24 -0.248 2 0 Angola Campeonato Nacional 3 0.69 0.230 2 16 Italy Serie B 3 -1.17 -0.390 1 27 Portugal Nacional 2A 2 -0.14 -0.070 1 30 Tunisia League 2 1.63 0.815 2 3 Basketball Japan League 2 1.63 0.815 2 9 Chinese Taiwan Professional Basketball League 2 1.66 0.830 2 1 Apollon Limassol BC 2 -0.20 -0.100 1 26 Portugal Nacional 1A 2 -2.00 -1.000 0 20 National Basketball League (Bulgaria) 2 -2.00 -1.000 0 14 Israeli Liga Leumit 2 -2.00 -1.000 0 24 Polska Liga Koszykówki Kobiet 1 -1.00 -1.000 0 29 Saudi Arabia Premier League 1 0.83 0.830 1 28 Romania Liga 1 1 0.83 0.830 1 23 Philippine Basketball Cup 1 0.83 0.830 1 losses pushes winrate 21 3 0 0.400000 0 1 0 0.666667 16 2 0 0.333333 27 1 0 0.500000 30 0 0 1.000000 3 0 0 1.000000 9 0 0 1.000000 1 1 0 0.500000 26 2 0 0.000000 20 2 0 0.000000 14 2 0 0.000000 24 1 0 0.000000 29 0 0 1.000000 28 0 0 1.000000 23 0 0 1.000000
Потому что в твоём файле почти все лиги имеют слишком маленькую выборку (1–3 ставки), и “по лигам” получается много шума: одна-две случайные победы/поражения резко переворачивают ROI. Поэтому я сначала резал по band/side/abs_diff — там выборка намного плотнее, и видно реальную “дыру” (22–26).
Но по лигам я всё равно разделил — вот что выходит на твоём Excel.
В этом файле только 2 лиги имеют ≥10 ставок:
Если взять порог ≥5 ставок, добавляются ещё несколько лиг, но там всё равно выборки маленькие (5–9 ставок): NBA (9), China NBL (5), Italy Serie B (5), Argentina La Liga (5), Angola (5, плюс).
Даже в NCAA Men общий минус почти целиком создаётся твоей “плохой зоной” 20–30 + OVER:
То есть лигой “лечить” тут не надо — лечится стороной (не брать OVER) и фильтром по abs_diff.
А вот NCAA Women действительно выглядит кандидатом на исключение: ROI −34% на 11 ставках — это уже не “случайность на 2 играх”.
Напомню стратегию, которую я предложил по твоему файлу:
Только UNDER, band 20–30, и abs_diff ∈ [20,22) или [26,30)
(убираем токсичную яму 22–26)
По этой стратегии у тебя: 61 ставка, +10.66, ROI +17.5%.
По лигам там картина такая:
Вывод: если добавлять лиговый фильтр прямо сейчас — то только один очевидный кандидат: NCAA Women.
python# ===== LEAGUE FILTER ===== LEAGUE_BLACKLIST_RE = [ r"\bNational Committee Association America W\b", # NCAA Women (по твоему Excel) ] # если захочешь наоборот "белый список", можно включить: LEAGUE_WHITELIST_RE = [] # например: [r"NCAA", r"Japan", ...]
pythondef league_allowed(league: str) -> bool: lg = league or "" # whitelist (если задан) — разрешаем только то, что матчится if LEAGUE_WHITELIST_RE: ok = any(re.search(p, lg, re.I) for p in LEAGUE_WHITELIST_RE) if not ok: return False # blacklist — запрещаем if any(re.search(p, lg, re.I) for p in LEAGUE_BLACKLIST_RE): return False return True
league, band, side, abs_diff) вставь:pythonif not league_allowed(league): print(f" [{now_ts()}] SKIP (league blacklist) id={mid} league={league}", flush=True) continue
Если хочешь, я могу сделать вариант “умнее”: при старте читать твой Bets-лист и автоматически собирать blacklist для лиг, где (например) >=12 ставок и ROI < −5% в рамках выбранной стратегии. Это будет гораздо устойчивее, чем вручную банить лиги на 2–3 играх.
import re
import ast
import os
import time
from dataclasses import dataclass
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
from collections import deque
from pathlib import Path
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import StaleElementReferenceException
from openpyxl import Workbook, load_workbook
from openpyxl.chart import LineChart, Reference
URL = "https://www.nowgoal.com/basketball"
POLL_SECONDS = 5
STAKE = 1.0
HEADLESS = True
HALFTIME_WAIT_SECONDS = 15
DIFF_BANDS = [
(20.0, 30.0, "20-30"),
(30.0, 40.0, "30-40"),
(40.0, 50.0, "40-50"),
(50.0, None, "50+"),
]
SCROLL_EVERY_N_LOOPS = 12
EXCEL_PATH = os.getenv("EXCEL_PATH", "nowgoal_monitor.xlsx")
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "8025557030:AAHgZX_pCOvQu7Gmi33X3iqe0oY7Y67yBMA")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "6667940236")
SNAPSHOT_DIR = "snapshots" # сюда будут писаться файлы, которые можно открывать в Excel
SNAPSHOT_EVERY_SECONDS = 60 # снимок раз в минуту (можешь поставить 30)
AUTOSAVE_SUFFIX = "_AUTOSAVE.xlsx" # если основной файл открыт -> сохраняем сюда
SAVE_RETRIES = 3
SAVE_RETRY_SLEEP = 1.0
_last_snapshot_ts = 0.0
_excel_lock_notified = False
def tg_ready() -> bool:
return bool(TELEGRAM_TOKEN and TELEGRAM_CHAT_ID)
def tg_send(text: str) -> None:
"""Отправка сообщения в Telegram + лог в консоль."""
print(f"[TG] -> {text.replace('\n', ' | ')}", flush=True)
if not tg_ready():
print("[TG] Token/ChatId не заданы (пропускаю отправку).", flush=True)
return
texttry: r = requests.post( f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage", data={"chat_id": TELEGRAM_CHAT_ID, "text": text, "disable_web_page_preview": True}, timeout=10, ) if not r.ok: print(f"[TG] Ошибка отправки: {r.status_code} {r.text[:300]}", flush=True) except Exception as e: print(f"[TG] Exception: {e}", flush=True)
@dataclass
class Team:
name: str
tag: Optional[str] = None
@dataclass
class LineOU:
total: Optional[float] = None
over_odds: Optional[float] = None # decimal
under_odds: Optional[float] = None # decimal
def now_ts() -> str:
return datetime.now().strftime("%H:%M:%S")
def now_dt() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def txt(el) -> str:
if el is None:
return ""
return (el.get_attribute("textContent") or "").strip()
def safe_float(x: Optional[str]) -> Optional[float]:
if not x:
return None
x = x.strip().replace(",", ".")
try:
return float(x)
except ValueError:
return None
def hk_to_decimal(odds: Optional[float]) -> Optional[float]:
# HK 0.83 -> Decimal 1.83
if odds is None:
return None
if odds >= 1.2:
return odds
return odds + 1.0
def read_odds_decimal(span) -> Optional[float]:
"""odds2 может быть текст 1.80, а может быть пусто, но есть data-o=0.80 (HK)."""
if span is None:
return None
textt = (span.get_attribute("textContent") or "").strip() v = safe_float(t) if v is not None: return hk_to_decimal(v) v2 = safe_float(span.get_attribute("data-o")) if v2 is not None: return hk_to_decimal(v2) return None
def safe_save(wb: Workbook, path: str) -> bool:
"""
Пытаемся сохранить в основной файл.
Если Excel держит лок (Windows) -> не падаем, сохраняем в AUTOSAVE.
"""
global _excel_lock_notified
textfor i in range(1, SAVE_RETRIES + 1): try: wb.save(path) # если раньше предупреждали про лок — сбросим флаг, т.к. снова всё ок _excel_lock_notified = False return True except PermissionError: print(f"[{now_ts()}] Excel LOCKED: {path} (try {i}/{SAVE_RETRIES})", flush=True) time.sleep(SAVE_RETRY_SLEEP) except OSError as e: print(f"[{now_ts()}] Save OSError: {e} (try {i}/{SAVE_RETRIES})", flush=True) time.sleep(SAVE_RETRY_SLEEP) base, _ext = os.path.splitext(path) fallback = base + AUTOSAVE_SUFFIX try: wb.save(fallback) print(f"[{now_ts()}] Saved to AUTOSAVE: {fallback}", flush=True) # предупреждение в TG только один раз, чтобы не спамить if (not _excel_lock_notified) and tg_ready(): tg_send( "⚠️ Excel-файл открыт и заблокирован.\n" f"Не могу записать в: {os.path.abspath(path)}\n" f"Сохраняю во временный: {os.path.abspath(fallback)}\n" f"И делаю snapshots в папку: {os.path.abspath(SNAPSHOT_DIR)}" ) _excel_lock_notified = True return False except Exception as e: print(f"[{now_ts()}] AUTOSAVE failed: {e}", flush=True) if tg_ready(): tg_send(f"❌ Не могу сохранить Excel ни в основной, ни в AUTOSAVE: {e}") return False
def maybe_write_snapshot(wb: Workbook, base_path: str) -> None:
"""
Пишем снимок в отдельный файл с уникальным именем.
Такой файл можно держать открытым в Excel — бот не будет блокироваться.
"""
global _last_snapshot_ts
now = time.time()
if now - _last_snapshot_ts < SNAPSHOT_EVERY_SECONDS:
return
_last_snapshot_ts = now
texttry: Path(SNAPSHOT_DIR).mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") snap_path = os.path.join(SNAPSHOT_DIR, f"nowgoal_{stamp}.xlsx") wb.save(snap_path) print(f"[{now_ts()}] Snapshot saved: {snap_path}", flush=True) except Exception as e: print(f"[{now_ts()}] Snapshot save failed: {e}", flush=True)
OU_TOTAL_RE = re.compile(r"\b[OU]\s*([0-9]+(?:.[0-9]+)?)", re.IGNORECASE)
def read_total_from_cell(cell) -> Optional[float]:
"""Тотал берём из span.odds1, а если он пропал/пустой — выковыриваем из innerText."""
odds1 = cell.find_elements(By.CSS_SELECTOR, "span.odds1")
if odds1:
v = safe_float(txt(odds1[0]))
if v is not None:
return v
textit = (cell.get_attribute("innerText") or "").strip() m = OU_TOTAL_RE.search(it) if m: return safe_float(m.group(1)) return None
def split_team_and_tag(s: str) -> Team:
s = (s or "").strip()
if s.endswith("]") and "[" in s:
i = s.rfind("[")
name = s[:i].strip()
tag = s[i + 1:-1].strip() or None
if name:
return Team(name=name, tag=tag)
return Team(name=s, tag=None)
def parse_analysis_onclick(onclick: str) -> Optional[Tuple[int, str, str, str]]:
if not onclick or "basketInPage.analysis" not in onclick:
return None
try:
args_str = onclick[onclick.find("(") + 1: onclick.rfind(")")]
args = ast.literal_eval("(" + args_str + ")")
return int(args[0]), str(args[1]), str(args[2]), str(args[3])
except Exception:
return None
def scroll_load_all(driver, max_rounds: int = 25, pause: float = 0.35):
last = 0
stable = 0
for _ in range(max_rounds):
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(pause)
cur = len(driver.find_elements(By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]'))
if cur == last:
stable += 1
if stable >= 3:
break
else:
stable = 0
last = cur
driver.execute_script("window.scrollTo(0, 0);")
time.sleep(0.15)
def pick_band(abs_diff: float) -> Optional[str]:
for lo, hi, label in DIFF_BANDS:
if abs_diff >= lo and (hi is None or abs_diff < hi):
return label
return None
def line_complete(line: LineOU) -> bool:
return (line.total is not None and line.over_odds is not None and line.under_odds is not None)
LIVE_PAT = re.compile(r"\b\d+(?:st|nd|rd|th)\s+Qtr\b|\bOT\b|\bOvertime\b|\bLive\b", re.IGNORECASE)
TIME_PAT = re.compile(r"^\s*\d{1,2}:\d{2}\s*(?:AM|PM)?\s*$", re.IGNORECASE)
def is_finished(status: Optional[str]) -> bool:
return (status or "").strip().lower() in {"finished", "final", "ft", "ended"}
def is_halftime(status: Optional[str]) -> bool:
return "halftime" in (status or "").strip().lower()
def is_live(status: Optional[str]) -> bool:
s = (status or "").strip()
if not s:
return False
if is_finished(s) or is_halftime(s):
return True
return bool(LIVE_PAT.search(s))
def is_pregame(status: Optional[str]) -> bool:
"""
pregame = всё что НЕ live/halftime/finished и НЕ отменено/перенесено.
Pend. считаем pregame (игра задерживается).
"""
s = (status or "").strip()
if not s:
return True
if TIME_PAT.match(s):
return True
sl = s.lower()
if is_live(s) or is_halftime(s) or is_finished(s):
return False
if any(k in sl for k in ["postp", "cancel", "abandon", "void"]):
return False
if "pend" in sl:
return True
return True
def status_bucket(status: Optional[str]) -> str:
if is_halftime(status):
return "halftime"
if is_finished(status):
return "finished"
if is_live(status):
return "live"
if is_pregame(status):
return "pregame"
return "other"
def pick_best_line(current: LineOU, st_rec: Dict[str, Any], status: Optional[str]) -> LineOU:
"""
Если текущая линия неполная — берём последнюю полную из истории (или ещё более раннюю).
Приоритет: тот же bucket статуса.
"""
if line_complete(current):
return current
textbucket = status_bucket(status) hist = st_rec.get("line_hist") if not hist: return current for item in reversed(hist): if item["bucket"] == bucket and line_complete(item["line"]): return item["line"] for item in reversed(hist): if line_complete(item["line"]): return item["line"] return current
def empty_stats() -> Dict[str, Any]:
return {"bets": 0, "wins": 0, "losses": 0, "pushes": 0, "profit": 0.0}
def ensure_stats(d: Dict[str, Dict[str, Any]], key: str) -> Dict[str, Any]:
if key not in d:
d[key] = empty_stats()
return d[key]
def ensure_nested_stats(d: Dict[str, Dict[str, Dict[str, Any]]], k1: str, k2: str) -> Dict[str, Any]:
if k1 not in d:
d[k1] = {}
if k2 not in d[k1]:
d[k1][k2] = empty_stats()
return d[k1][k2]
def bet_profit(decimal_odds: float, stake: float, result: str) -> float:
if result == "push":
return 0.0
if result == "loss":
return -stake
return (decimal_odds - 1.0) * stake
def stats_line(s: Dict[str, Any]) -> str:
return f"Bets={s['bets']} W={s['wins']} L={s['losses']} P={s['pushes']} Profit={s['profit']:+.2f}"
def stats_block_extended(
stats_total: Dict[str, Any],
stats_by_band: Dict[str, Dict[str, Any]],
stats_by_league: Dict[str, Dict[str, Any]],
stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]],
focus_league: Optional[str] = None,
focus_band: Optional[str] = None,
) -> str:
lines = []
lines.append(f"TOTAL: {stats_line(stats_total)}")
for _, _, label in DIFF_BANDS:
lines.append(f"{label}: {stats_line(stats_by_band[label])}")
textif focus_league: ls = ensure_stats(stats_by_league, focus_league) lines.append("") lines.append(f"LEAGUE: {focus_league}") lines.append(stats_line(ls)) if focus_band: lbs = ensure_nested_stats(stats_by_league_band, focus_league, focus_band) lines.append(f"LEAGUE×BAND ({focus_band}): {stats_line(lbs)}") return "\n".join(lines)
BETS_HEADERS = [
"bet_uid", "match_id",
"league", "home", "away",
"band", "side",
"pre_total", "half_total", "diff", "abs_diff",
"odds", "stake",
"placed_at", "status_at_bet",
"final_total", "result", "profit", "settled_at",
]
def excel_open_or_create(path: str):
if os.path.exists(path):
wb = load_workbook(path)
else:
wb = Workbook()
text# Bets sheet if "Bets" not in wb.sheetnames: ws = wb.active ws.title = "Bets" ws.append(BETS_HEADERS) else: ws = wb["Bets"] if ws.max_row < 1: ws.append(BETS_HEADERS) else: header = [ws.cell(row=1, column=c).value for c in range(1, ws.max_column + 1)] if header[: len(BETS_HEADERS)] != BETS_HEADERS: for i, h in enumerate(BETS_HEADERS, start=1): ws.cell(row=1, column=i).value = h # Ensure other sheets for sname in ["Stats_Total", "Stats_Band", "Stats_League", "Stats_League_Band", "Monthly"]: if sname not in wb.sheetnames: wb.create_sheet(sname) # mapping bet_uid -> row ws_bets = wb["Bets"] uid_to_row: Dict[str, int] = {} for r in range(2, ws_bets.max_row + 1): uid = ws_bets.cell(row=r, column=1).value if uid: uid_to_row[str(uid)] = r safe_save(wb, path) return wb, uid_to_row
def excel_append_bet(wb: Workbook, uid_to_row: Dict[str, int], path: str, row: Dict[str, Any]) -> None:
ws = wb["Bets"]
values = [row.get(h) for h in BETS_HEADERS]
ws.append(values)
uid_to_row[str(row["bet_uid"])] = ws.max_row
safe_save(wb, path)
def excel_update_settle(
wb: Workbook,
uid_to_row: Dict[str, int],
path: str,
bet_uid: str,
final_total: int,
result: str,
profit: float,
settled_at: str,
) -> None:
ws = wb["Bets"]
r = uid_to_row.get(str(bet_uid))
if not r:
return
ws.cell(row=r, column=16).value = int(final_total)
ws.cell(row=r, column=17).value = str(result)
ws.cell(row=r, column=18).value = float(profit)
ws.cell(row=r, column=19).value = str(settled_at)
safe_save(wb, path)
def _clear_sheet_keep_header(ws, headers: Optional[list] = None):
ws.delete_rows(1, ws.max_row)
if headers:
ws.append(headers)
def excel_write_stats(
wb: Workbook,
path: str,
stats_total: Dict[str, Any],
stats_by_band: Dict[str, Dict[str, Any]],
stats_by_league: Dict[str, Dict[str, Any]],
stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]],
):
ws = wb["Stats_Total"]
_clear_sheet_keep_header(ws, ["metric", "value"])
for k in ["bets", "wins", "losses", "pushes", "profit"]:
ws.append([k, stats_total[k]])
textws = wb["Stats_Band"] _clear_sheet_keep_header(ws, ["band", "bets", "wins", "losses", "pushes", "profit"]) for _, _, band in DIFF_BANDS: s = stats_by_band[band] ws.append([band, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) ws = wb["Stats_League"] _clear_sheet_keep_header(ws, ["league", "bets", "wins", "losses", "pushes", "profit"]) for league, s in sorted(stats_by_league.items(), key=lambda kv: (-kv[1]["bets"], kv[0])): ws.append([league, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) ws = wb["Stats_League_Band"] _clear_sheet_keep_header(ws, ["league", "band", "bets", "wins", "losses", "pushes", "profit"]) rows = [] for league, bands in stats_by_league_band.items(): for band, s in bands.items(): rows.append((league, band, s)) for league, band, s in sorted(rows, key=lambda x: (-x[2]["bets"], x[0], x[1])): ws.append([league, band, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) safe_save(wb, path)
def excel_write_monthly(wb: Workbook, path: str) -> None:
ws_bets = wb["Bets"]
ws_m = wb["Monthly"]
textws_m.delete_rows(1, ws_m.max_row) ws_m.append(["month", "bets_settled", "profit", "cum_profit"]) header = [ws_bets.cell(row=1, column=c).value for c in range(1, ws_bets.max_column + 1)] def col_idx(name: str) -> int: return header.index(name) + 1 try: c_profit = col_idx("profit") c_settled_at = col_idx("settled_at") except ValueError: safe_save(wb, path) return monthly_profit: Dict[str, float] = {} monthly_bets: Dict[str, int] = {} for r in range(2, ws_bets.max_row + 1): settled_at = ws_bets.cell(row=r, column=c_settled_at).value prof = ws_bets.cell(row=r, column=c_profit).value if not settled_at or prof is None: continue month = str(settled_at)[:7] monthly_profit[month] = monthly_profit.get(month, 0.0) + float(prof) monthly_bets[month] = monthly_bets.get(month, 0) + 1 cum = 0.0 for month in sorted(monthly_profit.keys()): p = float(monthly_profit[month]) b = int(monthly_bets.get(month, 0)) cum += p ws_m.append([month, b, p, cum]) ws_m._charts = [] if ws_m.max_row >= 2: chart = LineChart() chart.title = "Monthly Profit Trend" chart.y_axis.title = "Profit" chart.x_axis.title = "Month" data = Reference(ws_m, min_col=3, max_col=4, min_row=1, max_row=ws_m.max_row) cats = Reference(ws_m, min_col=1, min_row=2, max_row=ws_m.max_row) chart.add_data(data, titles_from_data=True) chart.set_categories(cats) ws_m.add_chart(chart, "F2") safe_save(wb, path)
def make_driver(headless: bool = True):
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless=new")
options.add_argument("--window-size=1400,900")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument(
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
)
return webdriver.Chrome(options=options)
def _parse_ou_for_mid(driver, mid: int) -> LineOU:
over_total = under_total = None
over_odds = under_odds = None
textcells = [] ht = driver.find_elements(By.ID, f"ht_{mid}") gt = driver.find_elements(By.ID, f"gt_{mid}") if ht: cells.append(ht[0]) if gt: cells.append(gt[0]) if not cells: cells = driver.find_elements(By.CSS_SELECTOR, f'td.ouOdds[onclick*="oddscomp({mid}"]') for cell in cells: raw = (cell.get_attribute("innerText") or txt(cell)).strip().upper() odds2 = cell.find_elements(By.CSS_SELECTOR, "span.odds2") total_val = read_total_from_cell(cell) odds_val = read_odds_decimal(odds2[0]) if odds2 else None if raw.startswith("O"): over_total = total_val over_odds = odds_val elif raw.startswith("U"): under_total = total_val under_odds = odds_val line = LineOU(over_odds=over_odds, under_odds=under_odds) if over_total is not None and under_total is not None: if abs(over_total - under_total) < 1e-9: line.total = over_total else: line.total = (over_total + under_total) / 2.0 else: line.total = over_total if over_total is not None else under_total return line
def _get_snapshot_once(driver, do_scroll: bool) -> Dict[int, Dict[str, Any]]:
wait = WebDriverWait(driver, 30)
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]')))
if do_scroll:
scroll_load_all(driver)
textgames: Dict[int, Dict[str, Any]] = {} analysis_tds = driver.find_elements(By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]') for td in analysis_tds: parsed = parse_analysis_onclick(td.get_attribute("onclick") or "") if not parsed: continue mid, home_s, away_s, league_s = parsed games[mid] = { "match_id": mid, "league": league_s, "home": split_team_and_tag(home_s), "away": split_team_and_tag(away_s), "status": None, "line": LineOU(), "home_score": None, "away_score": None, } for mid in list(games.keys()): status_els = driver.find_elements(By.ID, f"zt_{mid}") if status_els: games[mid]["status"] = txt(status_els[0]) or None games[mid]["line"] = _parse_ou_for_mid(driver, mid) h_els = driver.find_elements(By.ID, f"hzf_{mid}") g_els = driver.find_elements(By.ID, f"gzf_{mid}") if h_els and g_els: hs = safe_float(txt(h_els[0])) gs = safe_float(txt(g_els[0])) games[mid]["home_score"] = int(hs) if hs is not None else None games[mid]["away_score"] = int(gs) if gs is not None else None return games
def get_snapshot(driver, do_scroll: bool, retries: int = 3) -> Dict[int, Dict[str, Any]]:
for attempt in range(1, retries + 1):
try:
return _get_snapshot_once(driver, do_scroll=do_scroll)
except StaleElementReferenceException:
print(f"[{now_ts()}] snapshot stale -> retry {attempt}/{retries}", flush=True)
time.sleep(0.25)
return _get_snapshot_once(driver, do_scroll=do_scroll)
def main():
print(f"[{now_ts()}] Starting monitor. poll={POLL_SECONDS}s stake={STAKE}", flush=True)
print(f"[{now_ts()}] Bands: " + ", ".join([lbl for _, _, lbl in DIFF_BANDS]), flush=True)
print(f"[{now_ts()}] Excel: {os.path.abspath(EXCEL_PATH)}", flush=True)
print(f"[{now_ts()}] Snapshots dir: {os.path.abspath(SNAPSHOT_DIR)} every {SNAPSHOT_EVERY_SECONDS}s", flush=True)
print(f"[{now_ts()}] Halftime wait: {HALFTIME_WAIT_SECONDS}s (first halftime only)", flush=True)
textwb, uid_to_row = excel_open_or_create(EXCEL_PATH) tg_send( "🟢 NowGoal monitor STARTED\n" f"poll={POLL_SECONDS}s | stake={STAKE}\n" f"bands: " + ", ".join([lbl for _, _, lbl in DIFF_BANDS]) + "\n" f"halftime_wait={HALFTIME_WAIT_SECONDS}s (first halftime only)\n" f"time={now_dt()}\n" f"excel(main)={os.path.abspath(EXCEL_PATH)}\n" f"snapshots={os.path.abspath(SNAPSHOT_DIR)}" ) driver = make_driver(headless=HEADLESS) state: Dict[int, Dict[str, Any]] = {} # stats (начинаются с нуля — как ты и сказал) stats_total = empty_stats() stats_by_band = {label: empty_stats() for _, _, label in DIFF_BANDS} stats_by_league: Dict[str, Dict[str, Any]] = {} stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]] = {} loop_n = 0 try: driver.get(URL) WebDriverWait(driver, 30).until( EC.presence_of_element_located((By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]')) ) while True: loop_n += 1 t0 = time.time() do_scroll = (loop_n == 1) or (loop_n % SCROLL_EVERY_N_LOOPS == 0) try: driver.refresh() time.sleep(0.35) except Exception as e: print(f"[{now_ts()}] refresh error: {e} -> reopen", flush=True) driver.get(URL) try: snap = get_snapshot(driver, do_scroll=do_scroll, retries=3) except Exception as e: print(f"[{now_ts()}] snapshot error: {e}", flush=True) time.sleep(POLL_SECONDS) continue # summary counts cnt_total = len(snap) cnt_live = cnt_half = cnt_finished = 0 for g in snap.values(): st = g.get("status") if is_finished(st): cnt_finished += 1 elif is_halftime(st): cnt_half += 1 elif is_live(st): cnt_live += 1 print( f"[{now_ts()}] loop={loop_n} games={cnt_total} live={cnt_live} halftime={cnt_half} finished={cnt_finished} " f"| TOTAL profit={stats_total['profit']:+.2f} bets={stats_total['bets']}", flush=True ) for mid, g in snap.items(): st_rec = state.setdefault(mid, { "league": g["league"], "home": g["home"], "away": g["away"], "pregame_total_last": None, "pregame_total_frozen": None, "bet": None, "bet_uid": None, "settled": False, "last_status": None, "line_hist": deque(maxlen=10), # ✅ NEW: halftime tracking "halftime_count": 0, # сколько раз вошли в halftime (переходом) "halftime_first_ts": None, # time.time() первого появления halftime }) status = g.get("status") line_raw: LineOU = g.get("line", LineOU()) line = pick_best_line(line_raw, st_rec, status) if line_complete(line): st_rec["line_hist"].append({ "ts": now_dt(), "bucket": status_bucket(status), "line": LineOU(line.total, line.over_odds, line.under_odds), }) # keep previous status for transitions prev_status = st_rec["last_status"] if status != prev_status: st_rec["last_status"] = status if status: parts = [] if line.total is not None: parts.append(f"OU={line.total:g}") if line.over_odds is not None: parts.append(f"O@{line.over_odds:.2f}") if line.under_odds is not None: parts.append(f"U@{line.under_odds:.2f}") line_str = " | " + (" ".join(parts) if parts else "OU=-") print( f" [{now_ts()}] status id={mid} | {st_rec['home'].name} vs {st_rec['away'].name} | {status}{line_str}", flush=True ) # ✅ NEW: detect FIRST entry into halftime (transition into halftime) if is_halftime(status) and not is_halftime(prev_status): st_rec["halftime_count"] += 1 if st_rec["halftime_count"] == 1: st_rec["halftime_first_ts"] = time.time() print( f" [{now_ts()}] FIRST HALFTIME detected id={mid} -> waiting {HALFTIME_WAIT_SECONDS}s for odds to stabilize", flush=True ) else: # second+ halftime (often OT) -> ignore for betting logic print( f" [{now_ts()}] HALFTIME повторно (#{st_rec['halftime_count']}) id={mid} -> IGNORE (OT/extra break)", flush=True ) # 1) pregame -> сохраняем последний pregame total if is_pregame(status): if line.total is not None: st_rec["pregame_total_last"] = float(line.total) # 2) first live -> freeze pregame total if is_live(status) and st_rec["pregame_total_frozen"] is None and st_rec["pregame_total_last"] is not None: st_rec["pregame_total_frozen"] = float(st_rec["pregame_total_last"]) print(f" [{now_ts()}] pregame frozen id={mid} pre_total={st_rec['pregame_total_frozen']:g}", flush=True) # 3) halftime -> bet (✅ only on FIRST halftime + ✅ wait 15s) if ( is_halftime(status) and st_rec["bet"] is None and st_rec["pregame_total_frozen"] is not None and st_rec["halftime_count"] == 1 # ✅ only first halftime ): # ✅ wait after first halftime appears ht_ts = st_rec.get("halftime_first_ts") if ht_ts is None: st_rec["halftime_first_ts"] = time.time() ht_ts = st_rec["halftime_first_ts"] if (time.time() - float(ht_ts)) < HALFTIME_WAIT_SECONDS: continue pre_total = float(st_rec["pregame_total_frozen"]) half_total = line.total if half_total is None: continue diff = float(half_total) - pre_total abs_diff = abs(diff) band = pick_band(abs_diff) if band is None: continue if diff > 0: side = "UNDER" odds = line.under_odds else: side = "OVER" odds = line.over_odds if odds is None: continue bet_uid = f"{mid}-{int(time.time()*1000)}" st_rec["bet_uid"] = bet_uid st_rec["bet"] = { "band": band, "side": side, "line": float(half_total), "odds": float(odds), "stake": float(STAKE), "pre_total": float(pre_total), "half_total": float(half_total), "diff": float(diff), "abs_diff": float(abs_diff), "placed_at": now_dt(), "status_at_bet": status, } league = st_rec["league"] stats_total["bets"] += 1 stats_by_band[band]["bets"] += 1 ensure_stats(stats_by_league, league)["bets"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["bets"] += 1 excel_append_bet( wb, uid_to_row, EXCEL_PATH, { "bet_uid": bet_uid, "match_id": mid, "league": league, "home": st_rec["home"].name, "away": st_rec["away"].name, "band": band, "side": side, "pre_total": float(pre_total), "half_total": float(half_total), "diff": float(diff), "abs_diff": float(abs_diff), "odds": float(odds), "stake": float(STAKE), "placed_at": st_rec["bet"]["placed_at"], "status_at_bet": status, "final_total": None, "result": None, "profit": None, "settled_at": None, } ) excel_write_stats(wb, EXCEL_PATH, stats_total, stats_by_band, stats_by_league, stats_by_league_band) excel_write_monthly(wb, EXCEL_PATH) print( f" [{now_ts()}] BET PLACED [{band}] id={mid} {side} {half_total:g} @ {odds:.2f} " f"(pre {pre_total:g}, diff {diff:+g})", flush=True ) block = stats_block_extended( stats_total, stats_by_band, stats_by_league, stats_by_league_band, focus_league=league, focus_band=band ) tg_send( f"✅ BET PLACED (TOTAL #{stats_total['bets']}) [{band}]\n" f"{league}\n" f"{st_rec['home'].name} vs {st_rec['away'].name}\n" f"Status: {status}\n" f"(waited {HALFTIME_WAIT_SECONDS}s after first halftime)\n" f"Pre total: {pre_total:g}\n" f"Halftime line: {half_total:g} (diff {diff:+g})\n" f"BET: {side} {half_total:g} @ {float(odds):.2f}\n" f"Stake: {STAKE}\n" f"\n📊 STATS\n{block}\n" f"id={mid}\n" f"bet_uid={bet_uid}" ) # 4) finished -> settle bet if is_finished(status) and st_rec["bet"] is not None and not st_rec["settled"]: hs = g.get("home_score") gs = g.get("away_score") if hs is None or gs is None: continue final_total = int(hs) + int(gs) band = st_rec["bet"]["band"] league = st_rec["league"] side = st_rec["bet"]["side"] line_bet = float(st_rec["bet"]["line"]) odds = float(st_rec["bet"]["odds"]) stake = float(st_rec["bet"]["stake"]) if abs(final_total - line_bet) < 1e-9: result = "push" stats_total["pushes"] += 1 stats_by_band[band]["pushes"] += 1 ensure_stats(stats_by_league, league)["pushes"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["pushes"] += 1 else: win = (final_total < line_bet) if side == "UNDER" else (final_total > line_bet) if win: result = "win" stats_total["wins"] += 1 stats_by_band[band]["wins"] += 1 ensure_stats(stats_by_league, league)["wins"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["wins"] += 1 else: result = "loss" stats_total["losses"] += 1 stats_by_band[band]["losses"] += 1 ensure_stats(stats_by_league, league)["losses"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["losses"] += 1 prof = bet_profit(odds, stake, result) stats_total["profit"] += prof stats_by_band[band]["profit"] += prof ensure_stats(stats_by_league, league)["profit"] += prof ensure_nested_stats(stats_by_league_band, league, band)["profit"] += prof st_rec["settled"] = True bet_uid = st_rec.get("bet_uid") if bet_uid: excel_update_settle( wb, uid_to_row, EXCEL_PATH, bet_uid, final_total, result.upper(), float(prof), now_dt() ) excel_write_stats(wb, EXCEL_PATH, stats_total, stats_by_band, stats_by_league, stats_by_league_band) excel_write_monthly(wb, EXCEL_PATH) block = stats_block_extended( stats_total, stats_by_band, stats_by_league, stats_by_league_band, focus_league=league, focus_band=band ) print( f" [{now_ts()}] BET SETTLED [{band}] id={mid} {result.upper()} final_total={final_total} " f"bet={side} {line_bet:g} @ {odds:.2f} P/L={prof:+.2f}\n{block}", flush=True ) tg_send( f"🏁 BET RESULT: {result.upper()} [{band}]\n" f"{league}\n" f"{st_rec['home'].name} {hs} : {gs} {st_rec['away'].name}\n" f"Final total: {final_total}\n" f"Bet: {side} {line_bet:g} @ {odds:.2f} | stake {stake}\n" f"P/L: {prof:+.2f}\n\n" f"📊 STATS\n{block}\n" f"id={mid}\n" f"bet_uid={bet_uid}" ) # делаем snapshot раз в SNAPSHOT_EVERY_SECONDS (можно смотреть в Excel без блокировок) maybe_write_snapshot(wb, EXCEL_PATH) elapsed = time.time() - t0 time.sleep(max(0.2, POLL_SECONDS - elapsed)) finally: driver.quit() print(f"[{now_ts()}] Driver closed.", flush=True)
if name == "main":
main() тогда давай переделай этот код как сам знаешь, по плюсовой стратегии, и вышли мне полный вариант, и пусть файл excel будет создан уже другой, чтобы там в итоге потом смотреть статистику
почему после первого пуска не пришло сообщение втелеграм, переделай и вышли полный код
Причин, почему не пришло стартовое сообщение в Telegram у твоего скрипта, обычно 3:
r.ok и печатаешь урезанный текст.Ниже — полный переделанный код под плюсовую стратегию из твоего Excel + улучшенная отправка в Telegram (ретраи + подробный лог + ранний “startup ping”) + новый Excel-файл (по умолчанию отдельный, чтобы не смешивать со старым).
Ставит только:
ВАЖНО: я не вставляю токен в код (он секретный). Задай переменные окружения
TELEGRAM_TOKENиTELEGRAM_CHAT_ID.
Пример (Windows PowerShell):
setx TELEGRAM_TOKEN "123:ABC"
setx TELEGRAM_CHAT_ID "6667940236"
pythonimport re import ast import os import time from dataclasses import dataclass from typing import Optional, Dict, Any, Tuple from datetime import datetime from collections import deque from pathlib import Path import requests from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import StaleElementReferenceException from openpyxl import Workbook, load_workbook from openpyxl.chart import LineChart, Reference # ===================== CONFIG ===================== URL = "https://www.nowgoal.com/basketball" POLL_SECONDS = 5 STAKE = 1.0 HEADLESS = True # Wait after FIRST halftime appears (to stabilize odds/lines) HALFTIME_WAIT_SECONDS = 15 DIFF_BANDS = [ (20.0, 30.0, "20-30"), (30.0, 40.0, "30-40"), (40.0, 50.0, "40-50"), (50.0, None, "50+"), ] SCROLL_EVERY_N_LOOPS = 12 # ✅ NEW: separate output file by default (so you don't mix with old runs) RUN_TAG = os.getenv("RUN_TAG", datetime.now().strftime("%Y%m%d")) EXCEL_PATH = os.getenv("EXCEL_PATH", f"nowgoal_monitor_PLUS_{RUN_TAG}.xlsx") # ✅ Telegram from env only (safer) TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "").strip() TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "").strip() TG_TIMEOUT = 15 TG_RETRIES = 3 TG_RETRY_SLEEP = 2.0 TG_DEBUG = True # show detailed Telegram errors in console # ====== WINDOWS-EXCEL SAFE SAVE + SNAPSHOTS ====== SNAPSHOT_DIR = "snapshots" SNAPSHOT_EVERY_SECONDS = 60 AUTOSAVE_SUFFIX = "_AUTOSAVE.xlsx" SAVE_RETRIES = 3 SAVE_RETRY_SLEEP = 1.0 _last_snapshot_ts = 0.0 _excel_lock_notified = False # ===================== STRATEGY (PLUS) ===================== # Based on your historical excel: # - only UNDER # - only band 20-30 # - abs_diff in [20,22) or [26,30) USE_STRATEGY_FILTER = True STRATEGY_RULES = [ {"band": "20-30", "side": "UNDER", "abs_min": 20.0, "abs_max": 22.0}, {"band": "20-30", "side": "UNDER", "abs_min": 26.0, "abs_max": 30.0}, ] # Optional: league blacklist (from your sample: NCAA Women looked bad) LEAGUE_BLACKLIST_RE = [ r"\bNational Committee Association America W\b", # NCAA Women ] LEAGUE_WHITELIST_RE = [] # keep empty unless you want strict allow-only # ===================== TELEGRAM ===================== def tg_ready() -> bool: return bool(TELEGRAM_TOKEN) and bool(TELEGRAM_CHAT_ID) def tg_send(text: str) -> bool: """ Telegram send with retries + detailed logging. Returns True if sent. """ print(f"[TG] -> {text.replace('\n', ' | ')}", flush=True) if not tg_ready(): print("[TG] SKIP: TELEGRAM_TOKEN / TELEGRAM_CHAT_ID not set.", flush=True) return False url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage" payload = { "chat_id": TELEGRAM_CHAT_ID, "text": text, "disable_web_page_preview": True, } last_err = None for attempt in range(1, TG_RETRIES + 1): try: r = requests.post(url, data=payload, timeout=TG_TIMEOUT) if r.ok: return True # Telegram returned error last_err = f"HTTP {r.status_code}: {r.text}" if TG_DEBUG: print(f"[TG] ERROR attempt {attempt}/{TG_RETRIES}: {last_err}", flush=True) except Exception as e: last_err = repr(e) if TG_DEBUG: print(f"[TG] EXCEPTION attempt {attempt}/{TG_RETRIES}: {last_err}", flush=True) time.sleep(TG_RETRY_SLEEP) print(f"[TG] FAILED after {TG_RETRIES} tries: {last_err}", flush=True) return False def tg_startup_ping() -> None: """ Send a minimal startup ping immediately, before webdriver/excel heavy ops. Helps detect token/chat_id issues early. """ tg_send(f"🟢 START ping | time={now_dt()} | excel={os.path.abspath(EXCEL_PATH)}") # ===================== MODELS ===================== @dataclass class Team: name: str tag: Optional[str] = None @dataclass class LineOU: total: Optional[float] = None over_odds: Optional[float] = None # decimal under_odds: Optional[float] = None # decimal # ===================== HELPERS ===================== def now_ts() -> str: return datetime.now().strftime("%H:%M:%S") def now_dt() -> str: return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def txt(el) -> str: if el is None: return "" return (el.get_attribute("textContent") or "").strip() def safe_float(x: Optional[str]) -> Optional[float]: if not x: return None x = x.strip().replace(",", ".") try: return float(x) except ValueError: return None def hk_to_decimal(odds: Optional[float]) -> Optional[float]: # HK 0.83 -> Decimal 1.83 if odds is None: return None if odds >= 1.2: return odds return odds + 1.0 def read_odds_decimal(span) -> Optional[float]: """odds2 может быть текст 1.80, а может быть пусто, но есть data-o=0.80 (HK).""" if span is None: return None t = (span.get_attribute("textContent") or "").strip() v = safe_float(t) if v is not None: return hk_to_decimal(v) v2 = safe_float(span.get_attribute("data-o")) if v2 is not None: return hk_to_decimal(v2) return None # -------- Excel safe save + snapshots (Windows) -------- def safe_save(wb: Workbook, path: str) -> bool: """ Try to save to main file. If locked by Excel (Windows) -> save to AUTOSAVE and notify once in TG. """ global _excel_lock_notified for i in range(1, SAVE_RETRIES + 1): try: wb.save(path) _excel_lock_notified = False return True except PermissionError: print(f"[{now_ts()}] Excel LOCKED: {path} (try {i}/{SAVE_RETRIES})", flush=True) time.sleep(SAVE_RETRY_SLEEP) except OSError as e: print(f"[{now_ts()}] Save OSError: {e} (try {i}/{SAVE_RETRIES})", flush=True) time.sleep(SAVE_RETRY_SLEEP) base, _ext = os.path.splitext(path) fallback = base + AUTOSAVE_SUFFIX try: wb.save(fallback) print(f"[{now_ts()}] Saved to AUTOSAVE: {fallback}", flush=True) if (not _excel_lock_notified) and tg_ready(): tg_send( "⚠️ Excel-файл открыт и заблокирован.\n" f"Не могу записать в: {os.path.abspath(path)}\n" f"Сохраняю во временный: {os.path.abspath(fallback)}\n" f"И делаю snapshots в папку: {os.path.abspath(SNAPSHOT_DIR)}" ) _excel_lock_notified = True return False except Exception as e: print(f"[{now_ts()}] AUTOSAVE failed: {e}", flush=True) if tg_ready(): tg_send(f"❌ Не могу сохранить Excel ни в основной, ни в AUTOSAVE: {e}") return False def maybe_write_snapshot(wb: Workbook, base_path: str) -> None: global _last_snapshot_ts now = time.time() if now - _last_snapshot_ts < SNAPSHOT_EVERY_SECONDS: return _last_snapshot_ts = now try: Path(SNAPSHOT_DIR).mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") snap_path = os.path.join(SNAPSHOT_DIR, f"nowgoal_PLUS_{stamp}.xlsx") wb.save(snap_path) print(f"[{now_ts()}] Snapshot saved: {snap_path}", flush=True) except Exception as e: print(f"[{now_ts()}] Snapshot save failed: {e}", flush=True) OU_TOTAL_RE = re.compile(r"\b[OU]\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE) def read_total_from_cell(cell) -> Optional[float]: odds1 = cell.find_elements(By.CSS_SELECTOR, "span.odds1") if odds1: v = safe_float(txt(odds1[0])) if v is not None: return v it = (cell.get_attribute("innerText") or "").strip() m = OU_TOTAL_RE.search(it) if m: return safe_float(m.group(1)) return None def split_team_and_tag(s: str) -> Team: s = (s or "").strip() if s.endswith("]") and "[" in s: i = s.rfind("[") name = s[:i].strip() tag = s[i + 1:-1].strip() or None if name: return Team(name=name, tag=tag) return Team(name=s, tag=None) def parse_analysis_onclick(onclick: str) -> Optional[Tuple[int, str, str, str]]: if not onclick or "basketInPage.analysis" not in onclick: return None try: args_str = onclick[onclick.find("(") + 1: onclick.rfind(")")] args = ast.literal_eval("(" + args_str + ")") return int(args[0]), str(args[1]), str(args[2]), str(args[3]) except Exception: return None def scroll_load_all(driver, max_rounds: int = 25, pause: float = 0.35): last = 0 stable = 0 for _ in range(max_rounds): driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(pause) cur = len(driver.find_elements(By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]')) if cur == last: stable += 1 if stable >= 3: break else: stable = 0 last = cur driver.execute_script("window.scrollTo(0, 0);") time.sleep(0.15) def pick_band(abs_diff: float) -> Optional[str]: for lo, hi, label in DIFF_BANDS: if abs_diff >= lo and (hi is None or abs_diff < hi): return label return None def line_complete(line: LineOU) -> bool: return (line.total is not None and line.over_odds is not None and line.under_odds is not None) # ===================== STATUS ===================== LIVE_PAT = re.compile(r"\b\d+(?:st|nd|rd|th)\s+Qtr\b|\bOT\b|\bOvertime\b|\bLive\b", re.IGNORECASE) TIME_PAT = re.compile(r"^\s*\d{1,2}:\d{2}\s*(?:AM|PM)?\s*$", re.IGNORECASE) def is_finished(status: Optional[str]) -> bool: return (status or "").strip().lower() in {"finished", "final", "ft", "ended"} def is_halftime(status: Optional[str]) -> bool: return "halftime" in (status or "").strip().lower() def is_live(status: Optional[str]) -> bool: s = (status or "").strip() if not s: return False if is_finished(s) or is_halftime(s): return True return bool(LIVE_PAT.search(s)) def is_pregame(status: Optional[str]) -> bool: s = (status or "").strip() if not s: return True if TIME_PAT.match(s): return True sl = s.lower() if is_live(s) or is_halftime(s) or is_finished(s): return False if any(k in sl for k in ["postp", "cancel", "abandon", "void"]): return False if "pend" in sl: return True return True def status_bucket(status: Optional[str]) -> str: if is_halftime(status): return "halftime" if is_finished(status): return "finished" if is_live(status): return "live" if is_pregame(status): return "pregame" return "other" def pick_best_line(current: LineOU, st_rec: Dict[str, Any], status: Optional[str]) -> LineOU: if line_complete(current): return current bucket = status_bucket(status) hist = st_rec.get("line_hist") if not hist: return current for item in reversed(hist): if item["bucket"] == bucket and line_complete(item["line"]): return item["line"] for item in reversed(hist): if line_complete(item["line"]): return item["line"] return current # ===================== STRATEGY FILTERS ===================== def league_allowed(league: str) -> bool: lg = league or "" if LEAGUE_WHITELIST_RE: ok = any(re.search(p, lg, re.I) for p in LEAGUE_WHITELIST_RE) if not ok: return False if any(re.search(p, lg, re.I) for p in LEAGUE_BLACKLIST_RE): return False return True def strategy_allows(league: str, band: str, side: str, abs_diff: float) -> bool: if not USE_STRATEGY_FILTER: return True # league gate first if not league_allowed(league): return False for r in STRATEGY_RULES: if r.get("band") and band != r["band"]: continue if r.get("side") and side != r["side"]: continue abs_min = r.get("abs_min") abs_max = r.get("abs_max") if abs_min is not None and abs_diff < float(abs_min): continue if abs_max is not None and abs_diff >= float(abs_max): continue return True return False # ===================== STATS ===================== def empty_stats() -> Dict[str, Any]: return {"bets": 0, "wins": 0, "losses": 0, "pushes": 0, "profit": 0.0} def ensure_stats(d: Dict[str, Dict[str, Any]], key: str) -> Dict[str, Any]: if key not in d: d[key] = empty_stats() return d[key] def ensure_nested_stats(d: Dict[str, Dict[str, Dict[str, Any]]], k1: str, k2: str) -> Dict[str, Any]: if k1 not in d: d[k1] = {} if k2 not in d[k1]: d[k1][k2] = empty_stats() return d[k1][k2] def bet_profit(decimal_odds: float, stake: float, result: str) -> float: if result == "push": return 0.0 if result == "loss": return -stake return (decimal_odds - 1.0) * stake def stats_line(s: Dict[str, Any]) -> str: return f"Bets={s['bets']} W={s['wins']} L={s['losses']} P={s['pushes']} Profit={s['profit']:+.2f}" def stats_block_extended( stats_total: Dict[str, Any], stats_by_band: Dict[str, Dict[str, Any]], stats_by_league: Dict[str, Dict[str, Any]], stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]], focus_league: Optional[str] = None, focus_band: Optional[str] = None, ) -> str: lines = [] lines.append(f"TOTAL: {stats_line(stats_total)}") for _, _, label in DIFF_BANDS: lines.append(f"{label}: {stats_line(stats_by_band[label])}") if focus_league: ls = ensure_stats(stats_by_league, focus_league) lines.append("") lines.append(f"LEAGUE: {focus_league}") lines.append(stats_line(ls)) if focus_band: lbs = ensure_nested_stats(stats_by_league_band, focus_league, focus_band) lines.append(f"LEAGUE×BAND ({focus_band}): {stats_line(lbs)}") return "\n".join(lines) # ===================== EXCEL STORAGE ===================== BETS_HEADERS = [ "bet_uid", "match_id", "league", "home", "away", "band", "side", "pre_total", "half_total", "diff", "abs_diff", "odds", "stake", "placed_at", "status_at_bet", "final_total", "result", "profit", "settled_at", ] def excel_open_or_create(path: str): if os.path.exists(path): wb = load_workbook(path) else: wb = Workbook() if "Bets" not in wb.sheetnames: ws = wb.active ws.title = "Bets" ws.append(BETS_HEADERS) else: ws = wb["Bets"] if ws.max_row < 1: ws.append(BETS_HEADERS) else: header = [ws.cell(row=1, column=c).value for c in range(1, ws.max_column + 1)] if header[: len(BETS_HEADERS)] != BETS_HEADERS: for i, h in enumerate(BETS_HEADERS, start=1): ws.cell(row=1, column=i).value = h for sname in ["Stats_Total", "Stats_Band", "Stats_League", "Stats_League_Band", "Monthly"]: if sname not in wb.sheetnames: wb.create_sheet(sname) ws_bets = wb["Bets"] uid_to_row: Dict[str, int] = {} for r in range(2, ws_bets.max_row + 1): uid = ws_bets.cell(row=r, column=1).value if uid: uid_to_row[str(uid)] = r safe_save(wb, path) return wb, uid_to_row def excel_append_bet(wb: Workbook, uid_to_row: Dict[str, int], path: str, row: Dict[str, Any]) -> None: ws = wb["Bets"] values = [row.get(h) for h in BETS_HEADERS] ws.append(values) uid_to_row[str(row["bet_uid"])] = ws.max_row safe_save(wb, path) def excel_update_settle( wb: Workbook, uid_to_row: Dict[str, int], path: str, bet_uid: str, final_total: int, result: str, profit: float, settled_at: str, ) -> None: ws = wb["Bets"] r = uid_to_row.get(str(bet_uid)) if not r: return ws.cell(row=r, column=16).value = int(final_total) ws.cell(row=r, column=17).value = str(result) ws.cell(row=r, column=18).value = float(profit) ws.cell(row=r, column=19).value = str(settled_at) safe_save(wb, path) def _clear_sheet_keep_header(ws, headers: Optional[list] = None): ws.delete_rows(1, ws.max_row) if headers: ws.append(headers) def excel_write_stats( wb: Workbook, path: str, stats_total: Dict[str, Any], stats_by_band: Dict[str, Dict[str, Any]], stats_by_league: Dict[str, Dict[str, Any]], stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]], ): ws = wb["Stats_Total"] _clear_sheet_keep_header(ws, ["metric", "value"]) for k in ["bets", "wins", "losses", "pushes", "profit"]: ws.append([k, stats_total[k]]) ws = wb["Stats_Band"] _clear_sheet_keep_header(ws, ["band", "bets", "wins", "losses", "pushes", "profit"]) for _, _, band in DIFF_BANDS: s = stats_by_band[band] ws.append([band, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) ws = wb["Stats_League"] _clear_sheet_keep_header(ws, ["league", "bets", "wins", "losses", "pushes", "profit"]) for league, s in sorted(stats_by_league.items(), key=lambda kv: (-kv[1]["bets"], kv[0])): ws.append([league, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) ws = wb["Stats_League_Band"] _clear_sheet_keep_header(ws, ["league", "band", "bets", "wins", "losses", "pushes", "profit"]) rows = [] for league, bands in stats_by_league_band.items(): for band, s in bands.items(): rows.append((league, band, s)) for league, band, s in sorted(rows, key=lambda x: (-x[2]["bets"], x[0], x[1])): ws.append([league, band, s["bets"], s["wins"], s["losses"], s["pushes"], float(s["profit"])]) safe_save(wb, path) def excel_write_monthly(wb: Workbook, path: str) -> None: ws_bets = wb["Bets"] ws_m = wb["Monthly"] ws_m.delete_rows(1, ws_m.max_row) ws_m.append(["month", "bets_settled", "profit", "cum_profit"]) header = [ws_bets.cell(row=1, column=c).value for c in range(1, ws_bets.max_column + 1)] def col_idx(name: str) -> int: return header.index(name) + 1 try: c_profit = col_idx("profit") c_settled_at = col_idx("settled_at") except ValueError: safe_save(wb, path) return monthly_profit: Dict[str, float] = {} monthly_bets: Dict[str, int] = {} for r in range(2, ws_bets.max_row + 1): settled_at = ws_bets.cell(row=r, column=c_settled_at).value prof = ws_bets.cell(row=r, column=c_profit).value if not settled_at or prof is None: continue month = str(settled_at)[:7] monthly_profit[month] = monthly_profit.get(month, 0.0) + float(prof) monthly_bets[month] = monthly_bets.get(month, 0) + 1 cum = 0.0 for month in sorted(monthly_profit.keys()): p = float(monthly_profit[month]) b = int(monthly_bets.get(month, 0)) cum += p ws_m.append([month, b, p, cum]) ws_m._charts = [] if ws_m.max_row >= 2: chart = LineChart() chart.title = "Monthly Profit Trend" chart.y_axis.title = "Profit" chart.x_axis.title = "Month" data = Reference(ws_m, min_col=3, max_col=4, min_row=1, max_row=ws_m.max_row) cats = Reference(ws_m, min_col=1, min_row=2, max_row=ws_m.max_row) chart.add_data(data, titles_from_data=True) chart.set_categories(cats) ws_m.add_chart(chart, "F2") safe_save(wb, path) # ===================== DRIVER ===================== def make_driver(headless: bool = True): options = webdriver.ChromeOptions() if headless: options.add_argument("--headless=new") options.add_argument("--window-size=1400,900") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument( "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" ) return webdriver.Chrome(options=options) # ===================== SNAPSHOT PARSER ===================== def _parse_ou_for_mid(driver, mid: int) -> LineOU: over_total = under_total = None over_odds = under_odds = None cells = [] ht = driver.find_elements(By.ID, f"ht_{mid}") gt = driver.find_elements(By.ID, f"gt_{mid}") if ht: cells.append(ht[0]) if gt: cells.append(gt[0]) if not cells: cells = driver.find_elements(By.CSS_SELECTOR, f'td.ouOdds[onclick*="oddscomp({mid}"]') for cell in cells: raw = (cell.get_attribute("innerText") or txt(cell)).strip().upper() odds2 = cell.find_elements(By.CSS_SELECTOR, "span.odds2") total_val = read_total_from_cell(cell) odds_val = read_odds_decimal(odds2[0]) if odds2 else None if raw.startswith("O"): over_total = total_val over_odds = odds_val elif raw.startswith("U"): under_total = total_val under_odds = odds_val line = LineOU(over_odds=over_odds, under_odds=under_odds) if over_total is not None and under_total is not None: if abs(over_total - under_total) < 1e-9: line.total = over_total else: line.total = (over_total + under_total) / 2.0 else: line.total = over_total if over_total is not None else under_total return line def _get_snapshot_once(driver, do_scroll: bool) -> Dict[int, Dict[str, Any]]: wait = WebDriverWait(driver, 30) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]'))) if do_scroll: scroll_load_all(driver) games: Dict[int, Dict[str, Any]] = {} analysis_tds = driver.find_elements(By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]') for td in analysis_tds: parsed = parse_analysis_onclick(td.get_attribute("onclick") or "") if not parsed: continue mid, home_s, away_s, league_s = parsed games[mid] = { "match_id": mid, "league": league_s, "home": split_team_and_tag(home_s), "away": split_team_and_tag(away_s), "status": None, "line": LineOU(), "home_score": None, "away_score": None, } for mid in list(games.keys()): status_els = driver.find_elements(By.ID, f"zt_{mid}") if status_els: games[mid]["status"] = txt(status_els[0]) or None games[mid]["line"] = _parse_ou_for_mid(driver, mid) h_els = driver.find_elements(By.ID, f"hzf_{mid}") g_els = driver.find_elements(By.ID, f"gzf_{mid}") if h_els and g_els: hs = safe_float(txt(h_els[0])) gs = safe_float(txt(g_els[0])) games[mid]["home_score"] = int(hs) if hs is not None else None games[mid]["away_score"] = int(gs) if gs is not None else None return games def get_snapshot(driver, do_scroll: bool, retries: int = 3) -> Dict[int, Dict[str, Any]]: for attempt in range(1, retries + 1): try: return _get_snapshot_once(driver, do_scroll=do_scroll) except StaleElementReferenceException: print(f"[{now_ts()}] snapshot stale -> retry {attempt}/{retries}", flush=True) time.sleep(0.25) return _get_snapshot_once(driver, do_scroll=do_scroll) # ===================== MAIN LOOP ===================== def main(): print(f"[{now_ts()}] Starting monitor PLUS. poll={POLL_SECONDS}s stake={STAKE}", flush=True) print(f"[{now_ts()}] Bands: " + ", ".join([lbl for _, _, lbl in DIFF_BANDS]), flush=True) print(f"[{now_ts()}] Excel: {os.path.abspath(EXCEL_PATH)}", flush=True) print(f"[{now_ts()}] Snapshots dir: {os.path.abspath(SNAPSHOT_DIR)} every {SNAPSHOT_EVERY_SECONDS}s", flush=True) print(f"[{now_ts()}] Halftime wait: {HALFTIME_WAIT_SECONDS}s (first halftime only)", flush=True) print(f"[{now_ts()}] Strategy: ONLY UNDER, band 20-30, abs_diff in [20,22) or [26,30)", flush=True) if LEAGUE_BLACKLIST_RE: print(f"[{now_ts()}] League blacklist enabled: {LEAGUE_BLACKLIST_RE}", flush=True) # ✅ Send ping immediately (so you know TG works) tg_startup_ping() wb, uid_to_row = excel_open_or_create(EXCEL_PATH) tg_send( "🟢 NowGoal monitor PLUS STARTED\n" f"poll={POLL_SECONDS}s | stake={STAKE}\n" f"strategy=UNDER only; band 20-30; abs_diff [20,22) or [26,30)\n" f"halftime_wait={HALFTIME_WAIT_SECONDS}s (first halftime only)\n" f"time={now_dt()}\n" f"excel(main)={os.path.abspath(EXCEL_PATH)}\n" f"snapshots={os.path.abspath(SNAPSHOT_DIR)}" ) driver = make_driver(headless=HEADLESS) state: Dict[int, Dict[str, Any]] = {} stats_total = empty_stats() stats_by_band = {label: empty_stats() for _, _, label in DIFF_BANDS} stats_by_league: Dict[str, Dict[str, Any]] = {} stats_by_league_band: Dict[str, Dict[str, Dict[str, Any]]] = {} loop_n = 0 try: driver.get(URL) WebDriverWait(driver, 30).until( EC.presence_of_element_located((By.CSS_SELECTOR, 'td[onclick*="basketInPage.analysis("]')) ) while True: loop_n += 1 t0 = time.time() do_scroll = (loop_n == 1) or (loop_n % SCROLL_EVERY_N_LOOPS == 0) try: driver.refresh() time.sleep(0.35) except Exception as e: print(f"[{now_ts()}] refresh error: {e} -> reopen", flush=True) driver.get(URL) try: snap = get_snapshot(driver, do_scroll=do_scroll, retries=3) except Exception as e: print(f"[{now_ts()}] snapshot error: {e}", flush=True) time.sleep(POLL_SECONDS) continue cnt_total = len(snap) cnt_live = cnt_half = cnt_finished = 0 for g in snap.values(): st = g.get("status") if is_finished(st): cnt_finished += 1 elif is_halftime(st): cnt_half += 1 elif is_live(st): cnt_live += 1 print( f"[{now_ts()}] loop={loop_n} games={cnt_total} live={cnt_live} halftime={cnt_half} finished={cnt_finished} " f"| TOTAL profit={stats_total['profit']:+.2f} bets={stats_total['bets']}", flush=True ) for mid, g in snap.items(): st_rec = state.setdefault(mid, { "league": g["league"], "home": g["home"], "away": g["away"], "pregame_total_last": None, "pregame_total_frozen": None, "bet": None, "bet_uid": None, "settled": False, "last_status": None, "line_hist": deque(maxlen=10), "halftime_count": 0, "halftime_first_ts": None, }) status = g.get("status") line_raw: LineOU = g.get("line", LineOU()) line = pick_best_line(line_raw, st_rec, status) if line_complete(line): st_rec["line_hist"].append({ "ts": now_dt(), "bucket": status_bucket(status), "line": LineOU(line.total, line.over_odds, line.under_odds), }) prev_status = st_rec["last_status"] if status != prev_status: st_rec["last_status"] = status if status: parts = [] if line.total is not None: parts.append(f"OU={line.total:g}") if line.over_odds is not None: parts.append(f"O@{line.over_odds:.2f}") if line.under_odds is not None: parts.append(f"U@{line.under_odds:.2f}") line_str = " | " + (" ".join(parts) if parts else "OU=-") print( f" [{now_ts()}] status id={mid} | {st_rec['home'].name} vs {st_rec['away'].name} | {status}{line_str}", flush=True ) # FIRST halftime transition if is_halftime(status) and not is_halftime(prev_status): st_rec["halftime_count"] += 1 if st_rec["halftime_count"] == 1: st_rec["halftime_first_ts"] = time.time() print( f" [{now_ts()}] FIRST HALFTIME detected id={mid} -> waiting {HALFTIME_WAIT_SECONDS}s", flush=True ) else: print( f" [{now_ts()}] HALFTIME повторно (#{st_rec['halftime_count']}) id={mid} -> IGNORE", flush=True ) # pregame -> remember last pregame total if is_pregame(status): if line.total is not None: st_rec["pregame_total_last"] = float(line.total) # first live -> freeze pregame total if is_live(status) and st_rec["pregame_total_frozen"] is None and st_rec["pregame_total_last"] is not None: st_rec["pregame_total_frozen"] = float(st_rec["pregame_total_last"]) print(f" [{now_ts()}] pregame frozen id={mid} pre_total={st_rec['pregame_total_frozen']:g}", flush=True) # halftime -> bet (only first halftime + wait + PLUS filters) if ( is_halftime(status) and st_rec["bet"] is None and st_rec["pregame_total_frozen"] is not None and st_rec["halftime_count"] == 1 ): ht_ts = st_rec.get("halftime_first_ts") if ht_ts is None: st_rec["halftime_first_ts"] = time.time() ht_ts = st_rec["halftime_first_ts"] if (time.time() - float(ht_ts)) < HALFTIME_WAIT_SECONDS: continue pre_total = float(st_rec["pregame_total_frozen"]) half_total = line.total if half_total is None: continue diff = float(half_total) - pre_total abs_diff = abs(diff) band = pick_band(abs_diff) if band is None: continue if diff > 0: side = "UNDER" odds = line.under_odds else: side = "OVER" odds = line.over_odds if odds is None: continue league = st_rec["league"] # ✅ PLUS STRATEGY GATE (main change) if not strategy_allows(league, band, side, abs_diff): print( f" [{now_ts()}] SKIP (PLUS strategy) id={mid} league={league} [{band}] {side} abs_diff={abs_diff:g}", flush=True ) continue bet_uid = f"{mid}-{int(time.time()*1000)}" st_rec["bet_uid"] = bet_uid st_rec["bet"] = { "band": band, "side": side, "line": float(half_total), "odds": float(odds), "stake": float(STAKE), "pre_total": float(pre_total), "half_total": float(half_total), "diff": float(diff), "abs_diff": float(abs_diff), "placed_at": now_dt(), "status_at_bet": status, } stats_total["bets"] += 1 stats_by_band[band]["bets"] += 1 ensure_stats(stats_by_league, league)["bets"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["bets"] += 1 excel_append_bet( wb, uid_to_row, EXCEL_PATH, { "bet_uid": bet_uid, "match_id": mid, "league": league, "home": st_rec["home"].name, "away": st_rec["away"].name, "band": band, "side": side, "pre_total": float(pre_total), "half_total": float(half_total), "diff": float(diff), "abs_diff": float(abs_diff), "odds": float(odds), "stake": float(STAKE), "placed_at": st_rec["bet"]["placed_at"], "status_at_bet": status, "final_total": None, "result": None, "profit": None, "settled_at": None, } ) excel_write_stats(wb, EXCEL_PATH, stats_total, stats_by_band, stats_by_league, stats_by_league_band) excel_write_monthly(wb, EXCEL_PATH) print( f" [{now_ts()}] BET PLACED PLUS [{band}] id={mid} {side} {half_total:g} @ {odds:.2f} " f"(pre {pre_total:g}, diff {diff:+g}, abs {abs_diff:g})", flush=True ) block = stats_block_extended( stats_total, stats_by_band, stats_by_league, stats_by_league_band, focus_league=league, focus_band=band ) tg_send( f"✅ BET PLACED (PLUS) (TOTAL #{stats_total['bets']}) [{band}]\n" f"{league}\n" f"{st_rec['home'].name} vs {st_rec['away'].name}\n" f"Status: {status}\n" f"(waited {HALFTIME_WAIT_SECONDS}s after first halftime)\n" f"Pre total: {pre_total:g}\n" f"Halftime line: {half_total:g} (diff {diff:+g}, abs {abs_diff:g})\n" f"BET: {side} {half_total:g} @ {float(odds):.2f}\n" f"Stake: {STAKE}\n" f"\n📊 STATS\n{block}\n" f"id={mid}\n" f"bet_uid={bet_uid}" ) # finished -> settle if is_finished(status) and st_rec["bet"] is not None and not st_rec["settled"]: hs = g.get("home_score") gs = g.get("away_score") if hs is None or gs is None: continue final_total = int(hs) + int(gs) band = st_rec["bet"]["band"] league = st_rec["league"] side = st_rec["bet"]["side"] line_bet = float(st_rec["bet"]["line"]) odds = float(st_rec["bet"]["odds"]) stake = float(st_rec["bet"]["stake"]) if abs(final_total - line_bet) < 1e-9: result = "push" stats_total["pushes"] += 1 stats_by_band[band]["pushes"] += 1 ensure_stats(stats_by_league, league)["pushes"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["pushes"] += 1 else: win = (final_total < line_bet) if side == "UNDER" else (final_total > line_bet) if win: result = "win" stats_total["wins"] += 1 stats_by_band[band]["wins"] += 1 ensure_stats(stats_by_league, league)["wins"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["wins"] += 1 else: result = "loss" stats_total["losses"] += 1 stats_by_band[band]["losses"] += 1 ensure_stats(stats_by_league, league)["losses"] += 1 ensure_nested_stats(stats_by_league_band, league, band)["losses"] += 1 prof = bet_profit(odds, stake, result) stats_total["profit"] += prof stats_by_band[band]["profit"] += prof ensure_stats(stats_by_league, league)["profit"] += prof ensure_nested_stats(stats_by_league_band, league, band)["profit"] += prof st_rec["settled"] = True bet_uid = st_rec.get("bet_uid") if bet_uid: excel_update_settle( wb, uid_to_row, EXCEL_PATH, bet_uid, final_total, result.upper(), float(prof), now_dt() ) excel_write_stats(wb, EXCEL_PATH, stats_total, stats_by_band, stats_by_league, stats_by_league_band) excel_write_monthly(wb, EXCEL_PATH) block = stats_block_extended( stats_total, stats_by_band, stats_by_league, stats_by_league_band, focus_league=league, focus_band=band ) print( f" [{now_ts()}] BET SETTLED PLUS [{band}] id={mid} {result.upper()} final_total={final_total} " f"bet={side} {line_bet:g} @ {odds:.2f} P/L={prof:+.2f}\n{block}", flush=True ) tg_send( f"🏁 BET RESULT (PLUS): {result.upper()} [{band}]\n" f"{league}\n" f"{st_rec['home'].name} {hs} : {gs} {st_rec['away'].name}\n" f"Final total: {final_total}\n" f"Bet: {side} {line_bet:g} @ {odds:.2f} | stake {stake}\n" f"P/L: {prof:+.2f}\n\n" f"📊 STATS\n{block}\n" f"id={mid}\n" f"bet_uid={bet_uid}" ) maybe_write_snapshot(wb, EXCEL_PATH) elapsed = time.time() - t0 time.sleep(max(0.2, POLL_SECONDS - elapsed)) finally: driver.quit() print(f"[{now_ts()}] Driver closed.", flush=True) if __name__ == "__main__": main()
С этим кодом в консоль будет выведена реальная причина (например: 401 Unauthorized, 400 Bad Request: chat not found, bot was blocked by the user, etc.).
Самые частые фиксы:
/start).TELEGRAM_CHAT_ID (в группе он обычно отрицательный).401 Unauthorized — токен неверный/отозван → перевыпустить.Если хочешь — скинь сюда ровно строку ошибки, которую выведет консоль ([TG] ERROR ...), и я точечно скажу что именно не так (chat_id vs токен vs права).