""" Odds Movement Monitor — opening→closing line movement + steam radar. =================================================================== Reads live_odds_history (filled by data-fetcher.task.ts every 15 min for upcoming matches, all markets) and reports, PER MATCH: * opening odd (first capture) vs closing odd (latest capture) * total move % = (closing - opening) / opening ← the headline signal * the steam side (the selection that shortened the most = money/info/şike) Why opening→closing matters: it is the market's TOTAL revision. A side that shortened a lot from open to close = the market learned something. If you can bet EARLY (before the shortening), that gap is real value (positive CLV) — the one realistic edge vs İddaa. As a closing bettor it's a RISK FILTER: heavy late steam against your pick = skip. Capture is done by the NestJS cron now (DB); this is a pure READER. Usage: python scripts/monitor_odds_movement.py # MS movers python scripts/monitor_odds_movement.py --min-move 0.08 --market "Maç Sonucu" """ from __future__ import annotations import argparse, os, sys, time from collections import defaultdict if sys.stdout and hasattr(sys.stdout, "reconfigure"): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass AI_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, AI_DIR) from data.db import get_clean_dsn # noqa: E402 import psycopg2 # noqa: E402 from psycopg2.extras import RealDictCursor # noqa: E402 def connect(): last = None for _ in range(8): try: return psycopg2.connect(get_clean_dsn()) except Exception as e: last = e; time.sleep(3) raise last def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--min-move", type=float, default=0.05, help="flag matches whose focus-market move >= this fraction (default 0.05)") ap.add_argument("--market", default="Maç Sonucu", help="focus market for the watchlist") ap.add_argument("--limit", type=int, default=25) args = ap.parse_args() with connect() as c, c.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT to_regclass('public.live_odds_history') AS ex") if not cur.fetchall()[0]["ex"]: print("live_odds_history yok — NestJS cron'u henüz yazmamış (deploy/build kontrol)."); return # opening (earliest) + closing (latest) per match/market/selection cur.execute(""" SELECT match_id, market, selection, (array_agg(new_value ORDER BY change_time ASC))[1] AS opening, (array_agg(new_value ORDER BY change_time DESC))[1] AS closing, count(*) AS ticks FROM live_odds_history GROUP BY match_id, market, selection """) rows = cur.fetchall() if not rows: print("live_odds_history boş (henüz yakalama yok)."); return # per match aggregation by_match = defaultdict(lambda: {"focus": {}, "any_ticks": 0, "max_abs": 0.0}) for r in rows: mid = r["match_id"]; o = r["opening"]; cl = r["closing"] d = by_match[mid] d["any_ticks"] = max(d["any_ticks"], r["ticks"]) if o and cl and o > 0: mv = (cl - o) / o d["max_abs"] = max(d["max_abs"], abs(mv)) if r["market"] == args.market: d["focus"][r["selection"]] = (o, cl, mv) # team names + kickoff ids = list(by_match.keys()) names = {} if ids: cur.execute("""SELECT lm.id, ht.name h, at.name a, lm.mst_utc FROM live_matches lm JOIN teams ht ON ht.id=lm.home_team_id JOIN teams at ON at.id=lm.away_team_id WHERE lm.id = ANY(%s)""", (ids,)) for r in cur.fetchall(): names[r["id"]] = (f"{r['h']} v {r['a']}", r["mst_utc"]) moved = [(m, d) for m, d in by_match.items() if d["any_ticks"] > 1] print("="*78) print("ODDS MOVEMENT — açılış→kapanış (live_odds_history)") print("="*78) print(f"izlenen maç: {len(by_match)} | hareket başlamış (>1 yakalama): {len(moved)}") if not moved: print("\nHenüz hareket yok — hepsi tek yakalama (açılış). Oranlar oynadıkça dolacak.") print("(NestJS 15-dk cron'u her tazelemede değişen oranı ekliyor.)") return flagged = sorted( [(m, d) for m, d in moved if d["focus"] and d["max_abs"] >= args.min_move], key=lambda x: -x[1]["max_abs"], ) now = int(time.time()*1000) print(f"\n{args.market} hareketi >= %{args.min_move*100:.0f} olan maçlar:") print(f" {'maç':<32}{'sel':>5}{'açılış':>8}{'kapanış':>9}{'hareket':>9}") print(" "+"-"*64) for mid, d in flagged[:args.limit]: nm, mst = names.get(mid, (mid[:30], None)) ko = "" if mst: mins = (mst-now)/60000 ko = f" KO~{mins/60:.1f}h" if mins > 0 else " (başladı)" # steam side = most shortened (most negative move) steam = min(d["focus"].items(), key=lambda kv: kv[1][2]) print(f" {nm[:30]:<32}{'':>5}{'':>8}{'':>9}{'':>9}{ko}") for sel, (o, cl, mv) in d["focus"].items(): tag = " ↓STEAM" if sel == steam[0] and mv < 0 else "" print(f" {'':<32}{sel:>5}{o:>8.2f}{cl:>9.2f}{100*mv:>+8.1f}%{tag}") if not flagged: print(" (eşiği geçen yok — hareketler küçük)") print("\nOKUMA: kapanışta oynuyorsan, pick'ine KARŞI ↓STEAM olan maçı PAS geç.") print("Erken oynayabiliyorsan, kısalan tarafı açılışta yakalamak = gerçek değer (CLV).") if __name__ == "__main__": main()