v26-shadow #2
+10
-1
@@ -16,6 +16,7 @@ from pydantic import BaseModel
|
||||
|
||||
from models.basketball_v25 import get_basketball_v25_predictor
|
||||
from services.single_match_orchestrator import get_single_match_orchestrator
|
||||
from services.v26_shadow_engine import get_v26_shadow_engine
|
||||
from data.database import dispose_engine
|
||||
|
||||
load_dotenv()
|
||||
@@ -38,6 +39,7 @@ async def lifespan(_: FastAPI):
|
||||
try:
|
||||
print("🚀 Initializing V25 orchestrator...", flush=True)
|
||||
get_single_match_orchestrator()
|
||||
get_v26_shadow_engine()
|
||||
print("✅ V25 orchestrator ready", flush=True)
|
||||
except Exception as error:
|
||||
print(f"❌ Failed to initialize orchestrator: {error}", flush=True)
|
||||
@@ -104,6 +106,7 @@ def read_root() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "Suggest-Bet AI Engine v25",
|
||||
"engine": "V25 Single Match Orchestrator",
|
||||
"mode": os.getenv("AI_ENGINE_MODE", "v25"),
|
||||
"routes": [
|
||||
"POST /v20plus/analyze/{match_id}",
|
||||
"GET /v20plus/analyze-htms/{match_id}",
|
||||
@@ -118,15 +121,21 @@ def read_root() -> dict[str, Any]:
|
||||
@app.get("/health")
|
||||
def health_check() -> dict[str, Any]:
|
||||
try:
|
||||
get_single_match_orchestrator()
|
||||
orchestrator = get_single_match_orchestrator()
|
||||
shadow_engine = get_v26_shadow_engine()
|
||||
basketball_predictor = get_basketball_v25_predictor()
|
||||
basketball_readiness = basketball_predictor.readiness_summary()
|
||||
ready = bool(basketball_readiness["fully_loaded"])
|
||||
return {
|
||||
"status": "healthy" if ready else "degraded",
|
||||
"engine": "v25.main",
|
||||
"mode": os.getenv("AI_ENGINE_MODE", "v25"),
|
||||
"ready": ready,
|
||||
"basketball_v25": basketball_readiness,
|
||||
"v26_shadow": shadow_engine.readiness_summary(),
|
||||
"prediction_service_ready": True,
|
||||
"model_loaded": ready,
|
||||
"orchestrator_mode": getattr(orchestrator, "engine_mode", "v25"),
|
||||
}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "ready": False, "error": str(error)}
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
[
|
||||
{
|
||||
"match_id": "2b1jyd72hogojec5j50fd9gr8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "1b2chhfsohmulm85sb95y189g",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "dg84sd1wkmtfrtdm9od7wy7f8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "Alt",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dydrdtrxi3dsomph1at54jaxg",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0264,
|
||||
"avg_confidence": 69.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.1559,
|
||||
"avg_confidence": 71.4
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "b6uzz042mizu0dqpci538z4lw",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.1515,
|
||||
"avg_confidence": 66.3
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.1103,
|
||||
"avg_confidence": 64.45
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dmp0q35bpbb7rt11opg5mwzkk",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0756,
|
||||
"avg_confidence": 67.5
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "6opr8muwfdoosfpnkm4gbb190",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.384,
|
||||
"avg_confidence": 80.8
|
||||
},
|
||||
"v25_main": "Tek",
|
||||
"v26_main": "KG Var"
|
||||
},
|
||||
{
|
||||
"match_id": "a6dxspn0akrnf19mno8z83yms",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.001,
|
||||
"avg_confidence": 72.5
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "9qrr3sya0mlfusmqlushjask4",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "2",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "ytsc38rm4j22govgwo3as6j8",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1251,
|
||||
"avg_confidence": 67.6
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "X2",
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "68oi5t06w15a9b8wt8rsl8gk",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "KG Yok",
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "bsw4axalza4idsop3qio295hw",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0113,
|
||||
"avg_confidence": 69.6
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.9893,
|
||||
"avg_confidence": 59.1
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "btsq93obda94w3be5bogr0etw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "ajgyxy5iqiprjkr2l98np982c",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0166,
|
||||
"avg_confidence": 69.5
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "4ocwqbbw37kikqj38hf4txes4",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0755,
|
||||
"avg_confidence": 67.5
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.0585,
|
||||
"avg_confidence": 74.65
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dogkmluzn54lg0q6yuom1l53o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.2566,
|
||||
"avg_confidence": 81.7
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "cfar57gsu6hy770n7e58u8duc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "cbg4zpl58ahr6r22d6syo0wt0",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "1X",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "5psd7wioj63day6cylflbzf2s",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "3.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "570ybzbhwkgvef82h49c27eac",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "5vaa5dl47mw2t6xf3t3qjhvro",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "9pqea93iqumda82hj1sfxd7v8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "cjs180a7sqxkurwo18rp9aeqc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "3j2wsty5be0d432ozvuj5w2l0",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1947,
|
||||
"avg_confidence": 71.4
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.091,
|
||||
"avg_confidence": 68.6
|
||||
},
|
||||
"v25_main": "1X",
|
||||
"v26_main": "3.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "3toy2ctfu4kce9ojkngofzf2s",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.0557,
|
||||
"avg_confidence": 67.25
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "KG Yok"
|
||||
},
|
||||
{
|
||||
"match_id": "5uvgaveseimkwkkj5lf0iv9xw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "8i2hjmknzgjkhfhn9pehrhy50",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "rfhmiaml1h9taxebpvmaijo4",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "25sjaorwswwaeu3p2hd3p747o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "d8e65nskkka2b6s1cr3rks1ec",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.7042,
|
||||
"avg_confidence": 61.7
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "fbxbk21jlzgmf0dc385mu6fo",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.5759,
|
||||
"avg_confidence": 57.1
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "9i8ikoed9f94nt90w8uy6y710",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.8784,
|
||||
"avg_confidence": 56.4
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "2iw74m76pj2ibdu9igq7u6ql0",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.6571,
|
||||
"avg_confidence": 60.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "3yt7t944i6ftaok5b799fa784",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "99r0d7ggi1169dhklo4eroopg",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0266,
|
||||
"avg_confidence": 69.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 4.0,
|
||||
"avg_edge": 0.2509,
|
||||
"avg_confidence": 76.65
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "63lonant3zu1u7xmpomocetw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 2.0,
|
||||
"avg_edge": 0.179,
|
||||
"avg_confidence": 66.6
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "790qnaweqoyffb5ndxnb4hlas",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.1067,
|
||||
"avg_confidence": 74.5
|
||||
},
|
||||
"v25_main": "2/2",
|
||||
"v26_main": "2.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "55bckv97w88qeqymhqgwg9fdg",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "1X",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "ir507rn2anyknjvm1xua1c7o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "Alt",
|
||||
"v26_main": "2.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "d9h5hdhwum2s04ma722emljx0",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.2061,
|
||||
"avg_confidence": 79.9
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "3riltqclv2llihnklpcw6u784",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "8808y1x2hmz52k3mr598orqc4",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.2221,
|
||||
"avg_confidence": 67.9
|
||||
},
|
||||
"v25_main": "KG Var",
|
||||
"v26_main": "KG Var"
|
||||
},
|
||||
{
|
||||
"match_id": "5qypw3tl5qkx16ihhbifv9jis",
|
||||
"v25": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0979,
|
||||
"avg_confidence": 66.9
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 3.0,
|
||||
"avg_edge": 0.1071,
|
||||
"avg_confidence": 66.3
|
||||
},
|
||||
"v25_main": "\u00dcst",
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "710vie8tgz5ccs7sq9xwrjmz8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.2892,
|
||||
"avg_confidence": 82.9
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "2vkbdwkxyjj1invl5877bz0us",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "7caglruhdiegg9xrc20fzhrtg",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "gl3nvw4hprtny0aby1z40duc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "f87f0jsazmdhlq3wzz7ngp3o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "X2",
|
||||
"v26_main": "2.5 Alt"
|
||||
},
|
||||
{
|
||||
"match_id": "chb58495fc1o6ek9t9kdfbyfo",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.0572,
|
||||
"avg_confidence": 75.5
|
||||
},
|
||||
"v25_main": "1",
|
||||
"v26_main": "KG Var"
|
||||
},
|
||||
{
|
||||
"match_id": "57h5qfr5mr4qoxix7vhiwd2j8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1927,
|
||||
"avg_confidence": 79.4
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "de76ipnpgaznxvl9tk9ewquqc",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "5pjdtn5bb9v64cj4ceh4ei1hw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": "\u00c7ift",
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "6orrk7jpn3ucpybdieh6oin84",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "3shwt6aecvnu9utm2go9diq6s",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.674,
|
||||
"avg_confidence": 60.6
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "5kukkba6u6wq1xj8oi118q1hw",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.8906,
|
||||
"avg_confidence": 56.7
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "9fzu7f8aybsqy88r3nam79p1w",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "2"
|
||||
},
|
||||
{
|
||||
"match_id": "bxcnq91mnr9f6bhicdxgzbims",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1.5 \u00dcst"
|
||||
},
|
||||
{
|
||||
"match_id": "dueticp36nbckn2pnpnt6az8",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "X"
|
||||
},
|
||||
{
|
||||
"match_id": "5m9do9pcggnl0tgmeolvnph5g",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 0.6914,
|
||||
"avg_confidence": 61.3
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
},
|
||||
{
|
||||
"match_id": "avu5vzpdi1wy7mrneodb3gw7o",
|
||||
"v25": {
|
||||
"playable_count": 0.0,
|
||||
"avg_edge": 0.0,
|
||||
"avg_confidence": 0.0
|
||||
},
|
||||
"v26": {
|
||||
"playable_count": 1.0,
|
||||
"avg_edge": 1.1608,
|
||||
"avg_confidence": 78.3
|
||||
},
|
||||
"v25_main": null,
|
||||
"v26_main": "1"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
match_id,date,league,match,ht_score,final_score,strategy,market,pick,odds,playable,confidence,result,counted_in_roi,profit_flat,resolution_note,source,reversal_pick,reversal_prob,favorite_gap,favorite_odd,support_score,odds_band_score,odds_band_label,league_reversal_rate,league_strict_rev_rate,referee_strict_rev_rate,surprise_score,reason_codes,pick_reason
|
||||
b6uzz042mizu0dqpci538z4lw,2025-09-06,Süper Kupa,V. Sarsfield vs C. Cordoba,0-0,2-0,v25_aggressive,HTFT,2/1,34.5,True,21.0,LOST,True,-1.0,actual=X/1,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
b6uzz042mizu0dqpci538z4lw,2025-09-06,Süper Kupa,V. Sarsfield vs C. Cordoba,0-0,2-0,v26_aggressive,HTFT,1/1,3.26,True,16.0,LOST,True,-1.0,actual=X/1,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
ytsc38rm4j22govgwo3as6j8,2025-09-06,DK Elemeler,Avusturya vs G. Kıbrıs Rum Kesimi,0-0,1-0,v25_aggressive,HTFT,X/1,4.01,True,11.4,WON,True,3.01,actual=X/1,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
cfar57gsu6hy770n7e58u8duc,2025-09-06,Eerste Divisie,Cambuur vs Willem II,0-1,2-2,v25_aggressive,HTFT,2/1,20.7,True,15.6,LOST,True,-1.0,actual=2/X,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
cfar57gsu6hy770n7e58u8duc,2025-09-06,Eerste Divisie,Cambuur vs Willem II,0-1,2-2,v26_aggressive,HTFT,1/1,2.13,True,14.4,LOST,True,-1.0,actual=2/X,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
99r0d7ggi1169dhklo4eroopg,2025-09-06,2. Lig,Bromley vs Gillingham,2-0,2-2,v25_aggressive,HTFT,2/1,30.5,True,25.6,LOST,True,-1.0,actual=1/X,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
99r0d7ggi1169dhklo4eroopg,2025-09-06,2. Lig,Bromley vs Gillingham,2-0,2-2,v26_surprise,HTFT,1/2,34.5,False,4.9,LOST,False,0.0,actual=1/X,v26.surprise_pick,1/2,0.0679,1.02,1.92,73.5,0.642,,0.04,0.0,0.0,62.7,"favorite_gap_large,favorite_price_supported,reversal_prob_warm,quality_supports_reversal,favorite_odds_band_reversal_window,favorite_streak_break_window",
|
||||
99r0d7ggi1169dhklo4eroopg,2025-09-06,2. Lig,Bromley vs Gillingham,2-0,2-2,v26_aggressive,HTFT,1/1,3.16,True,16.1,LOST,True,-1.0,actual=1/X,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
790qnaweqoyffb5ndxnb4hlas,2025-09-06,Ulusal Lig,Aldershot vs Brackley,1-0,2-2,v25_aggressive,HTFT,X/2,6.07,True,9.0,LOST,True,-1.0,actual=1/X,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
790qnaweqoyffb5ndxnb4hlas,2025-09-06,Ulusal Lig,Aldershot vs Brackley,1-0,2-2,v26_aggressive,HTFT,2/2,3.8,True,37.1,LOST,True,-1.0,actual=1/X,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
8808y1x2hmz52k3mr598orqc4,2025-09-06,DK Elemeler,Ermenistan vs Portekiz,0-3,0-5,v25_aggressive,HTFT,X/2,4.03,True,11.6,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
7gfnwoxqz5o2clin40a85uqdw,2025-09-06,1. Lig,Port Vale vs Leyton Orient,1-2,2-3,v25_aggressive,HTFT,X/1,4.84,True,12.3,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
7gfnwoxqz5o2clin40a85uqdw,2025-09-06,1. Lig,Port Vale vs Leyton Orient,1-2,2-3,v26_surprise,HTFT,1/2,33.0,False,5.4,LOST,False,0.0,actual=2/2,v26.surprise_pick,1/2,0.0749,0.62,1.97,52.1,0.642,,0.044,0.0,0.0,57.0,"favorite_gap_large,favorite_price_supported,reversal_prob_warm,draw_swing_support,quality_supports_reversal,favorite_odds_band_reversal_window",
|
||||
7gfnwoxqz5o2clin40a85uqdw,2025-09-06,1. Lig,Port Vale vs Leyton Orient,1-2,2-3,v26_aggressive,HTFT,2/2,4.51,True,17.3,WON,True,3.51,actual=2/2,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
7fld91ykj1kfuoc8wn4r2frbo,2025-09-06,1. Lig,Lincoln City vs Wigan Ath,2-1,2-2,v25_aggressive,HTFT,X/1,4.7,True,19.2,LOST,True,-1.0,actual=1/X,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
7fld91ykj1kfuoc8wn4r2frbo,2025-09-06,1. Lig,Lincoln City vs Wigan Ath,2-1,2-2,v26_surprise,HTFT,1/2,34.5,False,7.1,LOST,False,0.0,actual=1/X,v26.surprise_pick,1/2,0.0984,0.71,2.0,61.5,0.642,,0.044,0.0,0.0,61.7,"favorite_gap_large,favorite_price_supported,reversal_prob_hot,upset_risk_detected,quality_supports_reversal,favorite_odds_band_reversal_window",
|
||||
7fld91ykj1kfuoc8wn4r2frbo,2025-09-06,1. Lig,Lincoln City vs Wigan Ath,2-1,2-2,v26_aggressive,HTFT,1/1,3.36,True,19.5,LOST,True,-1.0,actual=1/X,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
7i4nhkex1qssyp3x6rsgj03ro,2025-09-06,1. Lig,Wycombe vs Mansfield,1-0,2-0,v25_aggressive,HTFT,X/2,6.92,True,11.1,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
7i4nhkex1qssyp3x6rsgj03ro,2025-09-06,1. Lig,Wycombe vs Mansfield,1-0,2-0,v26_main_htft,HTFT,2/2,5.22,False,39.9,LOST,False,0.0,actual=1/1,v26.main_pick,,,,,,,,,,,0.0,,
|
||||
7hagai8xazmsj5exw7idwhhck,2025-09-06,1. Lig,Rotherham vs Exeter City,1-0,1-0,v25_aggressive,HTFT,X/2,6.28,True,6.7,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
7hagai8xazmsj5exw7idwhhck,2025-09-06,1. Lig,Rotherham vs Exeter City,1-0,1-0,v26_aggressive,HTFT,2/2,4.47,True,29.6,LOST,True,-1.0,actual=1/1,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
6e37x17qvk0snpokd1698lkb8,2025-09-06,Premiership,Crusaders vs Coleraine,0-3,0-4,v25_aggressive,HTFT,X/2,4.24,True,11.5,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
6e37x17qvk0snpokd1698lkb8,2025-09-06,Premiership,Crusaders vs Coleraine,0-3,0-4,v26_aggressive,HTFT,2/2,2.32,True,37.7,WON,True,1.32,actual=2/2,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
6f0fqlafaei9oj8yd9hdi6rdg,2025-09-06,Premiership,Linfield vs Portadown,0-0,3-0,v25_aggressive,HTFT,2/1,23.2,True,18.6,LOST,True,-1.0,actual=X/1,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
6f0fqlafaei9oj8yd9hdi6rdg,2025-09-06,Premiership,Linfield vs Portadown,0-0,3-0,v26_aggressive,HTFT,1/1,1.74,True,20.4,LOST,True,-1.0,actual=X/1,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
6dny1bmxcj6shc5382dno2al0,2025-09-06,Premiership,Carrick vs Cliftonville,0-1,1-2,v25_aggressive,HTFT,2/1,30.5,True,14.4,LOST,True,-1.0,actual=2/2,v25.aggressive_pick,2/1,,,,,,,,,,,,
|
||||
6dny1bmxcj6shc5382dno2al0,2025-09-06,Premiership,Carrick vs Cliftonville,0-1,1-2,v26_surprise,HTFT,2/1,30.5,False,10.1,LOST,False,0.0,actual=2/2,v26.surprise_pick,2/1,0.1401,0.59,1.97,62.8,0.642,,0.0667,0.0,0.0,66.2,"favorite_price_supported,reversal_prob_hot,quality_supports_reversal,favorite_odds_band_reversal_window,league_strict_reversal_prior,draw_pressure_supports_swing",
|
||||
6dny1bmxcj6shc5382dno2al0,2025-09-06,Premiership,Carrick vs Cliftonville,0-1,1-2,v26_aggressive,HTFT,1/1,4.36,True,14.6,LOST,True,-1.0,actual=2/2,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
9d9nwo82prx06riy5odgnr190,2025-09-06,2. Lig,Walsall vs Chesterfield,1-0,1-0,v25_aggressive,HTFT,X/2,5.38,True,10.3,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/2,,,,,,,,,,,,
|
||||
9d9nwo82prx06riy5odgnr190,2025-09-06,2. Lig,Walsall vs Chesterfield,1-0,1-0,v26_aggressive,HTFT,2/2,3.78,True,35.1,LOST,True,-1.0,actual=1/1,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
7dwtx5tr7g66bsqkoc1wos9as,2025-09-06,1. Lig,Bolton vs Wimbledon,1-0,3-0,v25_aggressive,HTFT,X/1,3.76,True,12.1,LOST,True,-1.0,actual=1/1,v25.aggressive_pick,X/1,,,,,,,,,,,,
|
||||
7dwtx5tr7g66bsqkoc1wos9as,2025-09-06,1. Lig,Bolton vs Wimbledon,1-0,3-0,v26_aggressive,HTFT,1/1,1.81,True,33.3,WON,True,0.81,actual=1/1,v26.aggressive_pick,1/1,,,,,,,,,,,,
|
||||
7c85gguekhbhadtm7qdbgir6c,2025-09-06,Ulusal Lig,Tamworth vs Eastleigh,0-0,1-0,v25_aggressive,HTFT,1/2,34.5,True,14.1,LOST,True,-1.0,actual=X/1,v25.aggressive_pick,1/2,,,,,,,,,,,,
|
||||
7c85gguekhbhadtm7qdbgir6c,2025-09-06,Ulusal Lig,Tamworth vs Eastleigh,0-0,1-0,v26_aggressive,HTFT,2/2,5.53,True,14.8,LOST,True,-1.0,actual=X/1,v26.aggressive_pick,2/2,,,,,,,,,,,,
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
# HT/FT + Upset Backtest
|
||||
|
||||
- Sample: last 120 finished football matches
|
||||
- Scope: only HT/FT reversal and upset-oriented picks
|
||||
- ROI: flat `1 unit` per played pick
|
||||
- Generated at: 2026-04-21T12:32:14.419378+00:00
|
||||
|
||||
## Strategy Summary
|
||||
|
||||
| Strategy | Candidates | Played | Won | Lost | Hit Rate | Profit | ROI |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| v25_aggressive | 16 | 16 | 1 | 15 | 6.25% | -11.99 | -74.94% |
|
||||
| v26_surprise | 4 | 0 | 0 | 0 | 0.0% | +0.00 | +0.00% |
|
||||
| v26_aggressive | 13 | 13 | 3 | 10 | 23.08% | -4.36 | -33.54% |
|
||||
| v26_main_htft | 1 | 0 | 0 | 0 | 0.0% | +0.00 | +0.00% |
|
||||
|
||||
## v26 Surprise By Reversal Type
|
||||
|
||||
| Reversal | Candidates | Played | Won | Lost | Profit | ROI |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 1/2 | 3 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
| 2/1 | 1 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
| X/1 | 0 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
| X/2 | 0 | 0 | 0 | 0 | +0.00 | +0.00% |
|
||||
|
||||
## Match Detail
|
||||
|
||||
| Date | Match | HT | FT | v25 aggressive | v26 surprise | v26 aggressive | v26 main HTFT |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 2025-09-06 | Forge FC vs Hfx Wan | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Estoril vs Santa Clara | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Tenerife vs Merida AD | 1-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Ibiza vs Hercules | 2-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | V. Sarsfield vs C. Cordoba | 0-0 | 2-0 | 2/1 (LOST, played, -1.00) | - | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Botafogo vs Paranaense | 0-1 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | San Felipe vs Curico Unido | 2-0 | 4-2 | - | - | - | - |
|
||||
| 2025-09-06 | Nacional Potosi vs Independiente | 0-1 | 0-3 | - | - | - | - |
|
||||
| 2025-09-06 | Avusturya vs G. Kıbrıs Rum Kesimi | 0-0 | 1-0 | X/1 (WON, played, +3.01) | - | - | - |
|
||||
| 2025-09-06 | CD Estepona FS vs Linares | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Pergolettese vs Cittadella | 2-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Union Brescia vs Pro Vercelli | 2-0 | 5-0 | - | - | - | - |
|
||||
| 2025-09-06 | Casertana vs Potenza | 1-1 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Montevideo vs Danubio | 0-2 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Hoogstraten vs W. Beveren | 0-0 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | Cambuur vs Willem II | 0-1 | 2-2 | 2/1 (LOST, played, -1.00) | - | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Tlaxcala vs A. Oaxaca | 1-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | JS Kabylie vs Olympique Akbou | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | CD A. Baleares vs Castellon II | 3-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Karlovac 1919 vs Dugopolje | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | San Sebastian R. vs RSD Alcala | 1-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Hindistan U23 vs Katar U23 | 0-1 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Estradense vs Boiro | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Yeclano II vs El Palmar | 1-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Montlouis vs La Roche | 0-3 | 1-4 | - | - | - | - |
|
||||
| 2025-09-06 | L'Hospitalet vs San Cristobal | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Marchamalo vs Guadalajara II | 1-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Real Zaragoza vs R. Valladolid | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Bromley vs Gillingham | 2-0 | 2-2 | 2/1 (LOST, played, -1.00) | 1/2 (LOST, not played, +0.00) | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Ajman Club vs Al Wahda | 2-1 | 2-4 | - | - | - | - |
|
||||
| 2025-09-06 | Aldershot vs Brackley | 1-0 | 2-2 | X/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Barbastro vs UE Olot | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | CA Atlanta vs Guemes | 1-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Pulpileno vs UCAM Murcia II | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Mojados vs Santa Marta | 0-2 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | Ermenistan vs Portekiz | 0-3 | 0-5 | X/2 (LOST, played, -1.00) | - | - | - |
|
||||
| 2025-09-06 | USM Khenchela vs CR Belouizdad | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Nafta vs Gorica | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | CS Cerrito vs Atenas | 0-0 | 0-3 | - | - | - | - |
|
||||
| 2025-09-06 | VfB Oldenburg vs Hannoverscher | 4-1 | 6-1 | - | - | - | - |
|
||||
| 2025-09-06 | Lealtad vs Numancia | 1-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Astorga vs Real Avila | 0-0 | 1-3 | - | - | - | - |
|
||||
| 2025-09-06 | Grindavik vs IR Reykjavik | 2-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Andratx vs Sant Andreu | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Arenas Getxo vs Cacereno | 2-1 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Aegir vs Dalvik / Reynir | 1-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Laval II vs Cesson | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Palencia CF vs Arandina | 3-0 | 4-0 | - | - | - | - |
|
||||
| 2025-09-06 | Leioa vs Pasaia | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Vic vs UE Cornella | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Voltigeurs vs Granville | 1-2 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Bobigny vs Creteil | 0-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Beauvais vs Furiani Agliani | 0-0 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Oissel vs Caen II | 0-1 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Cartagena LU II vs Un. Molinense | 0-2 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Hercules II vs Atzeneta | 0-1 | 0-3 | - | - | - | - |
|
||||
| 2025-09-06 | Sassari vs Pianese | 0-0 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Giugliano vs Foggia | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Sorrento vs Trapani 1905 | 0-0 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Ascoli vs Juventus U23 | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Arezzo vs Vis Pesaro | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Ath. Carpi vs Campobasso | 0-0 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Oviedo II vs Sarriana | 2-0 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Llosetense vs Platges | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Le Havre (K) vs Strasbourg (K) | 2-2 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | Montpellier (K) vs Fleury 91 (K) | 0-1 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Bistrica vs NK Krsko | 3-0 | 6-0 | - | - | - | - |
|
||||
| 2025-09-06 | Bilje vs Dravinja | 1-0 | 2-0 | - | - | - | - |
|
||||
| 2025-09-06 | Nantes (K) vs Saint-Etienne (K) | 2-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Jadran Dekani vs NK Ilirija | 3-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Tabor Sezana vs Jesenice | 0-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Alaves II vs Logrones | 0-0 | 2-0 | - | - | - | - |
|
||||
| 2025-09-06 | Unionistas II vs Tordesillas | 1-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Leganes II vs Alcorcon II | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Volna Pinsk vs Bumprom | 0-1 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | L. Mikulas vs S. Bratislava II | 0-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Dubrava vs Hrvace | 0-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Plymouth vs Stockport | 2-1 | 4-2 | - | - | - | - |
|
||||
| 2025-09-06 | Port Vale vs Leyton Orient | 1-2 | 2-3 | X/1 (LOST, played, -1.00) | 1/2 (LOST, not played, +0.00) | 2/2 (WON, played, +3.51) | - |
|
||||
| 2025-09-06 | Huddersfield vs Peterborough | 0-0 | 3-2 | - | - | - | - |
|
||||
| 2025-09-06 | Lincoln City vs Wigan Ath | 2-1 | 2-2 | X/1 (LOST, played, -1.00) | 1/2 (LOST, not played, +0.00) | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Wycombe vs Mansfield | 1-0 | 2-0 | X/2 (LOST, played, -1.00) | - | - | 2/2 (LOST, not played, +0.00) |
|
||||
| 2025-09-06 | Rotherham vs Exeter City | 1-0 | 1-0 | X/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Ballymena Utd vs Glentoran | 0-2 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Crusaders vs Coleraine | 0-3 | 0-4 | X/2 (LOST, played, -1.00) | - | 2/2 (WON, played, +1.32) | - |
|
||||
| 2025-09-06 | Glenavon vs Dungannon | 0-2 | 0-2 | - | - | - | - |
|
||||
| 2025-09-06 | Linfield vs Portadown | 0-0 | 3-0 | 2/1 (LOST, played, -1.00) | - | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Colchester vs Crewe | 0-1 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | G. Morton vs Raith Rovers | 0-0 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Puchov vs Pohronie | 0-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Carrick vs Cliftonville | 0-1 | 1-2 | 2/1 (LOST, played, -1.00) | 2/1 (LOST, not played, +0.00) | 1/1 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Harrogate vs Crawley Town | 0-1 | 0-1 | - | - | - | - |
|
||||
| 2025-09-06 | Walsall vs Chesterfield | 1-0 | 1-0 | X/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Banik Lehota vs Samorin | 1-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Bolton vs Wimbledon | 1-0 | 3-0 | X/1 (LOST, played, -1.00) | - | 1/1 (WON, played, +0.81) | - |
|
||||
| 2025-09-06 | Edinburgh vs Hearts II | 4-1 | 4-2 | - | - | - | - |
|
||||
| 2025-09-06 | Kelty Hearts vs Stranraer | 0-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Forfar vs Dundee II | 2-1 | 4-1 | - | - | - | - |
|
||||
| 2025-09-06 | Spartans vs Hibernian II | 2-0 | 5-1 | - | - | - | - |
|
||||
| 2025-09-06 | East Kilbride vs Hamilton | 0-2 | 2-4 | - | - | - | - |
|
||||
| 2025-09-06 | Annan Ath vs St. Mirren II | 0-1 | 2-2 | - | - | - | - |
|
||||
| 2025-09-06 | East Fife vs Dumbarton | 1-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Clyde vs Motherwell II | 3-0 | 4-0 | - | - | - | - |
|
||||
| 2025-09-06 | Peterhead vs Dundee United II | 2-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Montrose vs Aberdeen II | 2-1 | 3-1 | - | - | - | - |
|
||||
| 2025-09-06 | Elgin City vs Cove Rangers | 1-0 | 1-0 | - | - | - | - |
|
||||
| 2025-09-06 | Queen Of S. vs Rangers II | 0-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Boston Utd vs Solihull | 1-1 | 1-2 | - | - | - | - |
|
||||
| 2025-09-06 | Carlisle vs Truro | 2-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Southend vs Halifax | 1-0 | 3-0 | - | - | - | - |
|
||||
| 2025-09-06 | Woking vs Gateshead | 2-0 | 5-0 | - | - | - | - |
|
||||
| 2025-09-06 | Tamworth vs Eastleigh | 0-0 | 1-0 | 1/2 (LOST, played, -1.00) | - | 2/2 (LOST, played, -1.00) | - |
|
||||
| 2025-09-06 | Alcorcon vs Teruel | 1-1 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Merthyr vs Worksop | 0-0 | 2-0 | - | - | - | - |
|
||||
| 2025-09-06 | Chesham Utd vs Bath | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Southport vs South Shields | 0-0 | 0-0 | - | - | - | - |
|
||||
| 2025-09-06 | Kidderminster vs Macclesfield | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Throttur Vogar vs Höttur / Huginn | 0-0 | 2-1 | - | - | - | - |
|
||||
| 2025-09-06 | Alfreton vs Kings Lynn | 0-0 | 1-1 | - | - | - | - |
|
||||
| 2025-09-06 | Buxton vs Oxford City | 1-0 | 2-1 | - | - | - | - |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "v26.shadow.0",
|
||||
"calibration_version": "v26.shadow.calib.0",
|
||||
"train_rows": 6853,
|
||||
"validation_rows": 1469,
|
||||
"label_priors": {
|
||||
"MS": 0.4404,
|
||||
"OU25": 0.5214,
|
||||
"BTTS": 0.5398,
|
||||
"HT": 0.4275,
|
||||
"HTFT": 0.26,
|
||||
"CARDS": 0.6052
|
||||
},
|
||||
"artifact_path": "/Users/piton/Documents/GitHub/iddaai/iddaai-be/ai-engine/models/v26_shadow/market_profiles.json",
|
||||
"notes": [
|
||||
"v26.shadow runtime currently uses artifact-based calibration and ROI gating",
|
||||
"market profile JSON remains the source of truth for runtime thresholds"
|
||||
]
|
||||
}
|
||||
@@ -17,3 +17,4 @@ pyyaml>=6.0
|
||||
# V2 async database
|
||||
asyncpg>=0.29.0
|
||||
pydantic>=2.5.0
|
||||
pytest>=8.0.0
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_DIR))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
|
||||
|
||||
def _resolve_dsn() -> str:
|
||||
env_path = AI_ENGINE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().split("?schema=")[0]
|
||||
raise SystemExit("DATABASE_URL not found in ai-engine/.env")
|
||||
|
||||
|
||||
def _fetch_matches(dsn: str, limit: int = 60) -> list[str]:
|
||||
query = """
|
||||
SELECT m.id
|
||||
FROM matches m
|
||||
WHERE m.status = 'FT'
|
||||
AND m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, (limit,))
|
||||
return [str(row["id"]) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _score_prediction(package: dict) -> dict[str, float]:
|
||||
rows = package.get("bet_summary", []) or []
|
||||
playable = [row for row in rows if row.get("playable")]
|
||||
return {
|
||||
"playable_count": float(len(playable)),
|
||||
"avg_edge": round(
|
||||
sum(float(row.get("ev_edge", 0.0)) for row in playable) / len(playable),
|
||||
4,
|
||||
)
|
||||
if playable
|
||||
else 0.0,
|
||||
"avg_confidence": round(
|
||||
sum(float(row.get("calibrated_confidence", 0.0)) for row in playable)
|
||||
/ len(playable),
|
||||
2,
|
||||
)
|
||||
if playable
|
||||
else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dsn = _resolve_dsn()
|
||||
match_ids = _fetch_matches(dsn)
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
for match_id in match_ids:
|
||||
orchestrator.engine_mode = "v25"
|
||||
v25 = orchestrator.analyze_match(match_id)
|
||||
orchestrator.engine_mode = "v26"
|
||||
v26 = orchestrator.analyze_match(match_id)
|
||||
if not v25 or not v26:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"v25": _score_prediction(v25),
|
||||
"v26": _score_prediction(v26),
|
||||
"v25_main": (v25.get("main_pick") or {}).get("pick"),
|
||||
"v26_main": (v26.get("main_pick") or {}).get("pick"),
|
||||
}
|
||||
)
|
||||
|
||||
out_path = AI_ENGINE_DIR / "reports" / "backtest_v26_shadow.json"
|
||||
out_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
|
||||
print(f"[OK] Shadow backtest summary written to {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_DIR))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
|
||||
|
||||
STRATEGIES = ("v25_aggressive", "v26_surprise", "v26_aggressive", "v26_main_htft")
|
||||
REVERSAL_LABELS = ("1/2", "2/1", "X/1", "X/2")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchContext:
|
||||
match_id: str
|
||||
match_date_ms: int
|
||||
league: str
|
||||
home_team: str
|
||||
away_team: str
|
||||
final_home: int
|
||||
final_away: int
|
||||
ht_home: Optional[int]
|
||||
ht_away: Optional[int]
|
||||
|
||||
@property
|
||||
def match_name(self) -> str:
|
||||
return f"{self.home_team} vs {self.away_team}"
|
||||
|
||||
@property
|
||||
def final_score(self) -> str:
|
||||
return f"{self.final_home}-{self.final_away}"
|
||||
|
||||
@property
|
||||
def ht_score(self) -> str:
|
||||
if self.ht_home is None or self.ht_away is None:
|
||||
return "-"
|
||||
return f"{self.ht_home}-{self.ht_away}"
|
||||
|
||||
|
||||
def _resolve_dsn() -> str:
|
||||
env_path = AI_ENGINE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().split("?schema=")[0]
|
||||
raise SystemExit("DATABASE_URL not found in ai-engine/.env")
|
||||
|
||||
|
||||
def _fetch_matches(dsn: str, limit: int) -> list[MatchContext]:
|
||||
query = """
|
||||
SELECT
|
||||
m.id,
|
||||
m.mst_utc,
|
||||
COALESCE(l.name, 'Unknown League') AS league,
|
||||
COALESCE(ht.name, 'Home') AS home_team,
|
||||
COALESCE(at.name, 'Away') AS away_team,
|
||||
COALESCE(m.score_home, 0) AS score_home,
|
||||
COALESCE(m.score_away, 0) AS score_away,
|
||||
m.ht_score_home,
|
||||
m.ht_score_away
|
||||
FROM matches m
|
||||
LEFT JOIN leagues l ON l.id = m.league_id
|
||||
LEFT JOIN teams ht ON ht.id = m.home_team_id
|
||||
LEFT JOIN teams at ON at.id = m.away_team_id
|
||||
WHERE m.status = 'FT'
|
||||
AND m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND m.ht_score_home IS NOT NULL
|
||||
AND m.ht_score_away IS NOT NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, (limit,))
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
MatchContext(
|
||||
match_id=str(row["id"]),
|
||||
match_date_ms=int(row["mst_utc"] or 0),
|
||||
league=str(row["league"] or "Unknown League"),
|
||||
home_team=str(row["home_team"] or "Home"),
|
||||
away_team=str(row["away_team"] or "Away"),
|
||||
final_home=int(row["score_home"] or 0),
|
||||
final_away=int(row["score_away"] or 0),
|
||||
ht_home=int(row["ht_score_home"]) if row.get("ht_score_home") is not None else None,
|
||||
ht_away=int(row["ht_score_away"]) if row.get("ht_score_away") is not None else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _outcome_symbol(home: int, away: int) -> str:
|
||||
if home > away:
|
||||
return "1"
|
||||
if home < away:
|
||||
return "2"
|
||||
return "X"
|
||||
|
||||
|
||||
def _resolve_htft(pick: str, context: MatchContext) -> Dict[str, Any]:
|
||||
if not pick or "/" not in str(pick):
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "htft_pick_invalid"}
|
||||
actual = f"{_outcome_symbol(context.ht_home or 0, context.ht_away or 0)}/{_outcome_symbol(context.final_home, context.final_away)}"
|
||||
won = str(pick).strip().upper() == actual
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
|
||||
def _market_odds(odds: Dict[str, Any], market: str, pick: str) -> float:
|
||||
mapping = {
|
||||
"HTFT": {
|
||||
"1/1": "htft_11",
|
||||
"1/X": "htft_1x",
|
||||
"1/2": "htft_12",
|
||||
"X/1": "htft_x1",
|
||||
"X/X": "htft_xx",
|
||||
"X/2": "htft_x2",
|
||||
"2/1": "htft_21",
|
||||
"2/X": "htft_2x",
|
||||
"2/2": "htft_22",
|
||||
},
|
||||
"MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"},
|
||||
}
|
||||
key = mapping.get(market, {}).get(str(pick))
|
||||
if not key:
|
||||
return 0.0
|
||||
value = _safe_float((odds or {}).get(key))
|
||||
return value if value > 1.0 else 0.0
|
||||
|
||||
|
||||
def _evaluate_pick(
|
||||
*,
|
||||
strategy: str,
|
||||
market: str,
|
||||
pick: str,
|
||||
odds: Any,
|
||||
playable: bool,
|
||||
confidence: Any,
|
||||
extra: Optional[Dict[str, Any]],
|
||||
context: MatchContext,
|
||||
) -> Dict[str, Any]:
|
||||
odds_value = _safe_float(odds)
|
||||
if market == "HT/FT":
|
||||
market = "HTFT"
|
||||
resolution = _resolve_htft(pick, context) if market == "HTFT" else {
|
||||
"result": "UNRESOLVED",
|
||||
"won": None,
|
||||
"note": "non_htft_market",
|
||||
}
|
||||
counted = bool(playable and market == "HTFT" and odds_value > 1.01 and resolution["result"] in {"WON", "LOST"})
|
||||
profit = 0.0
|
||||
if counted:
|
||||
profit = (odds_value - 1.0) if resolution["result"] == "WON" else -1.0
|
||||
row = {
|
||||
"strategy": strategy,
|
||||
"market": market,
|
||||
"pick": pick,
|
||||
"odds": round(odds_value, 2),
|
||||
"playable": playable,
|
||||
"confidence": round(_safe_float(confidence), 1),
|
||||
"result": resolution["result"],
|
||||
"counted_in_roi": counted,
|
||||
"profit_flat": round(profit, 4),
|
||||
"resolution_note": resolution["note"],
|
||||
}
|
||||
if extra:
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def _extract_strategy_rows(
|
||||
*,
|
||||
context: MatchContext,
|
||||
odds_data: Dict[str, Any],
|
||||
v25: Dict[str, Any],
|
||||
v26: Dict[str, Any],
|
||||
) -> Dict[str, Optional[Dict[str, Any]]]:
|
||||
strategies: Dict[str, Optional[Dict[str, Any]]] = {name: None for name in STRATEGIES}
|
||||
|
||||
v25_aggressive = v25.get("aggressive_pick") or {}
|
||||
if v25_aggressive.get("pick"):
|
||||
pick = str(v25_aggressive.get("pick"))
|
||||
strategies["v25_aggressive"] = _evaluate_pick(
|
||||
strategy="v25_aggressive",
|
||||
market=str(v25_aggressive.get("market") or "HTFT"),
|
||||
pick=pick,
|
||||
odds=_market_odds(odds_data, "HTFT", pick),
|
||||
playable=True,
|
||||
confidence=v25_aggressive.get("confidence"),
|
||||
extra={
|
||||
"source": "v25.aggressive_pick",
|
||||
"reversal_pick": pick,
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
v26_surprise = v26.get("surprise_pick") or {}
|
||||
v26_hunter = v26.get("surprise_hunter") or {}
|
||||
if v26_surprise.get("pick"):
|
||||
pick = str(v26_surprise.get("raw_pick") or v26_surprise.get("pick"))
|
||||
strategies["v26_surprise"] = _evaluate_pick(
|
||||
strategy="v26_surprise",
|
||||
market=str(v26_surprise.get("market") or "HTFT"),
|
||||
pick=pick,
|
||||
odds=v26_surprise.get("odds") or _market_odds(odds_data, "HTFT", pick),
|
||||
playable=bool(v26_surprise.get("playable")),
|
||||
confidence=v26_surprise.get("calibrated_confidence", v26_surprise.get("confidence")),
|
||||
extra={
|
||||
"source": "v26.surprise_pick",
|
||||
"surprise_score": round(_safe_float(v26_surprise.get("surprise_score")), 1),
|
||||
"support_score": round(_safe_float(v26_surprise.get("support_score")), 1),
|
||||
"reversal_pick": v26_hunter.get("reversal_pick"),
|
||||
"reversal_prob": round(_safe_float(v26_hunter.get("reversal_prob")), 4),
|
||||
"favorite_gap": round(_safe_float(v26_hunter.get("favorite_gap")), 3),
|
||||
"favorite_odd": round(_safe_float(v26_hunter.get("favorite_odd")), 2),
|
||||
"odds_band_score": round(_safe_float(v26_hunter.get("odds_band_score")), 3),
|
||||
"odds_band_label": str(v26_hunter.get("odds_band_label") or ""),
|
||||
"league_reversal_rate": round(_safe_float(v26_hunter.get("league_reversal_rate")), 4),
|
||||
"league_strict_rev_rate": round(_safe_float(v26_hunter.get("league_strict_rev_rate")), 4),
|
||||
"referee_strict_rev_rate": round(_safe_float(v26_hunter.get("referee_strict_rev_rate")), 4),
|
||||
"reason_codes": ",".join(v26_hunter.get("reason_codes", [])),
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
v26_aggressive = v26.get("aggressive_pick") or {}
|
||||
if v26_aggressive.get("pick"):
|
||||
pick = str(v26_aggressive.get("pick"))
|
||||
strategies["v26_aggressive"] = _evaluate_pick(
|
||||
strategy="v26_aggressive",
|
||||
market=str(v26_aggressive.get("market") or "HTFT"),
|
||||
pick=pick,
|
||||
odds=v26_aggressive.get("odds") or _market_odds(odds_data, "HTFT", pick),
|
||||
playable=True,
|
||||
confidence=v26_aggressive.get("confidence"),
|
||||
extra={
|
||||
"source": "v26.aggressive_pick",
|
||||
"reversal_pick": pick,
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
v26_main = v26.get("main_pick") or {}
|
||||
if str(v26_main.get("market") or "") == "HTFT" and v26_main.get("pick"):
|
||||
pick = str(v26_main.get("raw_pick") or v26_main.get("pick"))
|
||||
strategies["v26_main_htft"] = _evaluate_pick(
|
||||
strategy="v26_main_htft",
|
||||
market="HTFT",
|
||||
pick=pick,
|
||||
odds=v26_main.get("odds") or _market_odds(odds_data, "HTFT", pick),
|
||||
playable=bool(v26_main.get("playable")),
|
||||
confidence=v26_main.get("calibrated_confidence", v26_main.get("confidence")),
|
||||
extra={
|
||||
"source": "v26.main_pick",
|
||||
"pick_reason": v26_main.get("pick_reason"),
|
||||
"surprise_score": round(_safe_float(v26_main.get("surprise_score")), 1),
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
return strategies
|
||||
|
||||
|
||||
def _summarize_bucket(bucket: Dict[str, float]) -> Dict[str, Any]:
|
||||
played = int(bucket["played"])
|
||||
won = int(bucket["won"])
|
||||
lost = int(bucket["lost"])
|
||||
candidate = int(bucket["candidate"])
|
||||
profit = round(bucket["profit"], 4)
|
||||
roi = round((profit / played) * 100.0, 2) if played else 0.0
|
||||
hit = round((won / played) * 100.0, 2) if played else 0.0
|
||||
return {
|
||||
"candidates": candidate,
|
||||
"played": played,
|
||||
"won": won,
|
||||
"lost": lost,
|
||||
"profit_flat": profit,
|
||||
"roi_flat_pct": roi,
|
||||
"hit_rate_pct": hit,
|
||||
}
|
||||
|
||||
|
||||
def _format_date(ms: int) -> str:
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _build_markdown(report: Dict[str, Any]) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# HT/FT + Upset Backtest")
|
||||
lines.append("")
|
||||
lines.append(f"- Sample: last {report['sample_size']} finished football matches")
|
||||
lines.append("- Scope: only HT/FT reversal and upset-oriented picks")
|
||||
lines.append("- ROI: flat `1 unit` per played pick")
|
||||
lines.append(f"- Generated at: {report['generated_at']}")
|
||||
lines.append("")
|
||||
lines.append("## Strategy Summary")
|
||||
lines.append("")
|
||||
lines.append("| Strategy | Candidates | Played | Won | Lost | Hit Rate | Profit | ROI |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|---:|")
|
||||
for strategy in STRATEGIES:
|
||||
payload = report["summary"]["strategies"][strategy]
|
||||
lines.append(
|
||||
f"| {strategy} | {payload['candidates']} | {payload['played']} | {payload['won']} | "
|
||||
f"{payload['lost']} | {payload['hit_rate_pct']}% | {payload['profit_flat']:+.2f} | {payload['roi_flat_pct']:+.2f}% |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## v26 Surprise By Reversal Type")
|
||||
lines.append("")
|
||||
lines.append("| Reversal | Candidates | Played | Won | Lost | Profit | ROI |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|")
|
||||
for reversal, payload in report["summary"]["v26_surprise_by_pick"].items():
|
||||
lines.append(
|
||||
f"| {reversal} | {payload['candidates']} | {payload['played']} | {payload['won']} | "
|
||||
f"{payload['lost']} | {payload['profit_flat']:+.2f} | {payload['roi_flat_pct']:+.2f}% |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Match Detail")
|
||||
lines.append("")
|
||||
lines.append("| Date | Match | HT | FT | v25 aggressive | v26 surprise | v26 aggressive | v26 main HTFT |")
|
||||
lines.append("|---|---|---|---|---|---|---|---|")
|
||||
for match in report["matches"]:
|
||||
lines.append(
|
||||
f"| {_format_date(match['match_date_ms'])} | {match['match_name']} | {match['ht_score']} | {match['final_score']} | "
|
||||
f"{match['v25_aggressive']} | {match['v26_surprise']} | {match['v26_aggressive']} | {match['v26_main_htft']} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="HT/FT + upset focused backtest.")
|
||||
parser.add_argument("--limit", type=int, default=120, help="Number of finished matches to analyze.")
|
||||
args = parser.parse_args()
|
||||
|
||||
dsn = _resolve_dsn()
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
matches = _fetch_matches(dsn, max(1, args.limit))
|
||||
|
||||
strategy_buckets: Dict[str, Dict[str, float]] = {name: defaultdict(float) for name in STRATEGIES}
|
||||
v26_reversal_buckets: Dict[str, Dict[str, float]] = {label: defaultdict(float) for label in REVERSAL_LABELS}
|
||||
report_matches: list[Dict[str, Any]] = []
|
||||
csv_rows: list[Dict[str, Any]] = []
|
||||
|
||||
for context in matches:
|
||||
data = orchestrator._load_match_data(context.match_id) # noqa: SLF001
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
orchestrator.engine_mode = "v25"
|
||||
v25 = orchestrator.analyze_match(context.match_id) or {}
|
||||
orchestrator.engine_mode = "v26"
|
||||
v26 = orchestrator.analyze_match(context.match_id) or {}
|
||||
|
||||
extracted = _extract_strategy_rows(
|
||||
context=context,
|
||||
odds_data=data.odds_data or {},
|
||||
v25=v25,
|
||||
v26=v26,
|
||||
)
|
||||
|
||||
match_row: Dict[str, Any] = {
|
||||
"match_id": context.match_id,
|
||||
"match_name": context.match_name,
|
||||
"league": context.league,
|
||||
"match_date_ms": context.match_date_ms,
|
||||
"ht_score": context.ht_score,
|
||||
"final_score": context.final_score,
|
||||
}
|
||||
|
||||
for strategy, payload in extracted.items():
|
||||
if payload:
|
||||
strategy_buckets[strategy]["candidate"] += 1
|
||||
if payload["counted_in_roi"]:
|
||||
strategy_buckets[strategy]["played"] += 1
|
||||
if payload["result"] == "WON":
|
||||
strategy_buckets[strategy]["won"] += 1
|
||||
else:
|
||||
strategy_buckets[strategy]["lost"] += 1
|
||||
strategy_buckets[strategy]["profit"] += payload["profit_flat"]
|
||||
|
||||
if strategy == "v26_surprise":
|
||||
reversal_label = str(payload.get("reversal_pick") or "")
|
||||
if reversal_label in v26_reversal_buckets:
|
||||
v26_reversal_buckets[reversal_label]["candidate"] += 1
|
||||
if payload["counted_in_roi"]:
|
||||
v26_reversal_buckets[reversal_label]["played"] += 1
|
||||
if payload["result"] == "WON":
|
||||
v26_reversal_buckets[reversal_label]["won"] += 1
|
||||
else:
|
||||
v26_reversal_buckets[reversal_label]["lost"] += 1
|
||||
v26_reversal_buckets[reversal_label]["profit"] += payload["profit_flat"]
|
||||
|
||||
summary = (
|
||||
f"{payload['pick']} ({payload['result']}, {'played' if payload['counted_in_roi'] else 'not played'}, {payload['profit_flat']:+.2f})"
|
||||
)
|
||||
match_row[strategy] = summary
|
||||
|
||||
csv_rows.append(
|
||||
{
|
||||
"match_id": context.match_id,
|
||||
"date": _format_date(context.match_date_ms),
|
||||
"league": context.league,
|
||||
"match": context.match_name,
|
||||
"ht_score": context.ht_score,
|
||||
"final_score": context.final_score,
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
else:
|
||||
match_row[strategy] = "-"
|
||||
|
||||
report_matches.append(match_row)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"sample_size": len(report_matches),
|
||||
"summary": {
|
||||
"strategies": {
|
||||
strategy: _summarize_bucket(bucket)
|
||||
for strategy, bucket in strategy_buckets.items()
|
||||
},
|
||||
"v26_surprise_by_pick": {
|
||||
label: _summarize_bucket(bucket)
|
||||
for label, bucket in v26_reversal_buckets.items()
|
||||
},
|
||||
},
|
||||
"matches": report_matches,
|
||||
}
|
||||
|
||||
report_dir = AI_ENGINE_DIR / "reports"
|
||||
json_path = report_dir / "backtest_v26_shadow_htft_upset.json"
|
||||
csv_path = report_dir / "backtest_v26_shadow_htft_upset.csv"
|
||||
md_path = report_dir / "backtest_v26_shadow_htft_upset.md"
|
||||
|
||||
json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=[
|
||||
"match_id",
|
||||
"date",
|
||||
"league",
|
||||
"match",
|
||||
"ht_score",
|
||||
"final_score",
|
||||
"strategy",
|
||||
"market",
|
||||
"pick",
|
||||
"odds",
|
||||
"playable",
|
||||
"confidence",
|
||||
"result",
|
||||
"counted_in_roi",
|
||||
"profit_flat",
|
||||
"resolution_note",
|
||||
"source",
|
||||
"reversal_pick",
|
||||
"reversal_prob",
|
||||
"favorite_gap",
|
||||
"favorite_odd",
|
||||
"support_score",
|
||||
"odds_band_score",
|
||||
"odds_band_label",
|
||||
"league_reversal_rate",
|
||||
"league_strict_rev_rate",
|
||||
"referee_strict_rev_rate",
|
||||
"surprise_score",
|
||||
"reason_codes",
|
||||
"pick_reason",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(csv_rows)
|
||||
md_path.write_text(_build_markdown(report), encoding="utf-8")
|
||||
|
||||
print(f"[OK] JSON report written to {json_path}")
|
||||
print(f"[OK] CSV report written to {csv_path}")
|
||||
print(f"[OK] Markdown report written to {md_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,810 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_DIR))
|
||||
|
||||
from services.single_match_orchestrator import SingleMatchOrchestrator
|
||||
from utils.top_leagues import load_top_league_ids
|
||||
|
||||
|
||||
MARKET_ORDER = [
|
||||
"MS",
|
||||
"DC",
|
||||
"OU15",
|
||||
"OU25",
|
||||
"OU35",
|
||||
"BTTS",
|
||||
"HT",
|
||||
"HT_OU05",
|
||||
"HT_OU15",
|
||||
"HTFT",
|
||||
"OE",
|
||||
"CARDS",
|
||||
"HCAP",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchContext:
|
||||
match_id: str
|
||||
match_date_ms: int
|
||||
league_id: Optional[str]
|
||||
league: str
|
||||
home_team: str
|
||||
away_team: str
|
||||
final_home: int
|
||||
final_away: int
|
||||
ht_home: Optional[int]
|
||||
ht_away: Optional[int]
|
||||
total_cards: Optional[float]
|
||||
|
||||
@property
|
||||
def match_name(self) -> str:
|
||||
return f"{self.home_team} vs {self.away_team}"
|
||||
|
||||
@property
|
||||
def final_score(self) -> str:
|
||||
return f"{self.final_home}-{self.final_away}"
|
||||
|
||||
@property
|
||||
def ht_score(self) -> Optional[str]:
|
||||
if self.ht_home is None or self.ht_away is None:
|
||||
return None
|
||||
return f"{self.ht_home}-{self.ht_away}"
|
||||
|
||||
@property
|
||||
def total_goals(self) -> int:
|
||||
return self.final_home + self.final_away
|
||||
|
||||
@property
|
||||
def total_ht_goals(self) -> Optional[int]:
|
||||
if self.ht_home is None or self.ht_away is None:
|
||||
return None
|
||||
return self.ht_home + self.ht_away
|
||||
|
||||
|
||||
def _resolve_dsn() -> str:
|
||||
env_path = AI_ENGINE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().split("?schema=")[0]
|
||||
raise SystemExit("DATABASE_URL not found in ai-engine/.env")
|
||||
|
||||
|
||||
def _fetch_matches(
|
||||
dsn: str,
|
||||
limit: int,
|
||||
top_league_ids: Optional[list[str]] = None,
|
||||
) -> list[MatchContext]:
|
||||
query = """
|
||||
SELECT
|
||||
m.id,
|
||||
m.mst_utc,
|
||||
m.league_id,
|
||||
COALESCE(l.name, 'Unknown League') AS league,
|
||||
COALESCE(ht.name, 'Home') AS home_team,
|
||||
COALESCE(at.name, 'Away') AS away_team,
|
||||
COALESCE(m.score_home, 0) AS score_home,
|
||||
COALESCE(m.score_away, 0) AS score_away,
|
||||
m.ht_score_home,
|
||||
m.ht_score_away,
|
||||
cards.total_cards
|
||||
FROM matches m
|
||||
LEFT JOIN leagues l ON l.id = m.league_id
|
||||
LEFT JOIN teams ht ON ht.id = m.home_team_id
|
||||
LEFT JOIN teams at ON at.id = m.away_team_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
mpe.match_id,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN mpe.event_type::text LIKE '%%yellow_card%%' THEN 1
|
||||
WHEN mpe.event_type::text LIKE '%%red_card%%' THEN 2
|
||||
ELSE 1
|
||||
END
|
||||
)::float AS total_cards
|
||||
FROM match_player_events mpe
|
||||
WHERE mpe.event_type::text LIKE '%%card%%'
|
||||
GROUP BY mpe.match_id
|
||||
) cards ON cards.match_id = m.id
|
||||
WHERE m.status = 'FT'
|
||||
AND m.sport = 'football'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
"""
|
||||
params: list[Any] = []
|
||||
if top_league_ids:
|
||||
query += " AND m.league_id = ANY(%s)"
|
||||
params.append(top_league_ids)
|
||||
query += """
|
||||
ORDER BY m.mst_utc DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
params.append(limit)
|
||||
with psycopg2.connect(dsn) as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
results: list[MatchContext] = []
|
||||
for row in rows:
|
||||
results.append(
|
||||
MatchContext(
|
||||
match_id=str(row["id"]),
|
||||
match_date_ms=int(row["mst_utc"] or 0),
|
||||
league_id=str(row["league_id"]) if row.get("league_id") else None,
|
||||
league=str(row["league"] or "Unknown League"),
|
||||
home_team=str(row["home_team"] or "Home"),
|
||||
away_team=str(row["away_team"] or "Away"),
|
||||
final_home=int(row["score_home"] or 0),
|
||||
final_away=int(row["score_away"] or 0),
|
||||
ht_home=(
|
||||
int(row["ht_score_home"])
|
||||
if row.get("ht_score_home") is not None
|
||||
else None
|
||||
),
|
||||
ht_away=(
|
||||
int(row["ht_score_away"])
|
||||
if row.get("ht_score_away") is not None
|
||||
else None
|
||||
),
|
||||
total_cards=(
|
||||
float(row["total_cards"])
|
||||
if row.get("total_cards") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def _odds_band(odds: float) -> str:
|
||||
if odds < 1.5:
|
||||
return "<1.50"
|
||||
if odds < 1.8:
|
||||
return "1.50-1.79"
|
||||
if odds < 2.1:
|
||||
return "1.80-2.09"
|
||||
if odds < 2.5:
|
||||
return "2.10-2.49"
|
||||
return "2.50+"
|
||||
|
||||
|
||||
def _confidence_band(confidence: float) -> str:
|
||||
if confidence < 55.0:
|
||||
return "<55"
|
||||
if confidence < 65.0:
|
||||
return "55-64.9"
|
||||
if confidence < 75.0:
|
||||
return "65-74.9"
|
||||
return "75+"
|
||||
|
||||
|
||||
def _edge_band(edge: float) -> str:
|
||||
if edge < 0.03:
|
||||
return "<0.03"
|
||||
if edge < 0.06:
|
||||
return "0.03-0.059"
|
||||
if edge < 0.10:
|
||||
return "0.06-0.099"
|
||||
return "0.10+"
|
||||
|
||||
|
||||
def _top_n_buckets(rows: Iterable[tuple[str, float]], limit: int = 10) -> list[dict[str, Any]]:
|
||||
ranked = sorted(rows, key=lambda item: (-item[1], item[0]))
|
||||
return [
|
||||
{"label": label, "count": int(count)}
|
||||
for label, count in ranked[:limit]
|
||||
]
|
||||
|
||||
|
||||
def _summarize_v26_losses(csv_rows: list[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
losses = [
|
||||
row for row in csv_rows
|
||||
if row.get("model") == "v26.shadow"
|
||||
and bool(row.get("counted_in_roi"))
|
||||
and row.get("result") == "LOST"
|
||||
]
|
||||
by_market: Dict[str, float] = defaultdict(float)
|
||||
by_league: Dict[str, float] = defaultdict(float)
|
||||
by_pick: Dict[str, float] = defaultdict(float)
|
||||
by_odds_band: Dict[str, float] = defaultdict(float)
|
||||
by_conf_band: Dict[str, float] = defaultdict(float)
|
||||
by_edge_band: Dict[str, float] = defaultdict(float)
|
||||
|
||||
for row in losses:
|
||||
market = str(row.get("market") or "UNKNOWN")
|
||||
league = str(row.get("league") or "Unknown League")
|
||||
pick = str(row.get("pick") or "")
|
||||
odds = _safe_float(row.get("odds"))
|
||||
confidence = _safe_float(row.get("confidence"))
|
||||
edge = _safe_float(row.get("edge"))
|
||||
|
||||
by_market[market] += 1
|
||||
by_league[league] += 1
|
||||
by_pick[f"{market} {pick}".strip()] += 1
|
||||
by_odds_band[_odds_band(odds)] += 1
|
||||
by_conf_band[_confidence_band(confidence)] += 1
|
||||
by_edge_band[_edge_band(edge)] += 1
|
||||
|
||||
return {
|
||||
"lost_bets": len(losses),
|
||||
"by_market": _top_n_buckets(by_market.items(), limit=20),
|
||||
"by_league": _top_n_buckets(by_league.items(), limit=15),
|
||||
"by_pick": _top_n_buckets(by_pick.items(), limit=15),
|
||||
"by_odds_band": _top_n_buckets(by_odds_band.items(), limit=10),
|
||||
"by_confidence_band": _top_n_buckets(by_conf_band.items(), limit=10),
|
||||
"by_edge_band": _top_n_buckets(by_edge_band.items(), limit=10),
|
||||
}
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
return (
|
||||
text.replace("İ", "I")
|
||||
.replace("İ", "I")
|
||||
.replace("Ş", "S")
|
||||
.replace("Ğ", "G")
|
||||
.replace("Ü", "U")
|
||||
.replace("Ö", "O")
|
||||
.replace("Ç", "C")
|
||||
)
|
||||
|
||||
|
||||
def _outcome_symbol(home: int, away: int) -> str:
|
||||
if home > away:
|
||||
return "1"
|
||||
if home < away:
|
||||
return "2"
|
||||
return "X"
|
||||
|
||||
|
||||
def _resolve_pick(
|
||||
market: str,
|
||||
pick: str,
|
||||
context: MatchContext,
|
||||
) -> Dict[str, Any]:
|
||||
market_code = _normalize_text(market).replace("/", "")
|
||||
pick_text = str(pick or "").strip()
|
||||
pick_norm = _normalize_text(pick_text)
|
||||
|
||||
if not market_code or not pick_norm:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "pick_missing"}
|
||||
|
||||
if market_code == "HTFT":
|
||||
market_code = "HTFT"
|
||||
if market_code == "HTFT" or market_code == "HTFT":
|
||||
if context.ht_home is None or context.ht_away is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ht_score_missing"}
|
||||
if "/" not in pick_text:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "htft_pick_invalid"}
|
||||
ht_pick, ft_pick = pick_text.split("/", 1)
|
||||
actual = f"{_outcome_symbol(context.ht_home, context.ht_away)}/{_outcome_symbol(context.final_home, context.final_away)}"
|
||||
won = f"{_normalize_text(ht_pick)}/{_normalize_text(ft_pick)}" == actual
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "MS":
|
||||
actual = _outcome_symbol(context.final_home, context.final_away)
|
||||
won = pick_norm in {actual, f"MS {actual}"}
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "DC":
|
||||
actual = _outcome_symbol(context.final_home, context.final_away)
|
||||
winning = {
|
||||
"1X": {"1", "X"},
|
||||
"X2": {"X", "2"},
|
||||
"12": {"1", "2"},
|
||||
}
|
||||
won = actual in winning.get(pick_norm, set())
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code in {"OU15", "OU25", "OU35", "HTOU05", "HTOU15", "HT_OU05", "HT_OU15"}:
|
||||
if market_code in {"HTOU05", "HTOU15", "HT_OU05", "HT_OU15"}:
|
||||
if context.total_ht_goals is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ht_score_missing"}
|
||||
total = context.total_ht_goals
|
||||
line = 0.5 if "05" in market_code else 1.5
|
||||
else:
|
||||
total = context.total_goals
|
||||
line = {"OU15": 1.5, "OU25": 2.5, "OU35": 3.5}[market_code]
|
||||
|
||||
if "UST" in pick_norm or "OVER" in pick_norm:
|
||||
won = total > line
|
||||
side = "OVER"
|
||||
elif "ALT" in pick_norm or "UNDER" in pick_norm:
|
||||
won = total < line
|
||||
side = "UNDER"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ou_side_unknown"}
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual_total={total} side={side} line={line}",
|
||||
}
|
||||
|
||||
if market_code == "BTTS":
|
||||
both_scored = context.final_home > 0 and context.final_away > 0
|
||||
if "VAR" in pick_norm or "YES" in pick_norm:
|
||||
won = both_scored
|
||||
side = "YES"
|
||||
elif "YOK" in pick_norm or pick_norm.endswith("NO") or pick_norm == "NO":
|
||||
won = not both_scored
|
||||
side = "NO"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "btts_side_unknown"}
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual_btts={'YES' if both_scored else 'NO'} side={side}",
|
||||
}
|
||||
|
||||
if market_code == "HT":
|
||||
if context.ht_home is None or context.ht_away is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "ht_score_missing"}
|
||||
actual = _outcome_symbol(context.ht_home, context.ht_away)
|
||||
won = pick_norm == actual
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "OE":
|
||||
actual = "EVEN" if context.total_goals % 2 == 0 else "ODD"
|
||||
if pick_norm in {"CIFT", "EVEN"}:
|
||||
wanted = "EVEN"
|
||||
elif pick_norm in {"TEK", "ODD"}:
|
||||
wanted = "ODD"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "oe_pick_unknown"}
|
||||
won = actual == wanted
|
||||
return {"result": "WON" if won else "LOST", "won": won, "note": f"actual={actual}"}
|
||||
|
||||
if market_code == "CARDS":
|
||||
if context.total_cards is None:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "cards_missing"}
|
||||
if "UST" in pick_norm or "OVER" in pick_norm:
|
||||
won = context.total_cards > 4.5
|
||||
side = "OVER"
|
||||
elif "ALT" in pick_norm or "UNDER" in pick_norm:
|
||||
won = context.total_cards < 4.5
|
||||
side = "UNDER"
|
||||
else:
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "cards_side_unknown"}
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual_cards={context.total_cards:.1f} side={side} line=4.5",
|
||||
}
|
||||
|
||||
if market_code == "HCAP":
|
||||
adjusted_home = context.final_home - 1.0
|
||||
adjusted_away = float(context.final_away)
|
||||
if adjusted_home > adjusted_away:
|
||||
actual = "1"
|
||||
elif adjusted_home < adjusted_away:
|
||||
actual = "2"
|
||||
else:
|
||||
actual = "X"
|
||||
won = pick_norm == actual
|
||||
return {
|
||||
"result": "WON" if won else "LOST",
|
||||
"won": won,
|
||||
"note": f"actual={actual} line_home=-1.0",
|
||||
}
|
||||
|
||||
return {"result": "UNRESOLVED", "won": None, "note": "market_not_supported"}
|
||||
|
||||
|
||||
def _evaluate_row(
|
||||
market: str,
|
||||
pick: str,
|
||||
odds: Any,
|
||||
playable: bool,
|
||||
stake_units: Any,
|
||||
context: MatchContext,
|
||||
) -> Dict[str, Any]:
|
||||
resolution = _resolve_pick(market, pick, context)
|
||||
odds_value = _safe_float(odds)
|
||||
stake_value = _safe_float(stake_units)
|
||||
counted = bool(playable and odds_value > 1.01 and resolution["result"] in {"WON", "LOST"})
|
||||
|
||||
flat_profit = 0.0
|
||||
stake_profit = 0.0
|
||||
if counted:
|
||||
flat_profit = (odds_value - 1.0) if resolution["result"] == "WON" else -1.0
|
||||
stake_profit = flat_profit * (stake_value if stake_value > 0 else 1.0)
|
||||
|
||||
return {
|
||||
"result": resolution["result"],
|
||||
"won": resolution["won"],
|
||||
"resolution_note": resolution["note"],
|
||||
"counted_in_roi": counted,
|
||||
"profit_flat": round(flat_profit, 4),
|
||||
"profit_stake": round(stake_profit, 4),
|
||||
}
|
||||
|
||||
|
||||
def _summarize_bucket(bucket: Dict[str, float]) -> Dict[str, Any]:
|
||||
played = int(bucket["played"])
|
||||
won = int(bucket["won"])
|
||||
lost = int(bucket["lost"])
|
||||
unresolved = int(bucket["unresolved"])
|
||||
profit = round(bucket["profit"], 4)
|
||||
roi = round((profit / played) * 100.0, 2) if played else 0.0
|
||||
win_rate = round((won / played) * 100.0, 2) if played else 0.0
|
||||
return {
|
||||
"played": played,
|
||||
"won": won,
|
||||
"lost": lost,
|
||||
"unresolved": unresolved,
|
||||
"profit_flat": profit,
|
||||
"roi_flat_pct": roi,
|
||||
"win_rate_pct": win_rate,
|
||||
}
|
||||
|
||||
|
||||
def _format_date(ms: int) -> str:
|
||||
if ms <= 0:
|
||||
return "-"
|
||||
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _build_markdown_report(report: Dict[str, Any]) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# v25 vs v26.shadow ROI Report")
|
||||
lines.append("")
|
||||
lines.append(f"- Sample: last {report['sample_size']} finished football matches")
|
||||
if report.get("top_leagues_only"):
|
||||
lines.append("- Filter: top leagues only")
|
||||
lines.append("- ROI calculation: flat `1 unit` per playable and resolvable bet")
|
||||
lines.append(f"- Generated at: {report['generated_at']}")
|
||||
lines.append("")
|
||||
lines.append("## Overall Summary")
|
||||
lines.append("")
|
||||
lines.append("| Model | Played | Won | Lost | Win Rate | Profit | ROI | Main Pick ROI | Main Pick W/L |")
|
||||
lines.append("|---|---:|---:|---:|---:|---:|---:|---:|---|")
|
||||
for model_name, payload in report["summary"]["models"].items():
|
||||
main = payload["main_pick"]
|
||||
lines.append(
|
||||
f"| {model_name} | {payload['all_playable']['played']} | {payload['all_playable']['won']} | "
|
||||
f"{payload['all_playable']['lost']} | {payload['all_playable']['win_rate_pct']}% | "
|
||||
f"{payload['all_playable']['profit_flat']:+.2f} | {payload['all_playable']['roi_flat_pct']:+.2f}% | "
|
||||
f"{main['roi_flat_pct']:+.2f}% | {main['won']}/{main['played']} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Market Summary")
|
||||
lines.append("")
|
||||
lines.append("| Model | Market | Played | Won | Lost | Profit | ROI |")
|
||||
lines.append("|---|---|---:|---:|---:|---:|---:|")
|
||||
for model_name, markets in report["summary"]["markets"].items():
|
||||
for market_name in MARKET_ORDER:
|
||||
payload = markets.get(market_name)
|
||||
if not payload or payload["played"] == 0:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {model_name} | {market_name} | {payload['played']} | {payload['won']} | {payload['lost']} | "
|
||||
f"{payload['profit_flat']:+.2f} | {payload['roi_flat_pct']:+.2f}% |"
|
||||
)
|
||||
lines.append("")
|
||||
loss_summary = report["summary"].get("v26_loss_analysis", {})
|
||||
if loss_summary:
|
||||
lines.append("## v26 Loss Analysis")
|
||||
lines.append("")
|
||||
lines.append(f"- Lost bets: {loss_summary.get('lost_bets', 0)}")
|
||||
lines.append("")
|
||||
lines.append("| Bucket | Top Items |")
|
||||
lines.append("|---|---|")
|
||||
for label, key in (
|
||||
("By market", "by_market"),
|
||||
("By league", "by_league"),
|
||||
("By pick", "by_pick"),
|
||||
("By odds band", "by_odds_band"),
|
||||
("By confidence band", "by_confidence_band"),
|
||||
("By edge band", "by_edge_band"),
|
||||
):
|
||||
items = loss_summary.get(key) or []
|
||||
rendered = ", ".join(f"{item['label']} ({item['count']})" for item in items[:6]) or "-"
|
||||
lines.append(f"| {label} | {rendered} |")
|
||||
lines.append("")
|
||||
lines.append("## Match By Match")
|
||||
lines.append("")
|
||||
lines.append("| Date | Match | Score | v25 Main | v25 Played Picks | v25 Profit | v26 Main | v26 Played Picks | v26 Profit |")
|
||||
lines.append("|---|---|---|---|---|---:|---|---|---:|")
|
||||
for match in report["matches"]:
|
||||
v25 = match["models"]["v25"]
|
||||
v26 = match["models"]["v26.shadow"]
|
||||
lines.append(
|
||||
f"| {_format_date(match['match_date_ms'])} | {match['match_name']} | {match['final_score']} | "
|
||||
f"{v25['main_pick']['summary']} | {v25['played_picks_summary']} | {v25['profit_flat']:+.2f} | "
|
||||
f"{v26['main_pick']['summary']} | {v26['played_picks_summary']} | {v26['profit_flat']:+.2f} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detailed ROI backtest for v25 vs v26.shadow.",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=60, help="Number of finished matches to analyze.")
|
||||
parser.add_argument(
|
||||
"--top-leagues-only",
|
||||
action="store_true",
|
||||
help="Only analyze matches whose league_id exists in top_leagues.json.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
dsn = _resolve_dsn()
|
||||
top_league_ids = sorted(load_top_league_ids()) if args.top_leagues_only else None
|
||||
matches = _fetch_matches(dsn, max(1, args.limit), top_league_ids=top_league_ids)
|
||||
orchestrator = SingleMatchOrchestrator()
|
||||
|
||||
report_matches: list[Dict[str, Any]] = []
|
||||
model_aggregate: Dict[str, Dict[str, float]] = {
|
||||
"v25": defaultdict(float),
|
||||
"v26.shadow": defaultdict(float),
|
||||
}
|
||||
main_pick_aggregate: Dict[str, Dict[str, float]] = {
|
||||
"v25": defaultdict(float),
|
||||
"v26.shadow": defaultdict(float),
|
||||
}
|
||||
market_aggregate: Dict[str, Dict[str, Dict[str, float]]] = {
|
||||
"v25": defaultdict(lambda: defaultdict(float)),
|
||||
"v26.shadow": defaultdict(lambda: defaultdict(float)),
|
||||
}
|
||||
csv_rows: list[Dict[str, Any]] = []
|
||||
|
||||
for context in matches:
|
||||
match_payload = {
|
||||
"match_id": context.match_id,
|
||||
"match_name": context.match_name,
|
||||
"league": context.league,
|
||||
"match_date_ms": context.match_date_ms,
|
||||
"final_score": context.final_score,
|
||||
"ht_score": context.ht_score,
|
||||
"total_cards": context.total_cards,
|
||||
"models": {},
|
||||
}
|
||||
|
||||
for model_name, mode in (("v25", "v25"), ("v26.shadow", "v26")):
|
||||
orchestrator.engine_mode = mode
|
||||
package = orchestrator.analyze_match(context.match_id) or {}
|
||||
rows = package.get("bet_summary") or []
|
||||
evaluated_rows: list[Dict[str, Any]] = []
|
||||
match_profit = 0.0
|
||||
|
||||
for row in rows:
|
||||
market = str(row.get("market") or "")
|
||||
pick = str(row.get("pick") or "")
|
||||
evaluation = _evaluate_row(
|
||||
market=market,
|
||||
pick=pick,
|
||||
odds=row.get("odds"),
|
||||
playable=bool(row.get("playable")),
|
||||
stake_units=row.get("stake_units"),
|
||||
context=context,
|
||||
)
|
||||
combined = {
|
||||
"market": market,
|
||||
"pick": pick,
|
||||
"playable": bool(row.get("playable")),
|
||||
"bet_grade": row.get("bet_grade"),
|
||||
"odds": round(_safe_float(row.get("odds")), 2),
|
||||
"calibrated_confidence": round(_safe_float(row.get("calibrated_confidence")), 1),
|
||||
"edge": round(_safe_float(row.get("ev_edge", row.get("edge"))), 4),
|
||||
"stake_units": round(_safe_float(row.get("stake_units")), 2),
|
||||
**evaluation,
|
||||
}
|
||||
evaluated_rows.append(combined)
|
||||
|
||||
if combined["counted_in_roi"]:
|
||||
bucket = market_aggregate[model_name][market]
|
||||
bucket["played"] += 1
|
||||
if combined["result"] == "WON":
|
||||
bucket["won"] += 1
|
||||
else:
|
||||
bucket["lost"] += 1
|
||||
bucket["profit"] += combined["profit_flat"]
|
||||
|
||||
model_bucket = model_aggregate[model_name]
|
||||
model_bucket["played"] += 1
|
||||
if combined["result"] == "WON":
|
||||
model_bucket["won"] += 1
|
||||
else:
|
||||
model_bucket["lost"] += 1
|
||||
model_bucket["profit"] += combined["profit_flat"]
|
||||
match_profit += combined["profit_flat"]
|
||||
elif combined["playable"]:
|
||||
model_aggregate[model_name]["unresolved"] += 1
|
||||
market_aggregate[model_name][market]["unresolved"] += 1
|
||||
|
||||
csv_rows.append(
|
||||
{
|
||||
"match_id": context.match_id,
|
||||
"date": _format_date(context.match_date_ms),
|
||||
"league": context.league,
|
||||
"match": context.match_name,
|
||||
"final_score": context.final_score,
|
||||
"ht_score": context.ht_score or "",
|
||||
"model": model_name,
|
||||
"market": market,
|
||||
"pick": pick,
|
||||
"playable": combined["playable"],
|
||||
"bet_grade": combined["bet_grade"],
|
||||
"odds": combined["odds"],
|
||||
"confidence": combined["calibrated_confidence"],
|
||||
"edge": combined["edge"],
|
||||
"result": combined["result"],
|
||||
"counted_in_roi": combined["counted_in_roi"],
|
||||
"profit_flat": combined["profit_flat"],
|
||||
"resolution_note": combined["resolution_note"],
|
||||
}
|
||||
)
|
||||
|
||||
main_pick = package.get("main_pick") or {}
|
||||
main_eval = _evaluate_row(
|
||||
market=str(main_pick.get("market") or ""),
|
||||
pick=str(main_pick.get("pick") or ""),
|
||||
odds=main_pick.get("odds"),
|
||||
playable=bool(main_pick.get("playable")),
|
||||
stake_units=main_pick.get("stake_units"),
|
||||
context=context,
|
||||
)
|
||||
main_pick_summary = {
|
||||
"market": main_pick.get("market"),
|
||||
"pick": main_pick.get("pick"),
|
||||
"playable": bool(main_pick.get("playable")),
|
||||
"odds": round(_safe_float(main_pick.get("odds")), 2),
|
||||
"confidence": round(
|
||||
_safe_float(
|
||||
main_pick.get("calibrated_confidence", main_pick.get("confidence"))
|
||||
),
|
||||
1,
|
||||
),
|
||||
"edge": round(_safe_float(main_pick.get("ev_edge", main_pick.get("edge"))), 4),
|
||||
**main_eval,
|
||||
}
|
||||
|
||||
if main_pick_summary["counted_in_roi"]:
|
||||
summary_suffix = (
|
||||
f"{main_pick_summary['result']}, played, {main_pick_summary['profit_flat']:+.2f}"
|
||||
)
|
||||
elif main_pick_summary.get("market") and main_pick_summary.get("pick"):
|
||||
summary_suffix = f"{main_pick_summary['result']}, not played"
|
||||
else:
|
||||
summary_suffix = ""
|
||||
|
||||
if main_pick_summary["counted_in_roi"]:
|
||||
bucket = main_pick_aggregate[model_name]
|
||||
bucket["played"] += 1
|
||||
if main_pick_summary["result"] == "WON":
|
||||
bucket["won"] += 1
|
||||
else:
|
||||
bucket["lost"] += 1
|
||||
bucket["profit"] += main_pick_summary["profit_flat"]
|
||||
elif main_pick_summary["playable"]:
|
||||
main_pick_aggregate[model_name]["unresolved"] += 1
|
||||
|
||||
main_pick_summary["summary"] = (
|
||||
f"{main_pick_summary['market']} {main_pick_summary['pick']} "
|
||||
f"({summary_suffix})"
|
||||
if main_pick_summary.get("market") and main_pick_summary.get("pick")
|
||||
else "No main pick"
|
||||
)
|
||||
|
||||
played_rows = [row for row in evaluated_rows if row["counted_in_roi"]]
|
||||
played_picks_summary = (
|
||||
"; ".join(
|
||||
f"{row['market']} {row['pick']}={row['result']} ({row['profit_flat']:+.2f})"
|
||||
for row in played_rows
|
||||
)
|
||||
if played_rows
|
||||
else "-"
|
||||
)
|
||||
|
||||
match_payload["models"][model_name] = {
|
||||
"main_pick": main_pick_summary,
|
||||
"profit_flat": round(match_profit, 4),
|
||||
"played_picks_summary": played_picks_summary,
|
||||
"played_picks": played_rows,
|
||||
"all_picks": evaluated_rows,
|
||||
}
|
||||
|
||||
report_matches.append(match_payload)
|
||||
|
||||
summary = {
|
||||
"models": {
|
||||
model_name: {
|
||||
"all_playable": _summarize_bucket(model_aggregate[model_name]),
|
||||
"main_pick": _summarize_bucket(main_pick_aggregate[model_name]),
|
||||
}
|
||||
for model_name in ("v25", "v26.shadow")
|
||||
},
|
||||
"markets": {
|
||||
model_name: {
|
||||
market_name: _summarize_bucket(bucket)
|
||||
for market_name, bucket in sorted(
|
||||
market_aggregate[model_name].items(),
|
||||
key=lambda item: (
|
||||
MARKET_ORDER.index(item[0]) if item[0] in MARKET_ORDER else 999,
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
}
|
||||
for model_name in ("v25", "v26.shadow")
|
||||
},
|
||||
"v26_loss_analysis": _summarize_v26_losses(csv_rows),
|
||||
}
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"sample_size": len(report_matches),
|
||||
"top_leagues_only": bool(args.top_leagues_only),
|
||||
"summary": summary,
|
||||
"matches": report_matches,
|
||||
}
|
||||
|
||||
report_dir = AI_ENGINE_DIR / "reports"
|
||||
json_path = report_dir / "backtest_v26_shadow_roi_detail.json"
|
||||
csv_path = report_dir / "backtest_v26_shadow_roi_picks.csv"
|
||||
md_path = report_dir / "backtest_v26_shadow_roi_report.md"
|
||||
|
||||
json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=[
|
||||
"match_id",
|
||||
"date",
|
||||
"league",
|
||||
"match",
|
||||
"final_score",
|
||||
"ht_score",
|
||||
"model",
|
||||
"market",
|
||||
"pick",
|
||||
"playable",
|
||||
"bet_grade",
|
||||
"odds",
|
||||
"confidence",
|
||||
"edge",
|
||||
"result",
|
||||
"counted_in_roi",
|
||||
"profit_flat",
|
||||
"resolution_note",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(csv_rows)
|
||||
|
||||
md_path.write_text(_build_markdown_report(report), encoding="utf-8")
|
||||
|
||||
print(f"[OK] JSON report written to {json_path}")
|
||||
print(f"[OK] CSV report written to {csv_path}")
|
||||
print(f"[OK] Markdown report written to {md_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
SOURCE_CSV = AI_ENGINE_DIR / "data" / "training_data.csv"
|
||||
TARGET_DIR = AI_ENGINE_DIR / "data" / "v26_shadow"
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _rolling_windows(frame: pd.DataFrame) -> list[dict[str, int]]:
|
||||
ordered = frame.sort_values("mst_utc").reset_index(drop=True)
|
||||
windows: list[dict[str, int]] = []
|
||||
if ordered.empty:
|
||||
return windows
|
||||
|
||||
size = len(ordered)
|
||||
cuts = [0.55, 0.7, 0.85]
|
||||
for idx, cut in enumerate(cuts, start=1):
|
||||
end_ix = max(int(size * cut), 1)
|
||||
test_end = min(size - 1, end_ix + max(int(size * 0.10), 1))
|
||||
windows.append(
|
||||
{
|
||||
"window": idx,
|
||||
"train_end_ix": end_ix - 1,
|
||||
"test_start_ix": end_ix,
|
||||
"test_end_ix": test_end,
|
||||
"train_end_mst_utc": int(ordered.iloc[end_ix - 1]["mst_utc"]),
|
||||
"test_end_mst_utc": int(ordered.iloc[test_end]["mst_utc"]),
|
||||
}
|
||||
)
|
||||
return windows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not SOURCE_CSV.exists():
|
||||
raise SystemExit(f"Missing source CSV: {SOURCE_CSV}")
|
||||
|
||||
frame = pd.read_csv(SOURCE_CSV)
|
||||
if "mst_utc" not in frame.columns:
|
||||
raise SystemExit("training_data.csv must include mst_utc")
|
||||
|
||||
ordered = frame.sort_values("mst_utc").reset_index(drop=True)
|
||||
ordered["lineup_completeness"] = 1.0
|
||||
ordered["referee_available"] = (
|
||||
ordered.get("referee_experience", pd.Series([0] * len(ordered))).fillna(0) > 0
|
||||
).astype(float)
|
||||
ordered["league_reliability"] = ordered.get("league_zero_goal_rate", 0).fillna(0).apply(
|
||||
lambda value: round(max(0.25, min(0.95, 0.85 - float(value))), 4)
|
||||
)
|
||||
ordered["odds_snapshot_freshness"] = 1.0
|
||||
|
||||
train_end = max(int(len(ordered) * 0.70), 1)
|
||||
validation_end = max(int(len(ordered) * 0.85), train_end + 1)
|
||||
validation_end = min(validation_end, len(ordered) - 1)
|
||||
|
||||
train_df = ordered.iloc[:train_end].copy()
|
||||
validation_df = ordered.iloc[train_end:validation_end].copy()
|
||||
holdout_df = ordered.iloc[validation_end:].copy()
|
||||
|
||||
train_df.to_csv(TARGET_DIR / "train.csv", index=False)
|
||||
validation_df.to_csv(TARGET_DIR / "validation.csv", index=False)
|
||||
holdout_df.to_csv(TARGET_DIR / "holdout.csv", index=False)
|
||||
|
||||
meta = {
|
||||
"source": str(SOURCE_CSV),
|
||||
"rows": int(len(ordered)),
|
||||
"train_rows": int(len(train_df)),
|
||||
"validation_rows": int(len(validation_df)),
|
||||
"holdout_rows": int(len(holdout_df)),
|
||||
"rolling_windows": _rolling_windows(ordered),
|
||||
"derived_columns": [
|
||||
"lineup_completeness",
|
||||
"referee_available",
|
||||
"league_reliability",
|
||||
"odds_snapshot_freshness",
|
||||
],
|
||||
"feature_policy": "prediction_time_only",
|
||||
}
|
||||
(TARGET_DIR / "dataset_meta.json").write_text(
|
||||
json.dumps(meta, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"[OK] V26 dataset written to {TARGET_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
AI_ENGINE_DIR = Path(__file__).resolve().parents[1]
|
||||
DATA_DIR = AI_ENGINE_DIR / "data" / "v26_shadow"
|
||||
CONFIG_PATH = AI_ENGINE_DIR / "models" / "v26_shadow" / "market_profiles.json"
|
||||
REPORT_PATH = AI_ENGINE_DIR / "reports" / "training_v26_shadow.json"
|
||||
REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _market_accuracy(frame: pd.DataFrame, target_col: str) -> float:
|
||||
if target_col not in frame.columns or frame.empty:
|
||||
return 0.0
|
||||
counts = frame[target_col].value_counts(normalize=True)
|
||||
if counts.empty:
|
||||
return 0.0
|
||||
return round(float(counts.max()), 4)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
train_csv = DATA_DIR / "train.csv"
|
||||
validation_csv = DATA_DIR / "validation.csv"
|
||||
if not train_csv.exists() or not validation_csv.exists():
|
||||
raise SystemExit("Run extract_training_data_v26.py first")
|
||||
|
||||
train_df = pd.read_csv(train_csv)
|
||||
validation_df = pd.read_csv(validation_csv)
|
||||
config = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
report = {
|
||||
"version": config.get("version"),
|
||||
"calibration_version": config.get("calibration_version"),
|
||||
"train_rows": int(len(train_df)),
|
||||
"validation_rows": int(len(validation_df)),
|
||||
"label_priors": {
|
||||
"MS": _market_accuracy(validation_df, "label_ms"),
|
||||
"OU25": _market_accuracy(validation_df, "label_ou25"),
|
||||
"BTTS": _market_accuracy(validation_df, "label_btts"),
|
||||
"HT": _market_accuracy(validation_df, "label_ht_result"),
|
||||
"HTFT": _market_accuracy(validation_df, "label_ht_ft"),
|
||||
"CARDS": _market_accuracy(validation_df, "label_cards_ou45"),
|
||||
},
|
||||
"artifact_path": str(CONFIG_PATH),
|
||||
"notes": [
|
||||
"v26.shadow runtime currently uses artifact-based calibration and ROI gating",
|
||||
"market profile JSON remains the source of truth for runtime thresholds",
|
||||
],
|
||||
}
|
||||
REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"[OK] Shadow training report written to {REPORT_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import re
|
||||
import time
|
||||
import math
|
||||
import os
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
@@ -33,6 +34,7 @@ from models.basketball_v25 import (
|
||||
)
|
||||
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
|
||||
from services.feature_enrichment import FeatureEnrichmentService
|
||||
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
|
||||
from utils.top_leagues import load_top_league_ids
|
||||
from utils.league_reliability import load_league_reliability
|
||||
|
||||
@@ -137,8 +139,10 @@ class SingleMatchOrchestrator:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.v25_predictor: Optional[V25Predictor] = None
|
||||
self.v26_shadow_engine: Optional[V26ShadowEngine] = None
|
||||
self.basketball_predictor: Optional[Any] = None
|
||||
self.dsn = get_clean_dsn()
|
||||
self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v25")).strip().lower()
|
||||
self.top_league_ids = load_top_league_ids()
|
||||
self.league_reliability = load_league_reliability()
|
||||
self.enrichment = FeatureEnrichmentService()
|
||||
@@ -212,6 +216,11 @@ class SingleMatchOrchestrator:
|
||||
self.v25_predictor = get_v25_predictor()
|
||||
return self.v25_predictor
|
||||
|
||||
def _get_v26_shadow_engine(self) -> V26ShadowEngine:
|
||||
if getattr(self, "v26_shadow_engine", None) is None:
|
||||
self.v26_shadow_engine = get_v26_shadow_engine()
|
||||
return self.v26_shadow_engine
|
||||
|
||||
def _build_v25_features(self, data: MatchData) -> Dict[str, float]:
|
||||
"""
|
||||
Build the single authoritative V25 pre-match feature vector.
|
||||
@@ -676,7 +685,35 @@ class SingleMatchOrchestrator:
|
||||
features = self._build_v25_features(data)
|
||||
v25_signal = self._get_v25_signal(data, features)
|
||||
prediction = self._build_v25_prediction(data, features, v25_signal)
|
||||
return self._build_prediction_package(data, prediction, v25_signal)
|
||||
base_package = self._build_prediction_package(data, prediction, v25_signal)
|
||||
|
||||
mode = str(getattr(self, "engine_mode", "v25") or "v25").lower()
|
||||
if mode not in {"v25", "v26", "dual"}:
|
||||
mode = "v25"
|
||||
|
||||
quality = base_package.get("data_quality", self._compute_data_quality(data))
|
||||
shadow_package = self._get_v26_shadow_engine().build_package(
|
||||
data=data,
|
||||
prediction=prediction,
|
||||
v25_signal=v25_signal,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if mode == "v26":
|
||||
return shadow_package
|
||||
if mode == "dual":
|
||||
merged = dict(base_package)
|
||||
merged.update(
|
||||
{
|
||||
"shadow_engine": shadow_package,
|
||||
"shadow_engine_version": shadow_package.get("model_version"),
|
||||
"calibration_version": shadow_package.get("calibration_version"),
|
||||
"decision_trace_id": shadow_package.get("decision_trace_id"),
|
||||
"market_reliability": shadow_package.get("market_reliability", {}),
|
||||
}
|
||||
)
|
||||
return merged
|
||||
return base_package
|
||||
|
||||
def analyze_match_htms(self, match_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,8 +90,10 @@ class _RouterCursor:
|
||||
def _build_orchestrator() -> SingleMatchOrchestrator:
|
||||
orchestrator = SingleMatchOrchestrator.__new__(SingleMatchOrchestrator)
|
||||
orchestrator.v25_predictor = MagicMock()
|
||||
orchestrator.v26_shadow_engine = None
|
||||
orchestrator.basketball_predictor = MagicMock()
|
||||
orchestrator.dsn = "postgresql://unit-test"
|
||||
orchestrator.engine_mode = "v25"
|
||||
orchestrator.league_reliability = {}
|
||||
orchestrator.market_calibration = {
|
||||
"MS": 0.82,
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ENGINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ENGINE_ROOT))
|
||||
|
||||
from services.v26_shadow_engine import V26ShadowEngine
|
||||
|
||||
|
||||
def _build_prediction():
|
||||
return SimpleNamespace(
|
||||
risk_level="MEDIUM",
|
||||
risk_score=42.0,
|
||||
is_surprise_risk=False,
|
||||
surprise_type="",
|
||||
surprise_score=0.0,
|
||||
surprise_comment="",
|
||||
surprise_reasons=[],
|
||||
risk_warnings=[],
|
||||
team_confidence=71.0,
|
||||
player_confidence=64.0,
|
||||
odds_confidence=75.0,
|
||||
referee_confidence=58.0,
|
||||
predicted_ft_score="2-1",
|
||||
predicted_ht_score="1-0",
|
||||
home_xg=1.72,
|
||||
away_xg=1.08,
|
||||
total_xg=2.8,
|
||||
ft_scores_top5=[
|
||||
{"score": "2-1", "prob": 0.093},
|
||||
{"score": "1-1", "prob": 0.086},
|
||||
],
|
||||
ms_home_prob=0.52,
|
||||
ms_draw_prob=0.24,
|
||||
ms_away_prob=0.24,
|
||||
)
|
||||
|
||||
|
||||
def _build_data(referee_name="Ref A", lineup_source="confirmed_live", league_id="league1"):
|
||||
return SimpleNamespace(
|
||||
match_id="m1",
|
||||
home_team_name="Home",
|
||||
away_team_name="Away",
|
||||
league_id=league_id,
|
||||
league_name="League",
|
||||
match_date_ms=1710000000000,
|
||||
sport="football",
|
||||
home_lineup=["h"] * 11,
|
||||
away_lineup=["a"] * 11,
|
||||
lineup_source=lineup_source,
|
||||
referee_name=referee_name,
|
||||
odds_data={
|
||||
"ms_h": 2.1,
|
||||
"ms_d": 3.4,
|
||||
"ms_a": 3.7,
|
||||
"dc_1x": 1.28,
|
||||
"dc_x2": 1.68,
|
||||
"dc_12": 1.34,
|
||||
"ou15_o": 1.24,
|
||||
"ou15_u": 4.1,
|
||||
"ou25_o": 1.77,
|
||||
"ou25_u": 2.05,
|
||||
"ou35_o": 2.95,
|
||||
"ou35_u": 1.4,
|
||||
"btts_y": 1.74,
|
||||
"btts_n": 2.04,
|
||||
"ht_h": 2.72,
|
||||
"ht_d": 2.05,
|
||||
"ht_a": 4.8,
|
||||
"ht_ou05_o": 1.38,
|
||||
"ht_ou05_u": 2.85,
|
||||
"ht_ou15_o": 2.48,
|
||||
"ht_ou15_u": 1.48,
|
||||
"oe_odd": 1.92,
|
||||
"oe_even": 1.9,
|
||||
"cards_o": 1.98,
|
||||
"cards_u": 1.84,
|
||||
"hcap_h": 3.3,
|
||||
"hcap_d": 3.7,
|
||||
"hcap_a": 1.93,
|
||||
"htft_11": 3.8,
|
||||
"htft_1x": 5.1,
|
||||
"htft_12": 16.5,
|
||||
"htft_x1": 5.6,
|
||||
"htft_xx": 4.8,
|
||||
"htft_x2": 7.4,
|
||||
"htft_21": 22.0,
|
||||
"htft_2x": 12.0,
|
||||
"htft_22": 6.2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class V26ShadowEngineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = V26ShadowEngine()
|
||||
self.engine.top_league_ids = {"top1"}
|
||||
self.prediction = _build_prediction()
|
||||
self.quality = {
|
||||
"label": "HIGH",
|
||||
"score": 0.88,
|
||||
"home_lineup_count": 11,
|
||||
"away_lineup_count": 11,
|
||||
"lineup_source": "confirmed_live",
|
||||
"flags": [],
|
||||
}
|
||||
self.v25_signal = {
|
||||
"MS": {"probs": {"1": 0.46, "X": 0.27, "2": 0.27}},
|
||||
"HT": {"probs": {"1": 0.39, "X": 0.41, "2": 0.20}},
|
||||
"HTFT": {"probs": {"1/1": 0.22, "X/X": 0.18, "2/2": 0.14}},
|
||||
"HCAP": {"probs": {"1": 0.21, "X": 0.19, "2": 0.60}},
|
||||
"CARDS": {"probs": {"Under": 0.53, "Over": 0.47}},
|
||||
}
|
||||
|
||||
def test_build_package_exposes_shadow_metadata(self):
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(),
|
||||
prediction=self.prediction,
|
||||
v25_signal=self.v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
self.assertEqual(package["model_version"], "v26.shadow.2")
|
||||
self.assertIn("calibration_version", package)
|
||||
self.assertIn("decision_trace_id", package)
|
||||
self.assertIn("market_reliability", package)
|
||||
self.assertTrue(package["bet_summary"])
|
||||
|
||||
def test_cards_defaults_to_pass_when_referee_missing(self):
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(referee_name=None),
|
||||
prediction=self.prediction,
|
||||
v25_signal=self.v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
cards = next(item for item in package["bet_summary"] if item["market"] == "CARDS")
|
||||
self.assertFalse(cards["playable"])
|
||||
self.assertEqual(cards["bet_grade"], "PASS")
|
||||
|
||||
def test_select_main_pick_prioritizes_ms_when_playable(self):
|
||||
rows = [
|
||||
{
|
||||
"market": "OU25",
|
||||
"pick": "2.5 Üst",
|
||||
"playable": True,
|
||||
"selection_score": 86.0,
|
||||
"play_score": 83.0,
|
||||
"edge": 0.15,
|
||||
"calibrated_confidence": 72.0,
|
||||
},
|
||||
{
|
||||
"market": "MS",
|
||||
"pick": "1",
|
||||
"playable": True,
|
||||
"selection_score": 81.0,
|
||||
"play_score": 82.0,
|
||||
"edge": 0.08,
|
||||
"calibrated_confidence": 64.0,
|
||||
},
|
||||
]
|
||||
|
||||
main_pick = self.engine._select_main_pick(rows)
|
||||
|
||||
self.assertIsNotNone(main_pick)
|
||||
self.assertEqual(main_pick["market"], "MS")
|
||||
self.assertEqual(main_pick["pick_reason"], "ms_priority_market")
|
||||
|
||||
def test_build_package_exposes_surprise_pick_when_reversal_is_hot(self):
|
||||
prediction = _build_prediction()
|
||||
prediction.is_surprise_risk = True
|
||||
prediction.surprise_score = 82.0
|
||||
prediction.surprise_type = "favorite_reversal"
|
||||
v25_signal = dict(self.v25_signal)
|
||||
v25_signal["HTFT"] = {
|
||||
"probs": {
|
||||
"1/2": 0.24,
|
||||
"X/2": 0.14,
|
||||
"1/1": 0.12,
|
||||
"X/X": 0.10,
|
||||
}
|
||||
}
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(),
|
||||
prediction=prediction,
|
||||
v25_signal=v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
self.assertIn("surprise_hunter", package)
|
||||
self.assertIn("surprise_pick", package)
|
||||
self.assertTrue(package["surprise_hunter"]["playable"])
|
||||
self.assertEqual(package["surprise_pick"]["market"], "HTFT")
|
||||
self.assertEqual(package["surprise_pick"]["strategy_channel"], "surprise_sidecar")
|
||||
self.assertEqual(package["surprise_hunter"]["strategy_channel"], "surprise_sidecar")
|
||||
self.assertGreaterEqual(package["surprise_pick"]["surprise_score"], 66.0)
|
||||
self.assertEqual(package["main_pick"]["strategy_channel"], "standard")
|
||||
self.assertNotEqual(package["main_pick"].get("strategy_channel"), package["surprise_pick"].get("strategy_channel"))
|
||||
self.assertNotEqual(package["main_pick"].get("pick_reason"), "favorite_reversal_signal")
|
||||
|
||||
def test_top_league_policy_suppresses_early_and_extra_goal_markets(self):
|
||||
package = self.engine.build_package(
|
||||
data=_build_data(league_id="top1"),
|
||||
prediction=self.prediction,
|
||||
v25_signal=self.v25_signal,
|
||||
quality=self.quality,
|
||||
)
|
||||
|
||||
summary = {item["market"]: item for item in package["bet_summary"]}
|
||||
self.assertFalse(summary["HT_OU05"]["playable"])
|
||||
self.assertTrue(
|
||||
"top_league_early_market_suppressed" in summary["HT_OU05"]["reasons"]
|
||||
or "top_league_ht_ou05_over_disabled" in summary["HT_OU05"]["reasons"]
|
||||
)
|
||||
|
||||
playable_goal_cluster = [
|
||||
item for item in package["bet_summary"]
|
||||
if item["market"] in {"OU15", "OU25", "OU35", "BTTS"} and item["playable"]
|
||||
]
|
||||
self.assertLessEqual(len(playable_goal_cluster), 1)
|
||||
|
||||
def test_scoreline_consistency_blocks_conflicting_markets(self):
|
||||
rows = [
|
||||
{
|
||||
"market": "MS",
|
||||
"raw_pick": "1",
|
||||
"pick": "1",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
{
|
||||
"market": "BTTS",
|
||||
"raw_pick": "Yes",
|
||||
"pick": "KG Var",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
{
|
||||
"market": "OU25",
|
||||
"raw_pick": "Over",
|
||||
"pick": "2.5 Üst",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
{
|
||||
"market": "OU25",
|
||||
"raw_pick": "Under",
|
||||
"pick": "2.5 Alt",
|
||||
"playable": True,
|
||||
"bet_grade": "A",
|
||||
"stake_units": 1.0,
|
||||
"decision_reasons": [],
|
||||
},
|
||||
]
|
||||
prediction = _build_prediction()
|
||||
prediction.predicted_ft_score = "1-0"
|
||||
prediction.predicted_ht_score = "1-0"
|
||||
|
||||
controlled = self.engine._apply_scoreline_consistency_controls(rows, prediction)
|
||||
by_market_pick = {(row["market"], row["raw_pick"]): row for row in controlled}
|
||||
|
||||
self.assertTrue(by_market_pick[("MS", "1")]["playable"])
|
||||
self.assertIn(
|
||||
"scoreline_scenario_aligned",
|
||||
by_market_pick[("MS", "1")]["decision_reasons"],
|
||||
)
|
||||
self.assertFalse(by_market_pick[("BTTS", "Yes")]["playable"])
|
||||
self.assertFalse(by_market_pick[("OU25", "Over")]["playable"])
|
||||
self.assertTrue(by_market_pick[("OU25", "Under")]["playable"])
|
||||
self.assertIn(
|
||||
"scoreline_scenario_conflict",
|
||||
by_market_pick[("BTTS", "Yes")]["decision_reasons"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -29,6 +29,13 @@
|
||||
"cleanup:live": "ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts",
|
||||
"swagger:summary": "ts-node -r tsconfig-paths/register src/scripts/export-swagger-endpoints-summary.ts",
|
||||
"postman:export": "ts-node -r tsconfig-paths/register src/scripts/export-postman-collection.ts"
|
||||
,
|
||||
"ai:extract:v26": "python3 ai-engine/scripts/extract_training_data_v26.py",
|
||||
"ai:train:v26": "python3 ai-engine/scripts/train_v26_shadow.py",
|
||||
"ai:backtest:v26": "python3 ai-engine/scripts/backtest_v26_shadow.py",
|
||||
"ai:backtest:v26:roi": "python3 ai-engine/scripts/backtest_v26_shadow_roi_detail.py",
|
||||
"ai:backtest:v26:htft": "python3 ai-engine/scripts/backtest_v26_shadow_htft_upset.py",
|
||||
"ai:test": "python3 -m pytest ai-engine/tests/test_main_api.py ai-engine/tests/test_single_match_orchestrator.py ai-engine/tests/test_v26_shadow_engine.py"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.964.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "prediction_runs" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"match_id" TEXT NOT NULL,
|
||||
"engine_version" TEXT NOT NULL,
|
||||
"decision_trace_id" TEXT,
|
||||
"generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"odds_snapshot" JSONB,
|
||||
"payload_summary" JSONB NOT NULL,
|
||||
"eventual_outcome" TEXT,
|
||||
"unit_profit" DOUBLE PRECISION,
|
||||
|
||||
CONSTRAINT "prediction_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "prediction_runs_match_id_generated_at_idx"
|
||||
ON "prediction_runs"("match_id", "generated_at" DESC);
|
||||
|
||||
CREATE INDEX "prediction_runs_engine_version_generated_at_idx"
|
||||
ON "prediction_runs"("engine_version", "generated_at" DESC);
|
||||
@@ -489,6 +489,22 @@ model Prediction {
|
||||
@@map("predictions")
|
||||
}
|
||||
|
||||
model PredictionRun {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId String @map("match_id")
|
||||
engineVersion String @map("engine_version")
|
||||
decisionTraceId String? @map("decision_trace_id")
|
||||
generatedAt DateTime @default(now()) @map("generated_at")
|
||||
oddsSnapshot Json? @map("odds_snapshot")
|
||||
payloadSummary Json @map("payload_summary")
|
||||
eventualOutcome String? @map("eventual_outcome")
|
||||
unitProfit Float? @map("unit_profit")
|
||||
|
||||
@@index([matchId, generatedAt(sort: Desc)])
|
||||
@@index([engineVersion, generatedAt(sort: Desc)])
|
||||
@@map("prediction_runs")
|
||||
}
|
||||
|
||||
model AiPredictionsLog {
|
||||
id Int @id @default(autoincrement())
|
||||
matchId String @map("match_id")
|
||||
|
||||
@@ -23,6 +23,7 @@ export const envSchema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
// AI Engine
|
||||
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"),
|
||||
AI_ENGINE_MODE: z.enum(["v25", "dual", "v26"]).default("v25"),
|
||||
|
||||
// JWT
|
||||
JWT_SECRET: z.string().min(32),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, Res } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import type { Response } from "express";
|
||||
import { Public } from "../../common/decorators";
|
||||
import { PrismaService } from "../../database/prisma.service";
|
||||
import { PredictionsService } from "../predictions/predictions.service";
|
||||
|
||||
@@ -115,6 +115,9 @@ export class MatchPickDto {
|
||||
@ApiProperty()
|
||||
market: string;
|
||||
|
||||
@ApiProperty({ required: false, default: "standard" })
|
||||
strategy_channel?: string;
|
||||
|
||||
@ApiProperty()
|
||||
pick: string;
|
||||
|
||||
@@ -350,6 +353,15 @@ export class MatchPredictionDto {
|
||||
@ApiProperty()
|
||||
model_version: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
calibration_version?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
shadow_engine_version?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
decision_trace_id?: string | null;
|
||||
|
||||
@ApiProperty({ type: MatchInfoDto })
|
||||
match_info: MatchInfoDto;
|
||||
|
||||
@@ -368,6 +380,9 @@ export class MatchPredictionDto {
|
||||
@ApiProperty({ type: MatchPickDto, nullable: true })
|
||||
value_pick: MatchPickDto | null;
|
||||
|
||||
@ApiProperty({ type: MatchPickDto, nullable: true, required: false })
|
||||
surprise_pick?: MatchPickDto | null;
|
||||
|
||||
@ApiProperty({ type: MatchBetAdviceDto })
|
||||
bet_advice: MatchBetAdviceDto;
|
||||
|
||||
@@ -394,6 +409,15 @@ export class MatchPredictionDto {
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
reasoning_factors: string[];
|
||||
|
||||
@ApiProperty({ type: Object, required: false })
|
||||
market_reliability?: Record<string, number>;
|
||||
|
||||
@ApiProperty({ type: Object, required: false })
|
||||
shadow_engine?: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ type: Object, required: false })
|
||||
surprise_hunter?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ValueBetDto {
|
||||
@@ -476,6 +500,9 @@ export class AIHealthDto {
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
detail?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
mode?: string | null;
|
||||
}
|
||||
|
||||
export * from "./smart-coupon.dto";
|
||||
|
||||
@@ -183,6 +183,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
circuitState: circuit.state,
|
||||
consecutiveFailures: circuit.consecutiveFailures,
|
||||
endpoint: this.aiEngineUrl,
|
||||
mode:
|
||||
typeof (response.data as Record<string, unknown>)?.mode === "string"
|
||||
? String((response.data as Record<string, unknown>).mode)
|
||||
: this.configService.get("AI_ENGINE_MODE", "v25"),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const requestError =
|
||||
@@ -203,6 +207,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
typeof requestError.detail === "string"
|
||||
? requestError.detail
|
||||
: requestError.message,
|
||||
mode: this.configService.get("AI_ENGINE_MODE", "v25"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -219,6 +224,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!data || data.error) {
|
||||
return null;
|
||||
}
|
||||
await this.recordPredictionRun(matchId, data as MatchPredictionDto);
|
||||
return this.enrichPredictionResponse(
|
||||
data as MatchPredictionDto,
|
||||
matchContext,
|
||||
@@ -236,6 +242,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
`/v20plus/analyze/${matchId}`,
|
||||
{},
|
||||
);
|
||||
await this.recordPredictionRun(matchId, response.data);
|
||||
return this.enrichPredictionResponse(
|
||||
response.data as MatchPredictionDto,
|
||||
matchContext,
|
||||
@@ -1228,4 +1235,124 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
private async recordPredictionRun(
|
||||
matchId: string,
|
||||
payload: MatchPredictionDto,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const oddsSnapshot = await this.getPredictionOddsSnapshot(matchId);
|
||||
const payloadSummary = this.buildPredictionPayloadSummary(payload);
|
||||
await this.prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO prediction_runs (
|
||||
match_id,
|
||||
engine_version,
|
||||
decision_trace_id,
|
||||
odds_snapshot,
|
||||
payload_summary
|
||||
)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
|
||||
`,
|
||||
matchId,
|
||||
String(payload.model_version || "unknown"),
|
||||
typeof payload.decision_trace_id === "string"
|
||||
? payload.decision_trace_id
|
||||
: null,
|
||||
JSON.stringify(oddsSnapshot),
|
||||
JSON.stringify(payloadSummary),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPredictionOddsSnapshot(
|
||||
matchId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const liveMatch = await this.prisma.liveMatch.findUnique({
|
||||
where: { id: matchId },
|
||||
select: {
|
||||
odds: true,
|
||||
oddsUpdatedAt: true,
|
||||
state: true,
|
||||
status: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
},
|
||||
});
|
||||
if (liveMatch) {
|
||||
return {
|
||||
source: "live_match",
|
||||
odds: liveMatch.odds ?? {},
|
||||
odds_updated_at: liveMatch.oddsUpdatedAt?.toISOString() ?? null,
|
||||
state: liveMatch.state ?? null,
|
||||
status: liveMatch.status ?? null,
|
||||
score_home: liveMatch.scoreHome ?? null,
|
||||
score_away: liveMatch.scoreAway ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const oddCategoryCount = await this.prisma.oddCategory.count({
|
||||
where: { matchId },
|
||||
});
|
||||
return {
|
||||
source: "historical_match",
|
||||
odd_category_count: oddCategoryCount,
|
||||
};
|
||||
}
|
||||
|
||||
private buildPredictionPayloadSummary(
|
||||
payload: MatchPredictionDto,
|
||||
): Record<string, unknown> {
|
||||
const topSummary = Array.isArray(payload.bet_summary)
|
||||
? payload.bet_summary.slice(0, 5).map((item) => ({
|
||||
market: item.market,
|
||||
pick: item.pick,
|
||||
playable: item.playable,
|
||||
bet_grade: item.bet_grade,
|
||||
calibrated_confidence: item.calibrated_confidence,
|
||||
ev_edge: item.ev_edge ?? 0,
|
||||
stake_units: item.stake_units,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
model_version: payload.model_version,
|
||||
calibration_version: payload.calibration_version ?? null,
|
||||
shadow_engine_version: payload.shadow_engine_version ?? null,
|
||||
decision_trace_id: payload.decision_trace_id ?? null,
|
||||
main_pick: payload.main_pick
|
||||
? {
|
||||
market: payload.main_pick.market,
|
||||
pick: payload.main_pick.pick,
|
||||
playable: payload.main_pick.playable,
|
||||
bet_grade: payload.main_pick.bet_grade,
|
||||
calibrated_confidence: payload.main_pick.calibrated_confidence,
|
||||
ev_edge: payload.main_pick.ev_edge ?? 0,
|
||||
stake_units: payload.main_pick.stake_units,
|
||||
}
|
||||
: null,
|
||||
value_pick: payload.value_pick
|
||||
? {
|
||||
market: payload.value_pick.market,
|
||||
pick: payload.value_pick.pick,
|
||||
playable: payload.value_pick.playable,
|
||||
bet_grade: payload.value_pick.bet_grade,
|
||||
calibrated_confidence: payload.value_pick.calibrated_confidence,
|
||||
ev_edge: payload.value_pick.ev_edge ?? 0,
|
||||
}
|
||||
: null,
|
||||
bet_advice: {
|
||||
playable: payload.bet_advice?.playable ?? false,
|
||||
suggested_stake_units:
|
||||
payload.bet_advice?.suggested_stake_units ?? 0,
|
||||
reason: payload.bet_advice?.reason ?? null,
|
||||
},
|
||||
top_summary: topSummary,
|
||||
market_reliability: payload.market_reliability ?? {},
|
||||
shadow_engine: payload.shadow_engine ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user