135 lines
5.8 KiB
Python
135 lines
5.8 KiB
Python
"""
|
||
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()
|