This commit is contained in:
2026-04-21 16:53:56 +03:00
parent 1346924387
commit 2ccd6831eb
26 changed files with 430403 additions and 3 deletions
+10 -1
View File
@@ -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)}
+902
View File
@@ -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,,,,,,,,,,,,
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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
34 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
35 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"
]
}
+1
View File
@@ -17,3 +17,4 @@ pyyaml>=6.0
# V2 async database
asyncpg>=0.29.0
pydantic>=2.5.0
pytest>=8.0.0
+94
View File
@@ -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()
+58
View File
@@ -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,
+286
View File
@@ -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()