Files
iddaai-be/ai-engine/scripts/monitor_odds_movement.py
T
fahricansecer c3e44ee697
Deploy Iddaai Backend / build-and-deploy (push) Successful in 1m5s
gg65
2026-06-07 22:50:33 +03:00

135 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()