gg
Deploy Iddaai Backend / build-and-deploy (push) Failing after 2m15s

This commit is contained in:
2026-05-11 20:50:31 +03:00
parent 70fdc066c7
commit 4dcc4ced50
8 changed files with 718 additions and 192 deletions
+257
View File
@@ -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()
+283 -40
View File
@@ -26,7 +26,7 @@
"@nestjs/swagger": "^11.2.4", "@nestjs/swagger": "^11.2.4",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "5.22.0", "@prisma/client": "^6.19.3",
"axios": "^1.13.6", "axios": "^1.13.6",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.66.4", "bullmq": "^5.66.4",
@@ -46,7 +46,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pino": "^10.1.0", "pino": "^10.1.0",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"prisma": "5.22.0", "prisma": "^6.19.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"twitter-api-v2": "^1.29.0", "twitter-api-v2": "^1.29.0",
@@ -3773,60 +3773,75 @@
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.22.0", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
"hasInstallScript": true, "hasInstallScript": true,
"engines": { "engines": {
"node": ">=16.13" "node": ">=18.18"
}, },
"peerDependencies": { "peerDependencies": {
"prisma": "*" "prisma": "*",
"typescript": ">=5.1.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"prisma": { "prisma": {
"optional": true "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": { "node_modules/@prisma/debug": {
"version": "5.22.0", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==" "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw=="
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.22.0", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0", "@prisma/debug": "6.19.3",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"@prisma/fetch-engine": "5.22.0", "@prisma/fetch-engine": "6.19.3",
"@prisma/get-platform": "5.22.0" "@prisma/get-platform": "6.19.3"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==" "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA=="
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.22.0", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0", "@prisma/debug": "6.19.3",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"@prisma/get-platform": "5.22.0" "@prisma/get-platform": "6.19.3"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.22.0", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0" "@prisma/debug": "6.19.3"
} }
}, },
"node_modules/@redis/bloom": { "node_modules/@redis/bloom": {
@@ -4603,6 +4618,11 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" "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": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -6366,6 +6386,44 @@
"node": ">= 0.8" "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": { "node_modules/cache-manager": {
"version": "7.2.7", "version": "7.2.7",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz", "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz",
@@ -6582,7 +6640,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@@ -6623,6 +6680,14 @@
"node": ">=8" "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": { "node_modules/cjs-module-lexer": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
@@ -6852,6 +6917,11 @@
"typedarray": "^0.0.6" "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": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -7109,6 +7179,14 @@
"node": ">=0.10.0" "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": { "node_modules/defaults": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@@ -7121,6 +7199,11 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -7145,6 +7228,11 @@
"node": ">= 0.8" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.267", "version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "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", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" "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": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -7862,11 +7967,52 @@
"url": "https://opencollective.com/express" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" "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": { "node_modules/fast-copy": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -8388,6 +8534,22 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/github-from-package": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "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" "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": { "node_modules/joycon": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -10575,6 +10745,11 @@
"url": "https://opencollective.com/node-fetch" "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": { "node_modules/node-gyp-build": {
"version": "4.8.4", "version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "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" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -10679,6 +10875,11 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-exit-leak-free": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -10995,11 +11196,21 @@
"node": ">=8" "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": { "node_modules/pause": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11181,6 +11392,16 @@
"node": ">=8" "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": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -11308,21 +11529,27 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.22.0", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.22.0" "@prisma/config": "6.19.3",
"@prisma/engines": "6.19.3"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"
}, },
"engines": { "engines": {
"node": ">=16.13" "node": ">=18.18"
}, },
"optionalDependencies": { "peerDependencies": {
"fsevents": "2.3.3" "typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
} }
}, },
"node_modules/process-warning": { "node_modules/process-warning": {
@@ -11474,6 +11701,15 @@
"node": ">=0.10.0" "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": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -11497,7 +11733,6 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"engines": { "engines": {
"node": ">= 14.18.0" "node": ">= 14.18.0"
}, },
@@ -12593,6 +12828,14 @@
"real-require": "^0.2.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -12918,7 +13161,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
+5 -5
View File
@@ -55,7 +55,7 @@
"@nestjs/swagger": "^11.2.4", "@nestjs/swagger": "^11.2.4",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "5.22.0", "@prisma/client": "^6.19.3",
"axios": "^1.13.6", "axios": "^1.13.6",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.66.4", "bullmq": "^5.66.4",
@@ -75,7 +75,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pino": "^10.1.0", "pino": "^10.1.0",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"prisma": "5.22.0", "prisma": "^6.19.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"twitter-api-v2": "^1.29.0", "twitter-api-v2": "^1.29.0",
@@ -110,9 +110,6 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.20.0"
}, },
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",
@@ -129,5 +126,8 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
} }
} }
+14 -6
View File
@@ -1,7 +1,15 @@
module.exports = { import path from 'node:path';
datasource: { import { defineConfig, env } from 'prisma/config';
url: import { config } from 'dotenv';
process.env.DATABASE_URL ||
'postgresql://suggestbet:SuGGesT2026SecuRe@localhost:15432/boilerplate_db?schema=public', config({ path: '.env.local' });
export default defineConfig({
schema: path.join('prisma', 'schema.prisma'),
migrations: {
path: path.join('prisma', 'migrations'),
}, },
}; datasource: {
url: env('DATABASE_URL'),
},
});
+122 -98
View File
@@ -42,22 +42,22 @@ model League {
} }
model Team { model Team {
id String @id id String @id
name String name String
slug String? slug String?
sport Sport sport Sport
logoUrl String? @map("logo_url") logoUrl String? @map("logo_url")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
awayMatchesLive LiveMatch[] @relation("AwayTeamLive") awayMatchesLive LiveMatch[] @relation("AwayTeamLive")
homeMatchesLive LiveMatch[] @relation("HomeTeamLive") homeMatchesLive LiveMatch[] @relation("HomeTeamLive")
playerEvents MatchPlayerEvents[] playerEvents MatchPlayerEvents[]
playerParticipations MatchPlayerParticipation[] playerParticipations MatchPlayerParticipation[]
basketballPlayerStats BasketballPlayerStats[] basketballPlayerStats BasketballPlayerStats[]
footballTeamStats FootballTeamStats[] footballTeamStats FootballTeamStats[]
basketballTeamStats BasketballTeamStats[] basketballTeamStats BasketballTeamStats[]
awayMatches Match[] @relation("AwayTeam") awayMatches Match[] @relation("AwayTeam")
homeMatches Match[] @relation("HomeTeam") homeMatches Match[] @relation("HomeTeam")
eloRating TeamEloRating? eloRating TeamEloRating?
@@index([name]) @@index([name])
@@index([sport]) @@index([sport])
@@ -80,24 +80,24 @@ model Player {
} }
model Match { model Match {
id String @id id String @id
leagueId String? @map("league_id") leagueId String? @map("league_id")
homeTeamId String? @map("home_team_id") homeTeamId String? @map("home_team_id")
awayTeamId String? @map("away_team_id") awayTeamId String? @map("away_team_id")
sport Sport sport Sport
matchName String? @map("match_name") matchName String? @map("match_name")
matchSlug String? @map("match_slug") matchSlug String? @map("match_slug")
mstUtc BigInt @map("mst_utc") mstUtc BigInt @map("mst_utc")
status String? status String?
state String? state String?
scoreHome Int? @map("score_home") scoreHome Int? @map("score_home")
scoreAway Int? @map("score_away") scoreAway Int? @map("score_away")
htScoreHome Int? @map("ht_score_home") htScoreHome Int? @map("ht_score_home")
htScoreAway Int? @map("ht_score_away") htScoreAway Int? @map("ht_score_away")
winner String? winner String?
iddaaCode String? @map("iddaa_code") iddaaCode String? @map("iddaa_code")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
footballAiFeatures FootballAiFeature? footballAiFeatures FootballAiFeature?
basketballAiFeatures BasketballAiFeature? basketballAiFeatures BasketballAiFeature?
officials MatchOfficial[] officials MatchOfficial[]
@@ -106,11 +106,12 @@ model Match {
basketballPlayerStats BasketballPlayerStats[] basketballPlayerStats BasketballPlayerStats[]
footballTeamStats FootballTeamStats[] footballTeamStats FootballTeamStats[]
basketballTeamStats BasketballTeamStats[] basketballTeamStats BasketballTeamStats[]
awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id]) awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id])
homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id]) homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id])
league League? @relation(fields: [leagueId], references: [id]) league League? @relation(fields: [leagueId], references: [id])
oddCategories OddCategory[] oddCategories OddCategory[]
prediction Prediction? prediction Prediction?
predictionOutcomes PredictionOutcome[]
couponItems UserCouponItem[] couponItems UserCouponItem[]
@@index([awayTeamId]) @@index([awayTeamId])
@@ -270,25 +271,25 @@ model TeamEloRating {
} }
model MatchPlayerEvents { model MatchPlayerEvents {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
matchId String @map("match_id") matchId String @map("match_id")
playerId String @map("player_id") playerId String @map("player_id")
teamId String @map("team_id") teamId String @map("team_id")
eventType EventType @map("event_type") eventType EventType @map("event_type")
eventSubtype String? @map("event_subtype") eventSubtype String? @map("event_subtype")
timeMinute String @map("time_minute") timeMinute String @map("time_minute")
timeSeconds Int? @map("time_seconds") timeSeconds Int? @map("time_seconds")
periodId Int? @map("period_id") periodId Int? @map("period_id")
assistPlayerId String? @map("assist_player_id") assistPlayerId String? @map("assist_player_id")
scoreAfter String? @map("score_after") scoreAfter String? @map("score_after")
playerOutId String? @map("player_out_id") playerOutId String? @map("player_out_id")
position MatchPosition? position MatchPosition?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
assistPlayer Player? @relation("AssistPlayer", fields: [assistPlayerId], references: [id]) assistPlayer Player? @relation("AssistPlayer", fields: [assistPlayerId], references: [id])
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
player Player @relation("EventPlayer", fields: [playerId], references: [id], onDelete: Cascade) player Player @relation("EventPlayer", fields: [playerId], references: [id], onDelete: Cascade)
substitutedOut Player? @relation("SubstitutedOut", fields: [playerOutId], references: [id]) substitutedOut Player? @relation("SubstitutedOut", fields: [playerOutId], references: [id])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([assistPlayerId]) @@index([assistPlayerId])
@@index([eventType]) @@index([eventType])
@@ -319,28 +320,28 @@ model MatchPlayerParticipation {
} }
model BasketballPlayerStats { model BasketballPlayerStats {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
matchId String @map("match_id") matchId String @map("match_id")
playerId String @map("player_id") playerId String @map("player_id")
teamId String @map("team_id") teamId String @map("team_id")
minutes String? minutes String?
points Int? points Int?
rebounds Int? rebounds Int?
assists Int? assists Int?
steals Int? steals Int?
blocks Int? blocks Int?
turnovers Int? turnovers Int?
fgMade Int? @map("fg_made") fgMade Int? @map("fg_made")
fgAttempted Int? @map("fg_attempted") fgAttempted Int? @map("fg_attempted")
threePtMade Int? @map("three_pt_made") threePtMade Int? @map("three_pt_made")
threePtAttempted Int? @map("three_pt_attempted") threePtAttempted Int? @map("three_pt_attempted")
ftMade Int? @map("ft_made") ftMade Int? @map("ft_made")
ftAttempted Int? @map("ft_attempted") ftAttempted Int? @map("ft_attempted")
fouls Int? fouls Int?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade) player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([matchId, playerId, teamId]) @@unique([matchId, playerId, teamId])
@@index([matchId]) @@index([matchId])
@@ -429,13 +430,13 @@ model OfficialRole {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
model OddCategory { model OddCategory {
dbId Int @id @default(autoincrement()) @map("db_id") dbId Int @id @default(autoincrement()) @map("db_id")
matchId String @map("match_id") matchId String @map("match_id")
categoryJsonId Int? @map("category_json_id") categoryJsonId Int? @map("category_json_id")
name String? name String?
sport Sport? sport Sport?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) match Match @relation(fields: [matchId], references: [id], onDelete: Cascade)
selections OddSelection[] selections OddSelection[]
@@unique([matchId, name]) @@unique([matchId, name])
@@ -445,17 +446,17 @@ model OddCategory {
} }
model OddSelection { model OddSelection {
dbId Int @id @default(autoincrement()) @map("db_id") dbId Int @id @default(autoincrement()) @map("db_id")
categoryId Int @map("odd_category_db_id") categoryId Int @map("odd_category_db_id")
name String? name String?
oddValue String? @map("odd_value") oddValue String? @map("odd_value")
position String? position String?
sov Float? sov Float?
state String? state String?
sport Sport? sport Sport?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at") updatedAt DateTime @default(now()) @map("updated_at")
category OddCategory @relation(fields: [categoryId], references: [dbId], onDelete: Cascade) category OddCategory @relation(fields: [categoryId], references: [dbId], onDelete: Cascade)
history OddsHistory[] history OddsHistory[]
@@unique([categoryId, name]) @@unique([categoryId, name])
@@ -505,6 +506,29 @@ model PredictionRun {
@@map("prediction_runs") @@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 { model AiPredictionsLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
matchId String @map("match_id") matchId String @map("match_id")
@@ -553,20 +577,20 @@ model User {
} }
model Subscription { model Subscription {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique @map("user_id") userId String @unique @map("user_id")
paddleSubscriptionId String? @unique @map("paddle_subscription_id") paddleSubscriptionId String? @unique @map("paddle_subscription_id")
paddleCustomerId String? @map("paddle_customer_id") paddleCustomerId String? @map("paddle_customer_id")
plan SubscriptionStatus @default(free) plan SubscriptionStatus @default(free)
billingInterval BillingInterval? @map("billing_interval") billingInterval BillingInterval? @map("billing_interval")
currentPeriodStart DateTime? @map("current_period_start") currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end") currentPeriodEnd DateTime? @map("current_period_end")
cancelledAt DateTime? @map("cancelled_at") cancelledAt DateTime? @map("cancelled_at")
cancelEffectiveDate DateTime? @map("cancel_effective_date") cancelEffectiveDate DateTime? @map("cancel_effective_date")
paddlePriceId String? @map("paddle_price_id") paddlePriceId String? @map("paddle_price_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([paddleSubscriptionId]) @@index([paddleSubscriptionId])
@@index([paddleCustomerId]) @@index([paddleCustomerId])
+4
View File
@@ -1,4 +1,5 @@
import { Controller, Post, Body, HttpCode } from "@nestjs/common"; import { Controller, Post, Body, HttpCode } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { I18n, I18nContext } from "nestjs-i18n"; import { I18n, I18nContext } from "nestjs-i18n";
import { ApiTags, ApiOperation, ApiOkResponse } from "@nestjs/swagger"; import { ApiTags, ApiOperation, ApiOkResponse } from "@nestjs/swagger";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
@@ -21,6 +22,7 @@ export class AuthController {
@Post("register") @Post("register")
@Public() @Public()
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(200) @HttpCode(200)
@ApiOperation({ summary: "Register a new user" }) @ApiOperation({ summary: "Register a new user" })
@ApiOkResponse({ @ApiOkResponse({
@@ -37,6 +39,7 @@ export class AuthController {
@Post("login") @Post("login")
@Public() @Public()
@Throttle({ default: { limit: 5, ttl: 60000 } })
@HttpCode(200) @HttpCode(200)
@ApiOperation({ summary: "Login with email and password" }) @ApiOperation({ summary: "Login with email and password" })
@ApiOkResponse({ description: "Login successful", type: TokenResponseDto }) @ApiOkResponse({ description: "Login successful", type: TokenResponseDto })
@@ -50,6 +53,7 @@ export class AuthController {
@Post("refresh") @Post("refresh")
@Public() @Public()
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(200) @HttpCode(200)
@ApiOperation({ summary: "Refresh access token" }) @ApiOperation({ summary: "Refresh access token" })
@ApiOkResponse({ @ApiOkResponse({
@@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../../../database/prisma.service"; import { PrismaService } from "../../../database/prisma.service";
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -108,16 +109,21 @@ export class FrequencyEngineService {
venue: "home" | "away", venue: "home" | "away",
oddsBand: string, oddsBand: string,
): Promise<TeamFrequencyRow | null> { ): Promise<TeamFrequencyRow | null> {
const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id"; // venue is a typed literal ("home"|"away") — safe to use with Prisma.raw()
const oddsSelection = venue === "home" ? "'1'" : "'2'"; 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); const bandRange = this.parseBandRange(oddsBand);
if (!bandRange) { if (!bandRange) {
return null; return null;
} }
const rows = await this.prisma.$queryRawUnsafe<TeamFrequencyRow[]>( const rows = await this.prisma.$queryRaw<TeamFrequencyRow[]>(Prisma.sql`
`
WITH team_matches AS ( WITH team_matches AS (
SELECT SELECT
m.id AS match_id, m.id AS match_id,
@@ -127,32 +133,26 @@ export class FrequencyEngineService {
CAST(os.odd_value AS DECIMAL) AS team_odds CAST(os.odd_value AS DECIMAL) AS team_odds
FROM matches m FROM matches m
JOIN odd_categories oc ON oc.match_id = m.id AND oc.name = 'Maç Sonucu' 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' WHERE m.status = 'FT'
AND m.score_home IS NOT NULL AND m.score_home IS NOT NULL
AND ${venueColumn} = $1 AND ${venueColumnRaw} = ${teamId}
AND CAST(os.odd_value AS DECIMAL) >= $2 AND CAST(os.odd_value AS DECIMAL) >= ${bandRange.min}
AND CAST(os.odd_value AS DECIMAL) < $3 AND CAST(os.odd_value AS DECIMAL) < ${bandRange.max}
) )
SELECT SELECT
$1::text AS team_id, ${teamId}::text AS team_id,
$4::text AS venue, ${venue}::text AS venue,
$5::text AS odds_band, ${oddsBand}::text AS odds_band,
COUNT(*)::int AS total_matches, 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 > 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 > 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 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 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 COALESCE(AVG(total_goals), 0)::float AS avg_goals
FROM team_matches FROM team_matches
`, `);
teamId,
bandRange.min,
bandRange.max,
venue,
oddsBand,
);
if (!rows.length || rows[0].total_matches < MIN_MATCHES) { if (!rows.length || rows[0].total_matches < MIN_MATCHES) {
return null; return null;
@@ -335,8 +335,7 @@ export class FrequencyEngineService {
if (matchIds && matchIds.length > 0) { if (matchIds && matchIds.length > 0) {
// Belirli maçlar istendi // Belirli maçlar istendi
return this.prisma.$queryRawUnsafe<UpcomingMatchRow[]>( return this.prisma.$queryRaw<UpcomingMatchRow[]>(Prisma.sql`
`
SELECT SELECT
lm.id AS match_id, lm.id AS match_id,
lm.home_team_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 ht ON lm.home_team_id = ht.id
LEFT JOIN teams at ON lm.away_team_id = at.id LEFT JOIN teams at ON lm.away_team_id = at.id
LEFT JOIN leagues l ON lm.league_id = l.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 IS NOT NULL
AND lm.odds != 'null'::jsonb AND lm.odds != 'null'::jsonb
ORDER BY lm.mst_utc ASC ORDER BY lm.mst_utc ASC
`, `);
matchIds,
);
} }
// Otomatik: yaklaşan tüm maçlar // Otomatik: yaklaşan tüm maçlar
return this.prisma.$queryRawUnsafe<UpcomingMatchRow[]>( return this.prisma.$queryRaw<UpcomingMatchRow[]>(Prisma.sql`
`
SELECT SELECT
lm.id AS match_id, lm.id AS match_id,
lm.home_team_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 ht ON lm.home_team_id = ht.id
LEFT JOIN teams at ON lm.away_team_id = at.id LEFT JOIN teams at ON lm.away_team_id = at.id
LEFT JOIN leagues l ON lm.league_id = l.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.sport = 'football'
AND lm.odds IS NOT NULL AND lm.odds IS NOT NULL
AND lm.odds != 'null'::jsonb 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.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')) AND (lm.state IS NULL OR lm.state NOT IN ('after', 'postponed', 'cancelled', 'abandoned'))
ORDER BY lm.mst_utc ASC ORDER BY lm.mst_utc ASC
LIMIT $2 LIMIT ${limit}
`, `);
BigInt(nowMs),
limit,
);
} }
/** /**
* Lig bazlı gol profili. * Lig bazlı gol profili.
*/ */
async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> { async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> {
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>( const rows = await this.prisma.$queryRaw<LeagueProfileRow[]>(Prisma.sql`
`
SELECT SELECT
m.league_id, m.league_id,
l.name AS league_name, l.name AS league_name,
@@ -424,12 +416,10 @@ export class FrequencyEngineService {
JOIN leagues l ON m.league_id = l.id JOIN leagues l ON m.league_id = l.id
WHERE m.status = 'FT' WHERE m.status = 'FT'
AND m.score_home IS NOT NULL AND m.score_home IS NOT NULL
AND m.league_id = $1 AND m.league_id = ${leagueId}
GROUP BY m.league_id, l.name GROUP BY m.league_id, l.name
HAVING COUNT(*) >= 20 HAVING COUNT(*) >= 20
`, `);
leagueId,
);
return rows.length > 0 ? rows[0] : null; return rows.length > 0 ? rows[0] : null;
} }
@@ -6,6 +6,7 @@
*/ */
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../../database/prisma.service"; import { PrismaService } from "../../database/prisma.service";
import { import {
Sport, Sport,
@@ -858,11 +859,11 @@ export class FeederPersistenceService {
// Use raw SQL for performance — Prisma's { some: {} } relation filters // Use raw SQL for performance — Prisma's { some: {} } relation filters
// generate heavy correlated subqueries that hang on Raspberry Pi with // generate heavy correlated subqueries that hang on Raspberry Pi with
// large tables (15M+ odd_selections, 3M+ participations). // large tables (15M+ odd_selections, 3M+ participations).
const result = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>( const result = await this.prisma.$queryRaw<Array<{ id: string }>>(
` Prisma.sql`
SELECT m.id SELECT m.id
FROM matches m 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 EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
AND ( AND (
(m.sport = 'football' (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)) AND EXISTS (SELECT 1 FROM basketball_player_stats bps WHERE bps.match_id = m.id))
) )
`, `,
matchIds,
); );
return result.map((r) => r.id); return result.map((r) => r.id);