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'),
},
});
+24
View File
@@ -111,6 +111,7 @@ model Match {
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])
@@ -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")
+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);