diff --git a/ai-engine/scripts/backtest_hitrate.py b/ai-engine/scripts/backtest_hitrate.py new file mode 100644 index 0000000..1a57cf7 --- /dev/null +++ b/ai-engine/scripts/backtest_hitrate.py @@ -0,0 +1,257 @@ +""" +Multi-market hit-rate backtest. + +Runs the orchestrator against historical finished matches and measures raw V25 +pick accuracy per market — independent of the "playable" gate. This isolates +model quality from the value-detection thresholds. + +Usage: + python scripts/backtest_hitrate.py --start 2026-05-01 --end 2026-05-09 [--limit 500] +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple + +import psycopg2 +from psycopg2.extras import RealDictCursor + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from data.db import get_clean_dsn +from services.single_match_orchestrator import SingleMatchOrchestrator + + +def fetch_matches(cur, start_date: str, end_date: str, limit: Optional[int]) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT m.id, m.score_home, m.score_away, m.ht_score_home, m.ht_score_away, + m.mst_utc, t1.name as home_name, t2.name as away_name + FROM matches m + LEFT JOIN teams t1 ON m.home_team_id = t1.id + LEFT JOIN teams t2 ON m.away_team_id = t2.id + WHERE m.status IN ('FT', 'AET', 'PEN') + AND m.sport = 'football' + AND to_timestamp(m.mst_utc / 1000.0)::date BETWEEN %s::date AND %s::date + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + ORDER BY m.mst_utc ASC + """ + (f" LIMIT {int(limit)}" if limit else ""), + (start_date, end_date), + ) + return cur.fetchall() + + +def actual_ms(h: int, a: int) -> str: + return "1" if h > a else ("X" if h == a else "2") + + +def actual_ht(hh: Optional[int], ha: Optional[int]) -> Optional[str]: + if hh is None or ha is None: + return None + return "1" if hh > ha else ("X" if hh == ha else "2") + + +OVER_TOKENS = {"over", "üst", "ust"} +UNDER_TOKENS = {"under", "alt"} +YES_TOKENS = {"yes", "var", "kg var"} +NO_TOKENS = {"no", "yok", "kg yok"} +ODD_TOKENS = {"odd", "tek"} +EVEN_TOKENS = {"even", "çift", "cift"} + + +def _norm(s: str) -> str: + return str(s or "").strip().lower() + + +def score_pick(market: str, predicted: str, h: int, a: int, hh: Optional[int], ha: Optional[int]) -> Optional[bool]: + """Return True/False for hit, or None if cannot evaluate.""" + total = h + a + ht_total = (hh + ha) if hh is not None and ha is not None else None + p = _norm(predicted) + + if market == "MS": + return p.upper() == actual_ms(h, a) + if market in ("OU15", "OU25", "OU35"): + line = {"OU15": 1.5, "OU25": 2.5, "OU35": 3.5}[market] + if p in OVER_TOKENS: + return total > line + if p in UNDER_TOKENS: + return total < line + return None + if market == "BTTS": + btts = h > 0 and a > 0 + if p in YES_TOKENS: + return btts + if p in NO_TOKENS: + return not btts + return None + if market == "HT": + ht = actual_ht(hh, ha) + return None if ht is None else p.upper() == ht + if market in ("HT_OU05", "HT_OU15"): + if ht_total is None: + return None + line = 0.5 if market == "HT_OU05" else 1.5 + if p in OVER_TOKENS: + return ht_total > line + if p in UNDER_TOKENS: + return ht_total < line + return None + if market == "HTFT": + ht = actual_ht(hh, ha) + if ht is None: + return None + full = actual_ms(h, a) + norm = p.replace(" ", "").upper().replace("0", "X") + return norm == f"{ht}/{full}" + if market == "OE": + odd = total % 2 == 1 + if p in ODD_TOKENS: + return odd + if p in EVEN_TOKENS: + return not odd + return None + if market == "DC": + ms = actual_ms(h, a) + compact = p.replace("-", "").upper() + if compact == "1X": + return ms in ("1", "X") + if compact == "X2": + return ms in ("X", "2") + if compact == "12": + return ms in ("1", "2") + return None + # CARDS / HCAP cannot be scored without extra data + return None + + +def top_pick(probs: Dict[str, float]) -> Tuple[Optional[str], float]: + if not probs: + return None, 0.0 + key = max(probs, key=lambda k: float(probs.get(k, 0) or 0)) + return key, float(probs.get(key, 0) or 0) + + +def run(start_date: str, end_date: str, limit: Optional[int], out_path: Optional[str]) -> None: + dsn = get_clean_dsn() + print(f"DSN host={dsn.split('@')[-1].split('/')[0]}") + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + + matches = fetch_matches(cur, start_date, end_date, limit) + print(f"Found {len(matches)} matches between {start_date} and {end_date}") + if not matches: + return + + orchestrator = SingleMatchOrchestrator() + + market_stats: Dict[str, Dict[str, Any]] = defaultdict(lambda: { + "total": 0, "hits": 0, "skipped": 0, + "playable_total": 0, "playable_hits": 0, + "conf_sum": 0.0, + }) + detailed_rows: List[Dict[str, Any]] = [] + errors = 0 + started = time.time() + + for idx, m in enumerate(matches, 1): + try: + pkg = orchestrator.analyze_match(m["id"]) + except Exception as e: + errors += 1 + if errors <= 5: + print(f"[ERR] {m['id']}: {e}") + continue + if not pkg: + continue + + board = pkg.get("market_board", {}) or {} + h = int(m["score_home"]) + a = int(m["score_away"]) + hh = m.get("ht_score_home") + ha = m.get("ht_score_away") + + for market, entry in board.items(): + if not isinstance(entry, dict): + continue + probs = entry.get("probs") or {} + pick, prob = top_pick(probs) + if pick is None: + continue + hit = score_pick(market, pick, h, a, hh, ha) + stats = market_stats[market] + if hit is None: + stats["skipped"] += 1 + continue + stats["total"] += 1 + stats["conf_sum"] += prob + if hit: + stats["hits"] += 1 + if entry.get("playable") is True: + stats["playable_total"] += 1 + if hit: + stats["playable_hits"] += 1 + detailed_rows.append({ + "match_id": m["id"], + "market": market, + "pick": pick, + "prob": round(prob, 4), + "hit": hit, + "playable": bool(entry.get("playable")), + "score": f"{h}-{a}", + "ht_score": f"{hh}-{ha}" if hh is not None else None, + }) + + if idx % 25 == 0: + elapsed = time.time() - started + print(f" ... processed {idx}/{len(matches)} ({elapsed:.1f}s)") + + elapsed = time.time() - started + print("\n" + "=" * 72) + print(f"BACKTEST {start_date} .. {end_date} | matches={len(matches)} errors={errors} elapsed={elapsed:.1f}s") + print("=" * 72) + header = f"{'Market':<10} {'N':>5} {'Hit':>5} {'Rate':>7} {'AvgConf':>8} | {'PlayN':>6} {'PlayHit':>7} {'PlayRate':>8}" + print(header) + print("-" * 72) + for market in sorted(market_stats.keys()): + s = market_stats[market] + n = s["total"] + rate = (s["hits"] / n * 100) if n else 0.0 + avg_conf = (s["conf_sum"] / n * 100) if n else 0.0 + pn = s["playable_total"] + prate = (s["playable_hits"] / pn * 100) if pn else 0.0 + print(f"{market:<10} {n:>5} {s['hits']:>5} {rate:>6.1f}% {avg_conf:>7.1f}% | {pn:>6} {s['playable_hits']:>7} {prate:>7.1f}%") + + if out_path: + payload = { + "range": {"start": start_date, "end": end_date}, + "match_count": len(matches), + "errors": errors, + "elapsed_sec": round(elapsed, 1), + "market_stats": {k: dict(v) for k, v in market_stats.items()}, + "rows": detailed_rows, + } + with open(out_path, "w") as f: + json.dump(payload, f, indent=2, ensure_ascii=False) + print(f"\nSaved details to {out_path}") + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--start", required=True, help="YYYY-MM-DD") + p.add_argument("--end", required=True, help="YYYY-MM-DD") + p.add_argument("--limit", type=int, default=None) + p.add_argument("--out", default=None, help="Optional JSON output path") + args = p.parse_args() + run(args.start, args.end, args.limit, args.out) + + +if __name__ == "__main__": + main() diff --git a/package-lock.json b/package-lock.json index ca766cc..3312de9 100755 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@nestjs/swagger": "^11.2.4", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.5.0", - "@prisma/client": "5.22.0", + "@prisma/client": "^6.19.3", "axios": "^1.13.6", "bcrypt": "^6.0.0", "bullmq": "^5.66.4", @@ -46,7 +46,7 @@ "passport-jwt": "^4.0.1", "pino": "^10.1.0", "pino-http": "^11.0.0", - "prisma": "5.22.0", + "prisma": "^6.19.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "twitter-api-v2": "^1.29.0", @@ -3773,60 +3773,75 @@ } }, "node_modules/@prisma/client": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", - "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "hasInstallScript": true, "engines": { - "node": ">=16.13" + "node": ">=18.18" }, "peerDependencies": { - "prisma": "*" + "prisma": "*", + "typescript": ">=5.1.0" }, "peerDependenciesMeta": { "prisma": { "optional": true + }, + "typescript": { + "optional": true } } }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, "node_modules/@prisma/debug": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", - "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==" + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==" }, "node_modules/@prisma/engines": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", - "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", "hasInstallScript": true, "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/fetch-engine": "5.22.0", - "@prisma/get-platform": "5.22.0" + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" } }, "node_modules/@prisma/engines-version": { - "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", - "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==" + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==" }, "node_modules/@prisma/fetch-engine": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", - "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", "dependencies": { - "@prisma/debug": "5.22.0", - "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", - "@prisma/get-platform": "5.22.0" + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" } }, "node_modules/@prisma/get-platform": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", - "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", "dependencies": { - "@prisma/debug": "5.22.0" + "@prisma/debug": "6.19.3" } }, "node_modules/@redis/bloom": { @@ -4603,6 +4618,11 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -6366,6 +6386,44 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/cache-manager": { "version": "7.2.7", "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz", @@ -6582,7 +6640,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6623,6 +6680,14 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -6852,6 +6917,11 @@ "typedarray": "^0.0.6" } }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==" + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -7109,6 +7179,14 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -7121,6 +7199,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7145,6 +7228,11 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7292,6 +7380,15 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -7315,6 +7412,14 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -7862,11 +7967,52 @@ "url": "https://opencollective.com/express" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -8388,6 +8534,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -9727,6 +9889,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -10575,6 +10745,11 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==" + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -10651,6 +10826,27 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10679,6 +10875,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -10995,11 +11196,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11181,6 +11392,16 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -11308,21 +11529,27 @@ } }, "node_modules/prisma": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", - "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "dependencies": { - "@prisma/engines": "5.22.0" + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=16.13" + "node": ">=18.18" }, - "optionalDependencies": { - "fsevents": "2.3.3" + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/process-warning": { @@ -11474,6 +11701,15 @@ "node": ">=0.10.0" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -11497,7 +11733,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, "engines": { "node": ">= 14.18.0" }, @@ -12593,6 +12828,14 @@ "real-require": "^0.2.0" } }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -12918,7 +13161,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 98e491d..30a0416 100755 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@nestjs/swagger": "^11.2.4", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.5.0", - "@prisma/client": "5.22.0", + "@prisma/client": "^6.19.3", "axios": "^1.13.6", "bcrypt": "^6.0.0", "bullmq": "^5.66.4", @@ -75,7 +75,7 @@ "passport-jwt": "^4.0.1", "pino": "^10.1.0", "pino-http": "^11.0.0", - "prisma": "5.22.0", + "prisma": "^6.19.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "twitter-api-v2": "^1.29.0", @@ -110,9 +110,6 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, - "prisma": { - "seed": "ts-node prisma/seed.ts" - }, "jest": { "moduleFileExtensions": [ "js", @@ -129,5 +126,8 @@ ], "coverageDirectory": "../coverage", "testEnvironment": "node" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" } } diff --git a/prisma.config.ts b/prisma.config.ts index 8129d06..f5bd20a 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,7 +1,15 @@ -module.exports = { - datasource: { - url: - process.env.DATABASE_URL || - 'postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db?schema=public', +import path from 'node:path'; +import { defineConfig, env } from 'prisma/config'; +import { config } from 'dotenv'; + +config({ path: '.env.local' }); + +export default defineConfig({ + schema: path.join('prisma', 'schema.prisma'), + migrations: { + path: path.join('prisma', 'migrations'), }, -}; + datasource: { + url: env('DATABASE_URL'), + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 12ab9db..b400b49 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,22 +42,22 @@ model League { } model Team { - id String @id - name String - slug String? - sport Sport - logoUrl String? @map("logo_url") - createdAt DateTime @default(now()) @map("created_at") - awayMatchesLive LiveMatch[] @relation("AwayTeamLive") - homeMatchesLive LiveMatch[] @relation("HomeTeamLive") - playerEvents MatchPlayerEvents[] - playerParticipations MatchPlayerParticipation[] + id String @id + name String + slug String? + sport Sport + logoUrl String? @map("logo_url") + createdAt DateTime @default(now()) @map("created_at") + awayMatchesLive LiveMatch[] @relation("AwayTeamLive") + homeMatchesLive LiveMatch[] @relation("HomeTeamLive") + playerEvents MatchPlayerEvents[] + playerParticipations MatchPlayerParticipation[] basketballPlayerStats BasketballPlayerStats[] - footballTeamStats FootballTeamStats[] - basketballTeamStats BasketballTeamStats[] - awayMatches Match[] @relation("AwayTeam") - homeMatches Match[] @relation("HomeTeam") - eloRating TeamEloRating? + footballTeamStats FootballTeamStats[] + basketballTeamStats BasketballTeamStats[] + awayMatches Match[] @relation("AwayTeam") + homeMatches Match[] @relation("HomeTeam") + eloRating TeamEloRating? @@index([name]) @@index([sport]) @@ -80,24 +80,24 @@ model Player { } model Match { - id String @id - leagueId String? @map("league_id") - homeTeamId String? @map("home_team_id") - awayTeamId String? @map("away_team_id") + id String @id + leagueId String? @map("league_id") + homeTeamId String? @map("home_team_id") + awayTeamId String? @map("away_team_id") sport Sport - matchName String? @map("match_name") - matchSlug String? @map("match_slug") - mstUtc BigInt @map("mst_utc") + matchName String? @map("match_name") + matchSlug String? @map("match_slug") + mstUtc BigInt @map("mst_utc") status String? state String? - scoreHome Int? @map("score_home") - scoreAway Int? @map("score_away") - htScoreHome Int? @map("ht_score_home") - htScoreAway Int? @map("ht_score_away") + scoreHome Int? @map("score_home") + scoreAway Int? @map("score_away") + htScoreHome Int? @map("ht_score_home") + htScoreAway Int? @map("ht_score_away") winner String? - iddaaCode String? @map("iddaa_code") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + iddaaCode String? @map("iddaa_code") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") footballAiFeatures FootballAiFeature? basketballAiFeatures BasketballAiFeature? officials MatchOfficial[] @@ -106,11 +106,12 @@ model Match { basketballPlayerStats BasketballPlayerStats[] footballTeamStats FootballTeamStats[] basketballTeamStats BasketballTeamStats[] - awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) - homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) - league League? @relation(fields: [leagueId], references: [id]) + awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) + homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) + league League? @relation(fields: [leagueId], references: [id]) oddCategories OddCategory[] prediction Prediction? + predictionOutcomes PredictionOutcome[] couponItems UserCouponItem[] @@index([awayTeamId]) @@ -270,25 +271,25 @@ model TeamEloRating { } model MatchPlayerEvents { - id Int @id @default(autoincrement()) - matchId String @map("match_id") - playerId String @map("player_id") - teamId String @map("team_id") - eventType EventType @map("event_type") - eventSubtype String? @map("event_subtype") - timeMinute String @map("time_minute") - timeSeconds Int? @map("time_seconds") - periodId Int? @map("period_id") - assistPlayerId String? @map("assist_player_id") - scoreAfter String? @map("score_after") - playerOutId String? @map("player_out_id") - position MatchPosition? - createdAt DateTime @default(now()) @map("created_at") - assistPlayer Player? @relation("AssistPlayer", fields: [assistPlayerId], references: [id]) - match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) - player Player @relation("EventPlayer", fields: [playerId], references: [id], onDelete: Cascade) - substitutedOut Player? @relation("SubstitutedOut", fields: [playerOutId], references: [id]) - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + matchId String @map("match_id") + playerId String @map("player_id") + teamId String @map("team_id") + eventType EventType @map("event_type") + eventSubtype String? @map("event_subtype") + timeMinute String @map("time_minute") + timeSeconds Int? @map("time_seconds") + periodId Int? @map("period_id") + assistPlayerId String? @map("assist_player_id") + scoreAfter String? @map("score_after") + playerOutId String? @map("player_out_id") + position MatchPosition? + createdAt DateTime @default(now()) @map("created_at") + assistPlayer Player? @relation("AssistPlayer", fields: [assistPlayerId], references: [id]) + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + player Player @relation("EventPlayer", fields: [playerId], references: [id], onDelete: Cascade) + substitutedOut Player? @relation("SubstitutedOut", fields: [playerOutId], references: [id]) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@index([assistPlayerId]) @@index([eventType]) @@ -319,28 +320,28 @@ model MatchPlayerParticipation { } model BasketballPlayerStats { - id Int @id @default(autoincrement()) - matchId String @map("match_id") - playerId String @map("player_id") - teamId String @map("team_id") - minutes String? - points Int? - rebounds Int? - assists Int? - steals Int? - blocks Int? - turnovers Int? - fgMade Int? @map("fg_made") - fgAttempted Int? @map("fg_attempted") - threePtMade Int? @map("three_pt_made") - threePtAttempted Int? @map("three_pt_attempted") - ftMade Int? @map("ft_made") - ftAttempted Int? @map("ft_attempted") - fouls Int? - createdAt DateTime @default(now()) @map("created_at") - match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) - player Player @relation(fields: [playerId], references: [id], onDelete: Cascade) - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + matchId String @map("match_id") + playerId String @map("player_id") + teamId String @map("team_id") + minutes String? + points Int? + rebounds Int? + assists Int? + steals Int? + blocks Int? + turnovers Int? + fgMade Int? @map("fg_made") + fgAttempted Int? @map("fg_attempted") + threePtMade Int? @map("three_pt_made") + threePtAttempted Int? @map("three_pt_attempted") + ftMade Int? @map("ft_made") + ftAttempted Int? @map("ft_attempted") + fouls Int? + createdAt DateTime @default(now()) @map("created_at") + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + player Player @relation(fields: [playerId], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@unique([matchId, playerId, teamId]) @@index([matchId]) @@ -429,13 +430,13 @@ model OfficialRole { // ───────────────────────────────────────────────────────────── model OddCategory { - dbId Int @id @default(autoincrement()) @map("db_id") - matchId String @map("match_id") - categoryJsonId Int? @map("category_json_id") + dbId Int @id @default(autoincrement()) @map("db_id") + matchId String @map("match_id") + categoryJsonId Int? @map("category_json_id") name String? sport Sport? - createdAt DateTime @default(now()) @map("created_at") - match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) selections OddSelection[] @@unique([matchId, name]) @@ -445,17 +446,17 @@ model OddCategory { } model OddSelection { - dbId Int @id @default(autoincrement()) @map("db_id") - categoryId Int @map("odd_category_db_id") + dbId Int @id @default(autoincrement()) @map("db_id") + categoryId Int @map("odd_category_db_id") name String? - oddValue String? @map("odd_value") + oddValue String? @map("odd_value") position String? sov Float? state String? sport Sport? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - category OddCategory @relation(fields: [categoryId], references: [dbId], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") + category OddCategory @relation(fields: [categoryId], references: [dbId], onDelete: Cascade) history OddsHistory[] @@unique([categoryId, name]) @@ -505,6 +506,29 @@ model PredictionRun { @@map("prediction_runs") } +model PredictionOutcome { + id BigInt @id @default(autoincrement()) + matchId String @map("match_id") + market String + pick String + probability Float + confidence Float + odds Float? + playable Boolean @default(false) + engineVersion String @map("engine_version") + generatedAt DateTime @default(now()) @map("generated_at") + resolved Boolean @default(false) + hit Boolean? + resolvedAt DateTime? @map("resolved_at") + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + + @@unique([matchId, market, engineVersion]) + @@index([market, resolvedAt(sort: Desc)]) + @@index([resolved, generatedAt]) + @@index([playable, resolved]) + @@map("prediction_outcomes") +} + model AiPredictionsLog { id Int @id @default(autoincrement()) matchId String @map("match_id") @@ -553,20 +577,20 @@ model User { } model Subscription { - id String @id @default(uuid()) - userId String @unique @map("user_id") - paddleSubscriptionId String? @unique @map("paddle_subscription_id") - paddleCustomerId String? @map("paddle_customer_id") - plan SubscriptionStatus @default(free) - billingInterval BillingInterval? @map("billing_interval") - currentPeriodStart DateTime? @map("current_period_start") - currentPeriodEnd DateTime? @map("current_period_end") - cancelledAt DateTime? @map("cancelled_at") - cancelEffectiveDate DateTime? @map("cancel_effective_date") - paddlePriceId String? @map("paddle_price_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @unique @map("user_id") + paddleSubscriptionId String? @unique @map("paddle_subscription_id") + paddleCustomerId String? @map("paddle_customer_id") + plan SubscriptionStatus @default(free) + billingInterval BillingInterval? @map("billing_interval") + currentPeriodStart DateTime? @map("current_period_start") + currentPeriodEnd DateTime? @map("current_period_end") + cancelledAt DateTime? @map("cancelled_at") + cancelEffectiveDate DateTime? @map("cancel_effective_date") + paddlePriceId String? @map("paddle_price_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([paddleSubscriptionId]) @@index([paddleCustomerId]) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index c134998..8576853 100755 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { Controller, Post, Body, HttpCode } from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; import { I18n, I18nContext } from "nestjs-i18n"; import { ApiTags, ApiOperation, ApiOkResponse } from "@nestjs/swagger"; import { AuthService } from "./auth.service"; @@ -21,6 +22,7 @@ export class AuthController { @Post("register") @Public() + @Throttle({ default: { limit: 10, ttl: 60000 } }) @HttpCode(200) @ApiOperation({ summary: "Register a new user" }) @ApiOkResponse({ @@ -37,6 +39,7 @@ export class AuthController { @Post("login") @Public() + @Throttle({ default: { limit: 5, ttl: 60000 } }) @HttpCode(200) @ApiOperation({ summary: "Login with email and password" }) @ApiOkResponse({ description: "Login successful", type: TokenResponseDto }) @@ -50,6 +53,7 @@ export class AuthController { @Post("refresh") @Public() + @Throttle({ default: { limit: 10, ttl: 60000 } }) @HttpCode(200) @ApiOperation({ summary: "Refresh access token" }) @ApiOkResponse({ diff --git a/src/modules/coupons/services/frequency-engine.service.ts b/src/modules/coupons/services/frequency-engine.service.ts index 5c0b958..e0d3b35 100644 --- a/src/modules/coupons/services/frequency-engine.service.ts +++ b/src/modules/coupons/services/frequency-engine.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "../../../database/prisma.service"; // ───────────────────────────────────────────────────────────── @@ -108,16 +109,21 @@ export class FrequencyEngineService { venue: "home" | "away", oddsBand: string, ): Promise { - const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id"; - const oddsSelection = venue === "home" ? "'1'" : "'2'"; + // venue is a typed literal ("home"|"away") — safe to use with Prisma.raw() + const venueColumnRaw = Prisma.raw( + venue === "home" ? "m.home_team_id" : "m.away_team_id", + ); + const oddsSelectionValue = venue === "home" ? "1" : "2"; + const winConditionRaw = Prisma.raw( + venue === "home" ? "score_home > score_away" : "score_away > score_home", + ); const bandRange = this.parseBandRange(oddsBand); if (!bandRange) { return null; } - const rows = await this.prisma.$queryRawUnsafe( - ` + const rows = await this.prisma.$queryRaw(Prisma.sql` WITH team_matches AS ( SELECT m.id AS match_id, @@ -127,32 +133,26 @@ export class FrequencyEngineService { CAST(os.odd_value AS DECIMAL) AS team_odds FROM matches m JOIN odd_categories oc ON oc.match_id = m.id AND oc.name = 'Maç Sonucu' - JOIN odd_selections os ON os.odd_category_db_id = oc.db_id AND os.name = ${oddsSelection} + JOIN odd_selections os ON os.odd_category_db_id = oc.db_id AND os.name = ${oddsSelectionValue} WHERE m.status = 'FT' AND m.score_home IS NOT NULL - AND ${venueColumn} = $1 - AND CAST(os.odd_value AS DECIMAL) >= $2 - AND CAST(os.odd_value AS DECIMAL) < $3 + AND ${venueColumnRaw} = ${teamId} + AND CAST(os.odd_value AS DECIMAL) >= ${bandRange.min} + AND CAST(os.odd_value AS DECIMAL) < ${bandRange.max} ) SELECT - $1::text AS team_id, - $4::text AS venue, - $5::text AS odds_band, + ${teamId}::text AS team_id, + ${venue}::text AS venue, + ${oddsBand}::text AS odds_band, COUNT(*)::int AS total_matches, COALESCE(AVG(CASE WHEN total_goals > 1 THEN 1.0 ELSE 0.0 END), 0)::float AS ou15_rate, COALESCE(AVG(CASE WHEN total_goals > 2 THEN 1.0 ELSE 0.0 END), 0)::float AS ou25_rate, COALESCE(AVG(CASE WHEN total_goals > 3 THEN 1.0 ELSE 0.0 END), 0)::float AS ou35_rate, COALESCE(AVG(CASE WHEN score_home > 0 AND score_away > 0 THEN 1.0 ELSE 0.0 END), 0)::float AS btts_rate, - COALESCE(AVG(CASE WHEN ${venue === "home" ? "score_home > score_away" : "score_away > score_home"} THEN 1.0 ELSE 0.0 END), 0)::float AS win_rate, + COALESCE(AVG(CASE WHEN ${winConditionRaw} THEN 1.0 ELSE 0.0 END), 0)::float AS win_rate, COALESCE(AVG(total_goals), 0)::float AS avg_goals FROM team_matches - `, - teamId, - bandRange.min, - bandRange.max, - venue, - oddsBand, - ); + `); if (!rows.length || rows[0].total_matches < MIN_MATCHES) { return null; @@ -335,8 +335,7 @@ export class FrequencyEngineService { if (matchIds && matchIds.length > 0) { // Belirli maçlar istendi - return this.prisma.$queryRawUnsafe( - ` + return this.prisma.$queryRaw(Prisma.sql` SELECT lm.id AS match_id, lm.home_team_id, @@ -359,18 +358,15 @@ export class FrequencyEngineService { LEFT JOIN teams ht ON lm.home_team_id = ht.id LEFT JOIN teams at ON lm.away_team_id = at.id LEFT JOIN leagues l ON lm.league_id = l.id - WHERE lm.id = ANY($1) + WHERE lm.id = ANY(${matchIds}) AND lm.odds IS NOT NULL AND lm.odds != 'null'::jsonb ORDER BY lm.mst_utc ASC - `, - matchIds, - ); + `); } // Otomatik: yaklaşan tüm maçlar - return this.prisma.$queryRawUnsafe( - ` + return this.prisma.$queryRaw(Prisma.sql` SELECT lm.id AS match_id, lm.home_team_id, @@ -393,26 +389,22 @@ export class FrequencyEngineService { LEFT JOIN teams ht ON lm.home_team_id = ht.id LEFT JOIN teams at ON lm.away_team_id = at.id LEFT JOIN leagues l ON lm.league_id = l.id - WHERE lm.mst_utc >= $1 + WHERE lm.mst_utc >= ${BigInt(nowMs)} AND lm.sport = 'football' AND lm.odds IS NOT NULL AND lm.odds != 'null'::jsonb AND (lm.status IS NULL OR lm.status NOT IN ('FT', 'AET', 'PEN', 'ABD', 'CANC', 'PST', 'SUSP', 'INT', 'AWD', 'WO')) AND (lm.state IS NULL OR lm.state NOT IN ('after', 'postponed', 'cancelled', 'abandoned')) ORDER BY lm.mst_utc ASC - LIMIT $2 - `, - BigInt(nowMs), - limit, - ); + LIMIT ${limit} + `); } /** * Lig bazlı gol profili. */ async getLeagueProfile(leagueId: string): Promise { - const rows = await this.prisma.$queryRawUnsafe( - ` + const rows = await this.prisma.$queryRaw(Prisma.sql` SELECT m.league_id, l.name AS league_name, @@ -424,12 +416,10 @@ export class FrequencyEngineService { JOIN leagues l ON m.league_id = l.id WHERE m.status = 'FT' AND m.score_home IS NOT NULL - AND m.league_id = $1 + AND m.league_id = ${leagueId} GROUP BY m.league_id, l.name HAVING COUNT(*) >= 20 - `, - leagueId, - ); + `); return rows.length > 0 ? rows[0] : null; } diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index 088aba3..4a96513 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -6,6 +6,7 @@ */ import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "../../database/prisma.service"; import { Sport, @@ -858,11 +859,11 @@ export class FeederPersistenceService { // Use raw SQL for performance — Prisma's { some: {} } relation filters // generate heavy correlated subqueries that hang on Raspberry Pi with // large tables (15M+ odd_selections, 3M+ participations). - const result = await this.prisma.$queryRawUnsafe>( - ` + const result = await this.prisma.$queryRaw>( + Prisma.sql` SELECT m.id FROM matches m - WHERE m.id = ANY($1::text[]) + WHERE m.id = ANY(${matchIds}::text[]) AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id) AND ( (m.sport = 'football' @@ -875,7 +876,6 @@ export class FeederPersistenceService { AND EXISTS (SELECT 1 FROM basketball_player_stats bps WHERE bps.match_id = m.id)) ) `, - matchIds, ); return result.map((r) => r.id);