From a338d0224428733290aed0ba79b67c96bb28032d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Sun, 26 Apr 2026 03:07:18 +0300 Subject: [PATCH] main --- ...ggest-bet-platform.postman_collection.json | 291 +++++++++++------- package.json | 1 + src/app.module.ts | 2 + src/config/env.validation.ts | 2 +- src/modules/admin/admin.controller.ts | 13 +- src/modules/ai-proxy/ai-proxy.controller.ts | 20 ++ src/modules/ai-proxy/ai-proxy.module.ts | 17 + src/modules/ai-proxy/ai-proxy.service.ts | 98 ++++++ src/modules/analysis/analysis.controller.ts | 25 +- src/modules/auth/auth.controller.ts | 12 +- src/modules/coupons/coupons.controller.ts | 75 ++++- .../feeder/feeder-persistence.service.ts | 52 ++++ src/modules/feeder/feeder.service.ts | 131 +++++--- src/modules/health/health.controller.ts | 13 +- src/modules/leagues/leagues.controller.ts | 47 ++- src/modules/matches/matches.controller.ts | 13 +- .../predictions/predictions.controller.ts | 13 +- src/modules/spor-toto/spor-toto.controller.ts | 30 +- src/scripts/run-feeder-repair.ts | 123 ++++++++ ts_error.txt | Bin 0 -> 3496 bytes 20 files changed, 818 insertions(+), 160 deletions(-) create mode 100644 src/modules/ai-proxy/ai-proxy.controller.ts create mode 100644 src/modules/ai-proxy/ai-proxy.module.ts create mode 100644 src/modules/ai-proxy/ai-proxy.service.ts create mode 100644 src/scripts/run-feeder-repair.ts create mode 100644 ts_error.txt diff --git a/mds/suggest-bet-platform.postman_collection.json b/mds/suggest-bet-platform.postman_collection.json index cdb978c..dd06fa1 100644 --- a/mds/suggest-bet-platform.postman_collection.json +++ b/mds/suggest-bet-platform.postman_collection.json @@ -99,7 +99,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -194,7 +194,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "[\n \"string\"\n]" } ] }, @@ -289,7 +289,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "[\n \"string\"\n]" } ] }, @@ -355,7 +355,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -421,7 +421,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -456,7 +456,7 @@ }, "response": [ { - "name": "POST /api/admin/usage-limits/reset-all - 201", + "name": "POST /api/admin/usage-limits/reset-all - 200", "originalRequest": { "method": "POST", "header": [ @@ -479,7 +479,7 @@ } }, "status": "", - "code": 201, + "code": 200, "_postman_previewlanguage": "json", "header": [ { @@ -487,7 +487,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"count\": 1\n}" } ] }, @@ -544,7 +544,7 @@ ] } }, - "status": "", + "status": "User deleted", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -621,7 +621,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -687,7 +687,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"key\": \"string\",\n \"value\": \"string\"\n}" } ] }, @@ -755,7 +755,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -823,7 +823,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] } @@ -900,7 +900,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": \"string\",\n \"message\": \"string\"\n}" }, { "name": "POST /api/analysis/analyze-matches - 400", @@ -1110,7 +1110,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": [\n \"string\"\n ]\n}" } ] } @@ -1251,7 +1251,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"message\": \"string\",\n \"data\": \"string\"\n}" } ] }, @@ -1460,7 +1460,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": \"string\",\n \"message\": \"string\"\n}" } ] }, @@ -1591,7 +1591,7 @@ ] } }, - "status": "", + "status": "Coupon created", "code": 201, "_postman_previewlanguage": "json", "header": [ @@ -1600,7 +1600,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": \"string\",\n \"message\": \"string\"\n}" } ] }, @@ -1729,7 +1729,75 @@ ] } }, - "status": "", + "status": "Daily banko coupon", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"success\": true,\n \"data\": \"string\",\n \"message\": \"string\"\n}" + } + ] + }, + { + "name": "Generate frequency-based parlay coupon", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "description": "Scans upcoming matches, applies conditional frequency analysis (team odds-band performance), and builds 2-5 match combos with +EV calculation.", + "url": { + "raw": "{{beBaseUrl}}/api/coupon/frequency-coupon", + "host": [ + "{{beBaseUrl}}" + ], + "path": [ + "api", + "coupon", + "frequency-coupon" + ], + "query": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"matchIds\": [\n \"string\"\n ],\n \"maxMatches\": 3,\n \"minSignal\": 0.7,\n \"markets\": [\n \"string\"\n ]\n}" + } + }, + "response": [ + { + "name": "POST /api/coupon/frequency-coupon - 200", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"matchIds\": [\n \"string\"\n ],\n \"maxMatches\": 3,\n \"minSignal\": 0.7,\n \"markets\": [\n \"string\"\n ]\n}" + }, + "url": { + "raw": "{{beBaseUrl}}/api/coupon/frequency-coupon", + "host": [ + "{{beBaseUrl}}" + ], + "path": [ + "api", + "coupon", + "frequency-coupon" + ] + } + }, + "status": "Frequency coupon generated", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -1809,7 +1877,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": [\n \"string\"\n ],\n \"message\": \"string\"\n}" } ] }, @@ -1864,7 +1932,7 @@ ] } }, - "status": "", + "status": "User statistics", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -1873,7 +1941,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": \"string\",\n \"message\": \"string\"\n}" } ] }, @@ -1941,7 +2009,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"success\": true,\n \"data\": \"string\",\n \"message\": \"string\"\n}" } ] } @@ -1995,7 +2063,7 @@ ] } }, - "status": "The Health Check is successful", + "status": "", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2004,10 +2072,37 @@ "value": "application/json" } ], - "body": "{\n \"status\": \"ok\",\n \"info\": {\n \"database\": {\n \"status\": \"up\"\n }\n },\n \"error\": {},\n \"details\": {\n \"database\": {\n \"status\": \"up\"\n }\n }\n}" - }, + "body": "{}" + } + ] + }, + { + "name": "Dependency-level health details", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "description": "Dependency-level health details", + "url": { + "raw": "{{beBaseUrl}}/api/health/dependencies", + "host": [ + "{{beBaseUrl}}" + ], + "path": [ + "api", + "health", + "dependencies" + ], + "query": [] + } + }, + "response": [ { - "name": "GET /api/health - 503", + "name": "GET /api/health/dependencies - 200", "originalRequest": { "method": "GET", "header": [ @@ -2017,18 +2112,19 @@ } ], "url": { - "raw": "{{beBaseUrl}}/api/health", + "raw": "{{beBaseUrl}}/api/health/dependencies", "host": [ "{{beBaseUrl}}" ], "path": [ "api", - "health" + "health", + "dependencies" ] } }, - "status": "The Health Check is not successful", - "code": 503, + "status": "", + "code": 200, "_postman_previewlanguage": "json", "header": [ { @@ -2036,7 +2132,7 @@ "value": "application/json" } ], - "body": "{\n \"status\": \"error\",\n \"info\": {\n \"database\": {\n \"status\": \"up\"\n }\n },\n \"error\": {\n \"redis\": {\n \"status\": \"down\",\n \"message\": \"Could not connect\"\n }\n },\n \"details\": {\n \"database\": {\n \"status\": \"up\"\n },\n \"redis\": {\n \"status\": \"down\",\n \"message\": \"Could not connect\"\n }\n }\n}" + "body": "{}" } ] }, @@ -2087,7 +2183,7 @@ ] } }, - "status": "", + "status": "System liveness", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2096,7 +2192,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"status\": \"string\",\n \"timestamp\": \"string\"\n}" } ] }, @@ -2147,7 +2243,7 @@ ] } }, - "status": "The Health Check is successful", + "status": "", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2156,40 +2252,7 @@ "value": "application/json" } ], - "body": "{\n \"status\": \"ok\",\n \"info\": {\n \"database\": {\n \"status\": \"up\"\n }\n },\n \"error\": {},\n \"details\": {\n \"database\": {\n \"status\": \"up\"\n }\n }\n}" - }, - { - "name": "GET /api/health/ready - 503", - "originalRequest": { - "method": "GET", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{beBaseUrl}}/api/health/ready", - "host": [ - "{{beBaseUrl}}" - ], - "path": [ - "api", - "health", - "ready" - ] - } - }, - "status": "The Health Check is not successful", - "code": 503, - "_postman_previewlanguage": "json", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": "{\n \"status\": \"error\",\n \"info\": {\n \"database\": {\n \"status\": \"up\"\n }\n },\n \"error\": {\n \"redis\": {\n \"status\": \"down\",\n \"message\": \"Could not connect\"\n }\n },\n \"details\": {\n \"database\": {\n \"status\": \"up\"\n },\n \"redis\": {\n \"status\": \"down\",\n \"message\": \"Could not connect\"\n }\n }\n}" + "body": "{}" } ] } @@ -2254,7 +2317,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "[\n \"string\"\n]" } ] }, @@ -2310,7 +2373,7 @@ ] } }, - "status": "", + "status": "List of leagues", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2319,7 +2382,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "[\n \"string\"\n]" } ] }, @@ -2372,7 +2435,7 @@ ] } }, - "status": "", + "status": "Country details", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2381,7 +2444,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -2453,7 +2516,7 @@ ] } }, - "status": "", + "status": "Head-to-head matches", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2462,7 +2525,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "[\n \"string\"\n]" } ] }, @@ -2513,7 +2576,7 @@ ] } }, - "status": "", + "status": "League details", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2522,7 +2585,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -2575,7 +2638,7 @@ ] } }, - "status": "", + "status": "Team details", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2584,12 +2647,12 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, { - "name": "Get team's recent matches", + "name": "Get team's recent matches (paginated)", "request": { "method": "GET", "header": [ @@ -2598,7 +2661,7 @@ "value": "application/json" } ], - "description": "Get team's recent matches", + "description": "Get team's recent matches (paginated)", "url": { "raw": "{{beBaseUrl}}/api/leagues/teams/{{id}}/matches", "host": [ @@ -2612,10 +2675,22 @@ "matches" ], "query": [ + { + "key": "page", + "value": "", + "description": "Page number (default: 1)", + "disabled": true + }, { "key": "limit", "value": "", - "description": "", + "description": "Items per page (default: 20)", + "disabled": true + }, + { + "key": "season", + "value": "", + "description": "Season (e.g. 2024-2025)", "disabled": true } ] @@ -2646,7 +2721,7 @@ ] } }, - "status": "", + "status": "Paginated list of matches", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2655,7 +2730,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"data\": [\n \"string\"\n ],\n \"meta\": \"string\"\n}" } ] }, @@ -2721,7 +2796,7 @@ ] } }, - "status": "", + "status": "List of teams matching search", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -2730,7 +2805,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "[\n \"string\"\n]" } ] } @@ -2932,7 +3007,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" }, { "name": "GET /api/matches/{id} - 404", @@ -3042,7 +3117,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "{\n \"data\": [\n \"string\"\n ],\n \"meta\": \"string\"\n}" } ] } @@ -3107,7 +3182,7 @@ "value": "application/json" } ], - "body": "{\n \"status\": \"string\",\n \"modelLoaded\": true,\n \"predictionServiceReady\": true\n}" + "body": "{\n \"status\": \"string\",\n \"modelLoaded\": true,\n \"predictionServiceReady\": true,\n \"aiEngineReachable\": true,\n \"circuitState\": \"closed\",\n \"consecutiveFailures\": 0,\n \"endpoint\": \"string\",\n \"detail\": \"string\",\n \"mode\": \"string\"\n}" } ] }, @@ -3175,7 +3250,7 @@ "value": "application/json" } ], - "body": "{\n \"model_version\": \"string\",\n \"match_info\": {\n \"match_id\": \"string\",\n \"match_name\": \"string\",\n \"home_team\": \"string\",\n \"away_team\": \"string\",\n \"league\": \"string\",\n \"match_date_ms\": 1\n },\n \"data_quality\": {\n \"label\": \"HIGH\",\n \"score\": 1,\n \"home_lineup_count\": 1,\n \"away_lineup_count\": 1,\n \"flags\": [\n \"string\"\n ]\n },\n \"risk\": {\n \"level\": \"LOW\",\n \"score\": 1,\n \"is_surprise_risk\": true,\n \"surprise_type\": \"string\",\n \"warnings\": [\n \"string\"\n ]\n },\n \"engine_breakdown\": {\n \"team\": 1,\n \"player\": 1,\n \"odds\": 1,\n \"referee\": 1\n },\n \"main_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"value_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"bet_advice\": {\n \"playable\": true,\n \"suggested_stake_units\": 1,\n \"reason\": \"string\"\n },\n \"bet_summary\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"bet_grade\": \"A\",\n \"playable\": true,\n \"stake_units\": 1,\n \"play_score\": 1,\n \"reasons\": [\n \"string\"\n ]\n }\n ],\n \"supporting_picks\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n }\n ],\n \"aggressive_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"htft\": {\n \"1/1\": 1,\n \"1/X\": 1,\n \"1/2\": 1,\n \"X/1\": 1,\n \"X/X\": 1,\n \"X/2\": 1,\n \"2/1\": 1,\n \"2/X\": 1,\n \"2/2\": 1,\n \"pick\": \"string\",\n \"confidence\": 1\n },\n \"scenario_top5\": [\n {\n \"scenario\": \"string\",\n \"score\": 1,\n \"probability\": 1\n }\n ],\n \"score_prediction\": {\n \"ft\": \"string\",\n \"ht\": \"string\",\n \"xg_home\": 1,\n \"xg_away\": 1,\n \"xg_total\": 1\n },\n \"market_board\": \"string\",\n \"reasoning_factors\": [\n \"string\"\n ]\n}" + "body": "{\n \"model_version\": \"string\",\n \"calibration_version\": \"string\",\n \"shadow_engine_version\": \"string\",\n \"decision_trace_id\": \"string\",\n \"match_info\": {\n \"match_id\": \"string\",\n \"match_name\": \"string\",\n \"home_team\": \"string\",\n \"away_team\": \"string\",\n \"league\": \"string\",\n \"match_date_ms\": 1,\n \"league_id\": \"string\",\n \"is_top_league\": false,\n \"sport\": \"football\"\n },\n \"data_quality\": {\n \"label\": \"HIGH\",\n \"score\": 1,\n \"home_lineup_count\": 1,\n \"away_lineup_count\": 1,\n \"lineup_source\": \"none\",\n \"flags\": [\n \"string\"\n ]\n },\n \"risk\": {\n \"level\": \"LOW\",\n \"score\": 1,\n \"is_surprise_risk\": true,\n \"surprise_type\": \"string\",\n \"surprise_score\": 0,\n \"surprise_comment\": \"string\",\n \"surprise_reasons\": [\n \"string\"\n ],\n \"warnings\": [\n \"string\"\n ]\n },\n \"engine_breakdown\": {\n \"team\": 1,\n \"player\": 1,\n \"odds\": 1,\n \"referee\": 1\n },\n \"main_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"value_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"surprise_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"bet_advice\": {\n \"playable\": true,\n \"suggested_stake_units\": 1,\n \"reason\": \"string\",\n \"confidence_band\": \"HIGH\",\n \"min_confidence_for_play\": 1,\n \"signal_tier\": \"CORE\"\n },\n \"bet_summary\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"bet_grade\": \"A\",\n \"playable\": true,\n \"stake_units\": 1,\n \"play_score\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"odds\": 0,\n \"reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n }\n ],\n \"supporting_picks\": [\n {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n }\n ],\n \"aggressive_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n }\n },\n \"htft\": {\n \"1/1\": 1,\n \"1/X\": 1,\n \"1/2\": 1,\n \"X/1\": 1,\n \"X/X\": 1,\n \"X/2\": 1,\n \"2/1\": 1,\n \"2/X\": 1,\n \"2/2\": 1,\n \"pick\": \"string\",\n \"confidence\": 1\n },\n \"scenario_top5\": [\n {\n \"scenario\": \"string\",\n \"score\": 1,\n \"probability\": 1\n }\n ],\n \"score_prediction\": {\n \"ft\": \"string\",\n \"ht\": \"string\",\n \"xg_home\": 1,\n \"xg_away\": 1,\n \"xg_total\": 1\n },\n \"market_board\": \"string\",\n \"reasoning_factors\": [\n \"string\"\n ],\n \"market_reliability\": \"string\",\n \"shadow_engine\": \"string\",\n \"surprise_hunter\": \"string\",\n \"v27_engine\": \"string\"\n}" } ] }, @@ -3243,7 +3318,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -3294,7 +3369,7 @@ ] } }, - "status": "", + "status": "Match prediction", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -3303,7 +3378,7 @@ "value": "application/json" } ], - "body": "{\n \"model_version\": \"string\",\n \"match_info\": {\n \"match_id\": \"string\",\n \"match_name\": \"string\",\n \"home_team\": \"string\",\n \"away_team\": \"string\",\n \"league\": \"string\",\n \"match_date_ms\": 1\n },\n \"data_quality\": {\n \"label\": \"HIGH\",\n \"score\": 1,\n \"home_lineup_count\": 1,\n \"away_lineup_count\": 1,\n \"flags\": [\n \"string\"\n ]\n },\n \"risk\": {\n \"level\": \"LOW\",\n \"score\": 1,\n \"is_surprise_risk\": true,\n \"surprise_type\": \"string\",\n \"warnings\": [\n \"string\"\n ]\n },\n \"engine_breakdown\": {\n \"team\": 1,\n \"player\": 1,\n \"odds\": 1,\n \"referee\": 1\n },\n \"main_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"value_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"bet_advice\": {\n \"playable\": true,\n \"suggested_stake_units\": 1,\n \"reason\": \"string\"\n },\n \"bet_summary\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"bet_grade\": \"A\",\n \"playable\": true,\n \"stake_units\": 1,\n \"play_score\": 1,\n \"reasons\": [\n \"string\"\n ]\n }\n ],\n \"supporting_picks\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n }\n ],\n \"aggressive_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"htft\": {\n \"1/1\": 1,\n \"1/X\": 1,\n \"1/2\": 1,\n \"X/1\": 1,\n \"X/X\": 1,\n \"X/2\": 1,\n \"2/1\": 1,\n \"2/X\": 1,\n \"2/2\": 1,\n \"pick\": \"string\",\n \"confidence\": 1\n },\n \"scenario_top5\": [\n {\n \"scenario\": \"string\",\n \"score\": 1,\n \"probability\": 1\n }\n ],\n \"score_prediction\": {\n \"ft\": \"string\",\n \"ht\": \"string\",\n \"xg_home\": 1,\n \"xg_away\": 1,\n \"xg_total\": 1\n },\n \"market_board\": \"string\",\n \"reasoning_factors\": [\n \"string\"\n ]\n}" + "body": "{\n \"model_version\": \"string\",\n \"calibration_version\": \"string\",\n \"shadow_engine_version\": \"string\",\n \"decision_trace_id\": \"string\",\n \"match_info\": {\n \"match_id\": \"string\",\n \"match_name\": \"string\",\n \"home_team\": \"string\",\n \"away_team\": \"string\",\n \"league\": \"string\",\n \"match_date_ms\": 1,\n \"league_id\": \"string\",\n \"is_top_league\": false,\n \"sport\": \"football\"\n },\n \"data_quality\": {\n \"label\": \"HIGH\",\n \"score\": 1,\n \"home_lineup_count\": 1,\n \"away_lineup_count\": 1,\n \"lineup_source\": \"none\",\n \"flags\": [\n \"string\"\n ]\n },\n \"risk\": {\n \"level\": \"LOW\",\n \"score\": 1,\n \"is_surprise_risk\": true,\n \"surprise_type\": \"string\",\n \"surprise_score\": 0,\n \"surprise_comment\": \"string\",\n \"surprise_reasons\": [\n \"string\"\n ],\n \"warnings\": [\n \"string\"\n ]\n },\n \"engine_breakdown\": {\n \"team\": 1,\n \"player\": 1,\n \"odds\": 1,\n \"referee\": 1\n },\n \"main_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"value_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"surprise_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"bet_advice\": {\n \"playable\": true,\n \"suggested_stake_units\": 1,\n \"reason\": \"string\",\n \"confidence_band\": \"HIGH\",\n \"min_confidence_for_play\": 1,\n \"signal_tier\": \"CORE\"\n },\n \"bet_summary\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"bet_grade\": \"A\",\n \"playable\": true,\n \"stake_units\": 1,\n \"play_score\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"odds\": 0,\n \"reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n }\n ],\n \"supporting_picks\": [\n {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n }\n ],\n \"aggressive_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n }\n },\n \"htft\": {\n \"1/1\": 1,\n \"1/X\": 1,\n \"1/2\": 1,\n \"X/1\": 1,\n \"X/X\": 1,\n \"X/2\": 1,\n \"2/1\": 1,\n \"2/X\": 1,\n \"2/2\": 1,\n \"pick\": \"string\",\n \"confidence\": 1\n },\n \"scenario_top5\": [\n {\n \"scenario\": \"string\",\n \"score\": 1,\n \"probability\": 1\n }\n ],\n \"score_prediction\": {\n \"ft\": \"string\",\n \"ht\": \"string\",\n \"xg_home\": 1,\n \"xg_away\": 1,\n \"xg_total\": 1\n },\n \"market_board\": \"string\",\n \"reasoning_factors\": [\n \"string\"\n ],\n \"market_reliability\": \"string\",\n \"shadow_engine\": \"string\",\n \"surprise_hunter\": \"string\",\n \"v27_engine\": \"string\"\n}" }, { "name": "GET /api/predictions/{matchId} - 404", @@ -3456,7 +3531,7 @@ "value": "application/json" } ], - "body": "{\n \"count\": 1,\n \"matches\": [\n {\n \"model_version\": \"string\",\n \"match_info\": {\n \"match_id\": \"string\",\n \"match_name\": \"string\",\n \"home_team\": \"string\",\n \"away_team\": \"string\",\n \"league\": \"string\",\n \"match_date_ms\": 1\n },\n \"data_quality\": {\n \"label\": \"HIGH\",\n \"score\": 1,\n \"home_lineup_count\": 1,\n \"away_lineup_count\": 1,\n \"flags\": [\n \"string\"\n ]\n },\n \"risk\": {\n \"level\": \"LOW\",\n \"score\": 1,\n \"is_surprise_risk\": true,\n \"surprise_type\": \"string\",\n \"warnings\": [\n \"string\"\n ]\n },\n \"engine_breakdown\": {\n \"team\": 1,\n \"player\": 1,\n \"odds\": 1,\n \"referee\": 1\n },\n \"main_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"value_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"bet_advice\": {\n \"playable\": true,\n \"suggested_stake_units\": 1,\n \"reason\": \"string\"\n },\n \"bet_summary\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"bet_grade\": \"A\",\n \"playable\": true,\n \"stake_units\": 1,\n \"play_score\": 1,\n \"reasons\": [\n \"string\"\n ]\n }\n ],\n \"supporting_picks\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n }\n ],\n \"aggressive_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ]\n },\n \"htft\": {\n \"1/1\": 1,\n \"1/X\": 1,\n \"1/2\": 1,\n \"X/1\": 1,\n \"X/X\": 1,\n \"X/2\": 1,\n \"2/1\": 1,\n \"2/X\": 1,\n \"2/2\": 1,\n \"pick\": \"string\",\n \"confidence\": 1\n },\n \"scenario_top5\": [\n {\n \"scenario\": \"string\",\n \"score\": 1,\n \"probability\": 1\n }\n ],\n \"score_prediction\": {\n \"ft\": \"string\",\n \"ht\": \"string\",\n \"xg_home\": 1,\n \"xg_away\": 1,\n \"xg_total\": 1\n },\n \"market_board\": \"string\",\n \"reasoning_factors\": [\n \"string\"\n ]\n }\n ],\n \"modelVersion\": \"string\"\n}" + "body": "{\n \"count\": 1,\n \"matches\": [\n {\n \"model_version\": \"string\",\n \"calibration_version\": \"string\",\n \"shadow_engine_version\": \"string\",\n \"decision_trace_id\": \"string\",\n \"match_info\": {\n \"match_id\": \"string\",\n \"match_name\": \"string\",\n \"home_team\": \"string\",\n \"away_team\": \"string\",\n \"league\": \"string\",\n \"match_date_ms\": 1,\n \"league_id\": \"string\",\n \"is_top_league\": false,\n \"sport\": \"football\"\n },\n \"data_quality\": {\n \"label\": \"HIGH\",\n \"score\": 1,\n \"home_lineup_count\": 1,\n \"away_lineup_count\": 1,\n \"lineup_source\": \"none\",\n \"flags\": [\n \"string\"\n ]\n },\n \"risk\": {\n \"level\": \"LOW\",\n \"score\": 1,\n \"is_surprise_risk\": true,\n \"surprise_type\": \"string\",\n \"surprise_score\": 0,\n \"surprise_comment\": \"string\",\n \"surprise_reasons\": [\n \"string\"\n ],\n \"warnings\": [\n \"string\"\n ]\n },\n \"engine_breakdown\": {\n \"team\": 1,\n \"player\": 1,\n \"odds\": 1,\n \"referee\": 1\n },\n \"main_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"value_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"surprise_pick\": {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n },\n \"bet_advice\": {\n \"playable\": true,\n \"suggested_stake_units\": 1,\n \"reason\": \"string\",\n \"confidence_band\": \"HIGH\",\n \"min_confidence_for_play\": 1,\n \"signal_tier\": \"CORE\"\n },\n \"bet_summary\": [\n {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"bet_grade\": \"A\",\n \"playable\": true,\n \"stake_units\": 1,\n \"play_score\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"odds\": 0,\n \"reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n }\n ],\n \"supporting_picks\": [\n {\n \"market\": \"string\",\n \"strategy_channel\": \"standard\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n },\n \"signal_tier\": \"CORE\"\n }\n ],\n \"aggressive_pick\": {\n \"market\": \"string\",\n \"pick\": \"string\",\n \"probability\": 1,\n \"confidence\": 1,\n \"odds\": 1,\n \"raw_confidence\": 1,\n \"calibrated_confidence\": 1,\n \"min_required_confidence\": 1,\n \"edge\": 1,\n \"ev_edge\": 0,\n \"implied_prob\": 0,\n \"play_score\": 1,\n \"playable\": true,\n \"bet_grade\": \"A\",\n \"stake_units\": 1,\n \"decision_reasons\": [\n \"string\"\n ],\n \"confidence_interval\": {\n \"lower\": 1,\n \"upper\": 1,\n \"width\": 1,\n \"band\": \"HIGH\",\n \"threshold_met\": true\n }\n },\n \"htft\": {\n \"1/1\": 1,\n \"1/X\": 1,\n \"1/2\": 1,\n \"X/1\": 1,\n \"X/X\": 1,\n \"X/2\": 1,\n \"2/1\": 1,\n \"2/X\": 1,\n \"2/2\": 1,\n \"pick\": \"string\",\n \"confidence\": 1\n },\n \"scenario_top5\": [\n {\n \"scenario\": \"string\",\n \"score\": 1,\n \"probability\": 1\n }\n ],\n \"score_prediction\": {\n \"ft\": \"string\",\n \"ht\": \"string\",\n \"xg_home\": 1,\n \"xg_away\": 1,\n \"xg_total\": 1\n },\n \"market_board\": \"string\",\n \"reasoning_factors\": [\n \"string\"\n ],\n \"market_reliability\": \"string\",\n \"shadow_engine\": \"string\",\n \"surprise_hunter\": \"string\",\n \"v27_engine\": \"string\"\n }\n ],\n \"modelVersion\": \"string\"\n}" } ] }, @@ -3569,7 +3644,7 @@ ] } }, - "status": "", + "status": "Prediction details", "code": 200, "_postman_previewlanguage": "json", "header": [ @@ -3578,7 +3653,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] } @@ -3792,7 +3867,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" }, { "name": "POST /api/spor-toto/bulletins - 409", @@ -3903,7 +3978,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -3975,7 +4050,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -4049,7 +4124,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -4111,7 +4186,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" }, { "name": "GET /api/spor-toto/bulletins/{id} - 404", @@ -4209,7 +4284,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -4276,7 +4351,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -4349,7 +4424,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -4413,7 +4488,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" } ] }, @@ -4489,7 +4564,7 @@ "value": "application/json" } ], - "body": "{}" + "body": "\"string\"" }, { "name": "PATCH /api/spor-toto/bulletins/{id}/results - 404", diff --git a/package.json b/package.json index 2eb682e..b1d42b3 100755 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "ai:backtest": "python ai-engine/scripts/backtest_v2_runtime.py", "ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.py", "feeder:historical": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts", + "feeder:repair": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-repair.ts", "feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-previous-day.ts", "feeder:fill-gaps": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-filtered.ts", "feeder:basketball": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-basketball.ts", diff --git a/src/app.module.ts b/src/app.module.ts index 3b66951..b37aed5 100755 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -50,6 +50,7 @@ import { LeaguesModule } from "./modules/leagues/leagues.module"; import { AnalysisModule } from "./modules/analysis/analysis.module"; import { CouponsModule } from "./modules/coupons/coupons.module"; import { SporTotoModule } from "./modules/spor-toto/spor-toto.module"; +import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module"; // Services and Tasks import { ServicesModule } from "./services/services.module"; @@ -201,6 +202,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical"; AnalysisModule, CouponsModule, SporTotoModule, + AiProxyModule, // Services and Scheduled Tasks ServicesModule, diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 5753e98..f4f09d2 100755 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -22,7 +22,7 @@ export const envSchema = z.object({ // Database DATABASE_URL: z.string().url(), // AI Engine - AI_ENGINE_URL: z.string().url().default("http://localhost:8000"), + AI_ENGINE_URL: z.string().url(), AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"), // JWT diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index c004b9c..cab344e 100755 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -18,7 +18,7 @@ import { CACHE_MANAGER, } from "@nestjs/cache-manager"; import * as cacheManager from "cache-manager"; -import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse as SwaggerResponse } from "@nestjs/swagger"; import { Roles } from "../../common/decorators"; import { PrismaService } from "../../database/prisma.service"; import { PaginationDto } from "../../common/dto/pagination.dto"; @@ -46,6 +46,7 @@ export class AdminController { @Get("users") @ApiOperation({ summary: "Get all users (admin)" }) + @SwaggerResponse({ status: 200, type: [UserResponseDto] }) async getAllUsers( @Query() pagination: PaginationDto, ): Promise>> { @@ -75,6 +76,7 @@ export class AdminController { @Get("users/:id") @ApiOperation({ summary: "Get user by ID" }) + @SwaggerResponse({ status: 200, type: UserResponseDto }) async getUserById( @Param("id") id: string, ): Promise> { @@ -98,6 +100,7 @@ export class AdminController { @Put("users/:id/toggle-active") @ApiOperation({ summary: "Toggle user active status" }) + @SwaggerResponse({ status: 200, type: UserResponseDto }) async toggleUserActive( @Param("id") id: string, ): Promise> { @@ -120,6 +123,7 @@ export class AdminController { @Put("users/:id/role") @ApiOperation({ summary: "Update user role" }) + @SwaggerResponse({ status: 200, type: UserResponseDto }) async updateUserRole( @Param("id") id: string, @Body() data: { role: UserRole }, @@ -137,6 +141,7 @@ export class AdminController { @Put("users/:id/subscription") @ApiOperation({ summary: "Update user subscription" }) + @SwaggerResponse({ status: 200, type: UserResponseDto }) async updateUserSubscription( @Param("id") id: string, @Body() @@ -160,6 +165,7 @@ export class AdminController { @Delete("users/:id") @ApiOperation({ summary: "Soft delete a user" }) + @SwaggerResponse({ status: 200, description: "User deleted" }) async deleteUser(@Param("id") id: string): Promise> { await this.prisma.user.update({ where: { id }, @@ -175,6 +181,7 @@ export class AdminController { @CacheKey("app_settings") @CacheTTL(60 * 1000) @ApiOperation({ summary: "Get all app settings" }) + @SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } }) async getAllSettings(): Promise>> { const settings = await this.prisma.appSetting.findMany(); const settingsMap: Record = {}; @@ -186,6 +193,7 @@ export class AdminController { @Put("settings/:key") @ApiOperation({ summary: "Update an app setting" }) + @SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } }) async updateSetting( @Param("key") key: string, @Body() data: { value: string }, @@ -206,6 +214,7 @@ export class AdminController { @Get("usage-limits") @ApiOperation({ summary: "Get all usage limits" }) + @SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } }) async getAllUsageLimits(@Query() pagination: PaginationDto) { const { skip, take } = pagination; @@ -233,6 +242,7 @@ export class AdminController { @Post("usage-limits/reset-all") @ApiOperation({ summary: "Reset all usage limits" }) + @SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } }) async resetAllUsageLimits(): Promise> { const result = await this.prisma.usageLimit.updateMany({ data: { @@ -252,6 +262,7 @@ export class AdminController { @Get("analytics/overview") @ApiOperation({ summary: "Get system analytics overview" }) + @SwaggerResponse({ status: 200, schema: { type: "object" } }) async getAnalyticsOverview() { const [ totalUsers, diff --git a/src/modules/ai-proxy/ai-proxy.controller.ts b/src/modules/ai-proxy/ai-proxy.controller.ts new file mode 100644 index 0000000..15f5f0f --- /dev/null +++ b/src/modules/ai-proxy/ai-proxy.controller.ts @@ -0,0 +1,20 @@ +import { All, Body, Controller, Req } from "@nestjs/common"; +import type { Request } from "express"; + +import { AiProxyService } from "./ai-proxy.service"; + +@Controller("ai-engine") +export class AiProxyController { + constructor(private readonly aiProxyService: AiProxyService) {} + + @All("*path") + proxy(@Req() request: Request, @Body() body: unknown) { + return this.aiProxyService.proxy({ + method: request.method, + originalUrl: request.originalUrl, + query: request.query as Record, + body, + acceptLanguage: request.headers["accept-language"], + }); + } +} diff --git a/src/modules/ai-proxy/ai-proxy.module.ts b/src/modules/ai-proxy/ai-proxy.module.ts new file mode 100644 index 0000000..540c6e7 --- /dev/null +++ b/src/modules/ai-proxy/ai-proxy.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; + +import { AiProxyController } from "./ai-proxy.controller"; +import { AiProxyService } from "./ai-proxy.service"; + +@Module({ + imports: [ + HttpModule.register({ + timeout: 45000, + maxRedirects: 0, + }), + ], + controllers: [AiProxyController], + providers: [AiProxyService], +}) +export class AiProxyModule {} diff --git a/src/modules/ai-proxy/ai-proxy.service.ts b/src/modules/ai-proxy/ai-proxy.service.ts new file mode 100644 index 0000000..e4d19fe --- /dev/null +++ b/src/modules/ai-proxy/ai-proxy.service.ts @@ -0,0 +1,98 @@ +import { + BadGatewayException, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, Method } from "axios"; + +interface ProxyRequest { + method: string; + originalUrl: string; + query: Record; + body: unknown; + acceptLanguage?: string | string[]; +} + +interface AllowedRoute { + method: Method; + pattern: RegExp; +} + +const ALLOWED_AI_ROUTES: AllowedRoute[] = [ + { method: "GET", pattern: /^\/$/ }, + { method: "GET", pattern: /^\/health$/ }, + { method: "POST", pattern: /^\/v20plus\/analyze\/[^/]+$/ }, + { method: "GET", pattern: /^\/v20plus\/analyze-htms\/[^/]+$/ }, + { method: "GET", pattern: /^\/v20plus\/analyze-htft\/[^/]+$/ }, + { method: "POST", pattern: /^\/v20plus\/coupon$/ }, + { method: "GET", pattern: /^\/v20plus\/daily-banker$/ }, + { method: "GET", pattern: /^\/v20plus\/reversal-watchlist$/ }, + { method: "GET", pattern: /^\/v2\/health$/ }, + { method: "POST", pattern: /^\/v2\/analyze\/[^/]+$/ }, +]; + +@Injectable() +export class AiProxyService { + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) {} + + async proxy(request: ProxyRequest) { + const path = this.extractProxyPath(request.originalUrl); + const method = request.method.toUpperCase() as Method; + + if (!this.isAllowed(method, path)) { + throw new ForbiddenException("AI_PROXY_ROUTE_NOT_ALLOWED"); + } + + const baseUrl = this.configService.getOrThrow("AI_ENGINE_URL"); + const targetUrl = new URL(path, baseUrl); + + try { + const response = await this.httpService.axiosRef.request({ + url: targetUrl.toString(), + method, + params: request.query, + data: request.body, + headers: { + "content-type": "application/json", + "accept-language": Array.isArray(request.acceptLanguage) + ? request.acceptLanguage[0] + : request.acceptLanguage, + }, + timeout: 45000, + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 500, + }); + + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + throw new BadGatewayException({ + message: "AI_PROXY_UPSTREAM_FAILED", + status: axiosError.response?.status, + }); + } + } + + private extractProxyPath(originalUrl: string): string { + const withoutQuery = originalUrl.split("?")[0] || ""; + const marker = "/ai-engine"; + const markerIndex = withoutQuery.indexOf(marker); + if (markerIndex === -1) { + return "/"; + } + + const path = withoutQuery.slice(markerIndex + marker.length); + return path.length === 0 ? "/" : path; + } + + private isAllowed(method: Method, path: string): boolean { + return ALLOWED_AI_ROUTES.some( + (route) => route.method === method && route.pattern.test(path), + ); + } +} diff --git a/src/modules/analysis/analysis.controller.ts b/src/modules/analysis/analysis.controller.ts index 819edd4..9e00a97 100755 --- a/src/modules/analysis/analysis.controller.ts +++ b/src/modules/analysis/analysis.controller.ts @@ -30,7 +30,18 @@ export class AnalysisController { @Post("analyze-matches") @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Analyze multiple matches for coupon" }) - @ApiResponse({ status: 200, description: "Analysis successful" }) + @ApiResponse({ + status: 200, + description: "Analysis successful", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "object" }, + message: { type: "string" }, + }, + }, + }) @ApiResponse({ status: 400, description: "Invalid input" }) @ApiResponse({ status: 429, description: "Usage limit exceeded" }) async analyzeMatches( @@ -92,7 +103,17 @@ export class AnalysisController { */ @Get("history") @ApiOperation({ summary: "Get analysis history" }) - @ApiResponse({ status: 200, description: "History retrieved" }) + @ApiResponse({ + status: 200, + description: "History retrieved", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "array", items: { type: "object" } }, + }, + }, + }) async getHistory(@CurrentUser() user: any) { const history = await this.analysisService.getAnalysisHistory(user.id); return { success: true, data: history }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 933da53..c134998 100755 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -67,7 +67,17 @@ export class AuthController { @Post("logout") @HttpCode(200) @ApiOperation({ summary: "Logout and invalidate refresh token" }) - @ApiOkResponse({ description: "Logout successful" }) + @ApiOkResponse({ + description: "Logout successful", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + data: { type: "null" }, + }, + }, + }) async logout( @Body() dto: RefreshTokenDto, @I18n() i18n: I18nContext, diff --git a/src/modules/coupons/coupons.controller.ts b/src/modules/coupons/coupons.controller.ts index 50f9596..b3aed7e 100755 --- a/src/modules/coupons/coupons.controller.ts +++ b/src/modules/coupons/coupons.controller.ts @@ -53,7 +53,18 @@ export class CouponsController { @Public() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Analyze single match with V20 model" }) - @ApiResponse({ status: 200, description: "Match analysis" }) + @ApiResponse({ + status: 200, + description: "Match analysis", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "object" }, + message: { type: "string" }, + }, + }, + }) async analyzeMatch(@Body() dto: AnalyzeMatchDto) { const analysis = await this.smartCouponService.analyzeMatch(dto.matchId); if (!analysis) { @@ -99,6 +110,18 @@ export class CouponsController { @ApiOperation({ summary: "Generate a high-confidence banko combo (2 matches)", }) + @ApiResponse({ + status: 200, + description: "Daily banko coupon", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "object" }, + message: { type: "string" }, + }, + }, + }) async getDailyBanko(@Body() dto: DailyBankoDto) { // If no match IDs provided, fetch from system (top 50 upcoming) let candidateMatches = dto.matchIds || []; @@ -146,7 +169,18 @@ export class CouponsController { @Public() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Suggest Smart Coupon" }) - @ApiResponse({ status: 200, description: "Smart Coupon generated" }) + @ApiResponse({ + status: 200, + description: "Smart Coupon generated", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "object" }, + message: { type: "string" }, + }, + }, + }) async suggestCoupon(@Body() dto: SuggestCouponDto) { // If no match IDs provided, fetch from system (top 50 upcoming) let candidateMatches = dto.matchIds || []; @@ -237,6 +271,18 @@ export class CouponsController { @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Create and save a user coupon" }) + @ApiResponse({ + status: 201, + description: "Coupon created", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "object" }, + message: { type: "string" }, + }, + }, + }) async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) { // req.user is populated by JwtAuthGuard const coupon = await this.userCouponService.createCoupon(req.user, dto); @@ -251,6 +297,18 @@ export class CouponsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: "Get user betting statistics" }) + @ApiResponse({ + status: 200, + description: "User statistics", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "object" }, + message: { type: "string" }, + }, + }, + }) async getUserStats(@Req() req: any) { const stats = await this.userCouponService.getUserStatistics(req.user.id); return { success: true, data: stats }; @@ -263,7 +321,18 @@ export class CouponsController { @Get("history") @ApiBearerAuth() @ApiOperation({ summary: "Get coupon history" }) - @ApiResponse({ status: 200, description: "History retrieved" }) + @ApiResponse({ + status: 200, + description: "History retrieved", + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { type: "array", items: { type: "object" } }, + message: { type: "string" }, + }, + }, + }) async getHistory(@Query("limit") limit?: string) { // eslint-disable-next-line @typescript-eslint/await-thenable const results = await this.couponsService.getCouponHistory( diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index cd96cd8..204d0b7 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -898,6 +898,58 @@ export class FeederPersistenceService { .map((m) => m.id); } + /** + * For a list of match IDs that ALREADY exist in DB, + * returns which data scopes are missing per match. + * Only checks completed (Ended) football/basketball matches. + */ + async getMissingScopes( + matchIds: string[], + ): Promise> { + const result = new Map(); + if (matchIds.length === 0) return result; + + const matches = await this.prisma.match.findMany({ + where: { + id: { in: matchIds }, + state: "Ended", + }, + select: { + id: true, + sport: true, + _count: { + select: { + playerParticipations: true, + footballTeamStats: true, + basketballTeamStats: true, + basketballPlayerStats: true, + oddCategories: true, + }, + }, + }, + }); + + for (const m of matches) { + const missing: string[] = []; + + if (m.sport === "football") { + if (m._count.footballTeamStats === 0) missing.push("stats"); + if (m._count.playerParticipations < 18) missing.push("lineups"); + } else if (m.sport === "basketball") { + if (m._count.basketballTeamStats === 0) missing.push("stats"); + if (m._count.basketballPlayerStats === 0) missing.push("lineups"); + } + + if (m._count.oddCategories === 0) missing.push("odds"); + + if (missing.length > 0) { + result.set(m.id, missing); + } + } + + return result; + } + async hasOdds(matchId: string): Promise { const category = await this.prisma.oddCategory.findFirst({ where: { matchId }, diff --git a/src/modules/feeder/feeder.service.ts b/src/modules/feeder/feeder.service.ts index db2164a..01274a5 100755 --- a/src/modules/feeder/feeder.service.ts +++ b/src/modules/feeder/feeder.service.ts @@ -385,21 +385,71 @@ export class FeederService { return; } - // 2. Filter out already existing matches to skip processing + // 2. Filter out already existing matches & patch incomplete ones const allIds = matchesToProcess.map((m) => m.id); const existingIds = await this.persistenceService.getExistingMatchIds(allIds); const totalCount = matchesToProcess.length; + // ── Patch incomplete existing matches ────────────────────── + // Find matches that ARE in DB but have missing data scopes + const allExistingInDb = await this.persistenceService.getMissingScopes(allIds); + if (allExistingInDb.size > 0) { + this.logger.log( + `[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`, + ); + + for (const [matchId, missingScopes] of allExistingInDb) { + const matchSummary = matchesToProcess.find((m) => m.id === matchId); + if (!matchSummary) continue; + + for (const scope of missingScopes) { + await this.delay(500); + try { + const patchScope: "all" | "lineups" | "odds" = + scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all"; + + const result = await this.processSingleMatch( + matchSummary, + data.competitions, + sport, + true, // force + patchScope, + ); + + this.heartbeat(); + if (result.success) { + this.logger.log( + `[${sport}] ✅ Patched [${scope}] for ${matchId} ${matchSummary.homeTeam.name} vs ${matchSummary.awayTeam.name}`, + ); + } else { + this.logger.warn( + `[${sport}] ⚠️ Patch [${scope}] failed for ${matchId}`, + ); + } + } catch (e: any) { + this.logger.warn( + `[${sport}] ❌ Patch [${scope}] exception for ${matchId}: ${e.message}`, + ); + } + } + } + } + // ───────────────────────────────────────────────────────────── + + // Now filter out COMPLETE existing matches (skip them) if (!refreshExistingMatches && existingIds.length > 0) { + // Re-check after patching - which ones are now complete? + const updatedExistingIds = + await this.persistenceService.getExistingMatchIds(allIds); matchesToProcess = matchesToProcess.filter( - (m) => !existingIds.includes(m.id), + (m) => !updatedExistingIds.includes(m.id), ); } if (matchesToProcess.length === 0) { this.logger.log( - `[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`, + `[${sport}] [${dateString}] All ${totalCount} matches processed (${existingIds.length} existed, ${allExistingInDb.size} patched). Done.`, ); return; } @@ -410,7 +460,7 @@ export class FeederService { ); } else { this.logger.log( - `[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`, + `[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} new matches (${existingIds.length} existing, ${allExistingInDb.size} patched)`, ); } @@ -474,7 +524,7 @@ export class FeederService { match, data.competitions, sport, - refreshExistingMatches, + true, // FORCE: re-fetch incomplete data ); if (result.success) { successCount++; @@ -778,8 +828,9 @@ export class FeederService { if (scope === "all" || scope === "lineups") { // Starting Formation try { - const formationData = - await this.scraperService.fetchStartingFormation(matchId); + const formationData = await fetchResilient("Formation", () => + this.scraperService.fetchStartingFormation(matchId), + ); if (formationData?.stats) { this.transformerService.processLineup( formationData.stats.home || [], @@ -805,8 +856,9 @@ export class FeederService { // Substitutes try { - const subsData = - await this.scraperService.fetchSubstitutions(matchId); + const subsData = await fetchResilient("Subs", () => + this.scraperService.fetchSubstitutions(matchId), + ); if (subsData?.stats) { this.transformerService.processLineup( subsData.stats.home || [], @@ -887,7 +939,37 @@ export class FeederService { } } - // 4. Persist to Database + // ── Pre-save completeness gate ────────────────────────────── + // If a 502 caused missing data, do NOT save. The data exists on + // the API and will be available shortly. Skip and retry instead. + const completedMatch = isMatchCompleted({ + state: headerData?.matchStatus ?? matchSummary.state, + status: matchSummary.status, + substate: matchSummary.substate, + statusBoxContent: matchSummary.statusBoxContent, + scoreHome: headerData?.scoreHome ?? matchSummary.score?.home, + scoreAway: headerData?.scoreAway ?? matchSummary.score?.away, + }); + + const missingParts: string[] = []; + if (scope === "all" && completedMatch) { + if (sport === "football" && !stats) missingParts.push("Stats"); + if (sport === "football" && participationData.length < 18) + missingParts.push("Lineups"); + if (sport === "basketball" && !basketballTeamStats) + missingParts.push("BoxScore"); + if (oddsArray.length === 0) missingParts.push("Odds"); + } + + // 502 caused missing data → do NOT save, retry later + if (hasCriticalError && missingParts.length > 0) { + this.logger.warn( + `[${matchId}] ⛔ SKIPPED SAVE: 502 errors caused missing [${missingParts.join(", ")}]. Will retry for complete data.`, + ); + return { success: false, retryable: true }; + } + + // 4. SAVE let saved = false; if (scope === "lineups") { saved = await this.persistenceService.saveLineups( @@ -941,34 +1023,11 @@ export class FeederService { */ // ========================================== - const completedMatch = isMatchCompleted({ - state: headerData?.matchStatus ?? matchSummary.state, - status: matchSummary.status, - substate: matchSummary.substate, - statusBoxContent: matchSummary.statusBoxContent, - scoreHome: headerData?.scoreHome ?? matchSummary.score?.home, - scoreAway: headerData?.scoreAway ?? matchSummary.score?.away, - }); - - const missingParts: string[] = []; - if (scope === "all" && completedMatch) { - if (sport === "football" && !stats) missingParts.push("Stats"); - if (sport === "football" && participationData.length < 18) - missingParts.push("Lineups"); - if (sport === "basketball" && !basketballTeamStats) - missingParts.push("BoxScore"); - if (oddsArray.length === 0) missingParts.push("Odds"); - } - - if (saved && (hasCriticalError || missingParts.length > 0)) { - const reason = hasCriticalError - ? "missing data after upstream errors" - : "incomplete completed-match payload"; - + // No 502 but data genuinely missing → save anyway, log warning + if (saved && missingParts.length > 0) { this.logger.warn( - `[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`, + `[${matchId}] Saved but data genuinely missing (no 502): [${missingParts.join(", ")}]`, ); - return { success: false, retryable: true }; } return { success: saved, retryable: !saved }; diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 2706b5e..230dc9b 100755 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Res } from "@nestjs/common"; -import { ApiTags, ApiOperation } from "@nestjs/swagger"; +import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import type { Response } from "express"; import { Public } from "../../common/decorators"; import { PrismaService } from "../../database/prisma.service"; @@ -52,6 +52,17 @@ export class HealthController { @Get("live") @Public() @ApiOperation({ summary: "Liveness check" }) + @ApiResponse({ + status: 200, + description: "System liveness", + schema: { + type: "object", + properties: { + status: { type: "string" }, + timestamp: { type: "string" }, + }, + }, + }) liveness(@Res() response: Response) { return response .status(200) diff --git a/src/modules/leagues/leagues.controller.ts b/src/modules/leagues/leagues.controller.ts index 6beb3f5..f2732ef 100755 --- a/src/modules/leagues/leagues.controller.ts +++ b/src/modules/leagues/leagues.controller.ts @@ -28,7 +28,11 @@ export class LeaguesController { @Get("countries") @Public() @ApiOperation({ summary: "Get all countries" }) - @ApiResponse({ status: 200, description: "List of countries" }) + @ApiResponse({ + status: 200, + description: "List of countries", + schema: { type: "array", items: { type: "object" } }, + }) async getCountries() { return this.leaguesService.findAllCountries(); } @@ -40,6 +44,11 @@ export class LeaguesController { @Get("countries/:id") @Public() @ApiOperation({ summary: "Get country by ID with leagues" }) + @ApiResponse({ + status: 200, + description: "Country details", + schema: { type: "object" }, + }) @ApiParam({ name: "id", description: "Country ID" }) async getCountryById(@Param("id") id: string) { const country = await this.leaguesService.findCountryById(id); @@ -54,6 +63,11 @@ export class LeaguesController { @Get() @Public() @ApiOperation({ summary: "Get all leagues" }) + @ApiResponse({ + status: 200, + description: "List of leagues", + schema: { type: "array", items: { type: "object" } }, + }) @ApiQuery({ name: "sport", required: false, @@ -71,6 +85,11 @@ export class LeaguesController { @Get("teams/h2h") @Public() @ApiOperation({ summary: "Get head-to-head matches between two teams" }) + @ApiResponse({ + status: 200, + description: "Head-to-head matches", + schema: { type: "array", items: { type: "object" } }, + }) @ApiQuery({ name: "team1", required: true }) @ApiQuery({ name: "team2", required: true }) @ApiQuery({ name: "limit", required: false, type: Number }) @@ -93,6 +112,11 @@ export class LeaguesController { @Get("teams/search") @Public() @ApiOperation({ summary: "Search teams by name" }) + @ApiResponse({ + status: 200, + description: "List of teams matching search", + schema: { type: "array", items: { type: "object" } }, + }) @ApiQuery({ name: "q", required: true, description: "Search query" }) @ApiQuery({ name: "sport", @@ -110,6 +134,11 @@ export class LeaguesController { @Get("teams/:id") @Public() @ApiOperation({ summary: "Get team by ID" }) + @ApiResponse({ + status: 200, + description: "Team details", + schema: { type: "object" }, + }) @ApiParam({ name: "id", description: "Team ID" }) async getTeamById(@Param("id") id: string) { const team = await this.leaguesService.findTeamById(id); @@ -124,6 +153,17 @@ export class LeaguesController { @Get("teams/:id/matches") @Public() @ApiOperation({ summary: "Get team's recent matches (paginated)" }) + @ApiResponse({ + status: 200, + description: "Paginated list of matches", + schema: { + type: "object", + properties: { + data: { type: "array", items: { type: "object" } }, + meta: { type: "object" }, + }, + }, + }) @ApiParam({ name: "id", description: "Team ID" }) @ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" }) @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" }) @@ -149,6 +189,11 @@ export class LeaguesController { @Get(":id") @Public() @ApiOperation({ summary: "Get league by ID" }) + @ApiResponse({ + status: 200, + description: "League details", + schema: { type: "object" }, + }) @ApiParam({ name: "id", description: "League ID" }) async getLeagueById(@Param("id") id: string) { const league = await this.leaguesService.findLeagueById(id); diff --git a/src/modules/matches/matches.controller.ts b/src/modules/matches/matches.controller.ts index 580a988..d031f82 100755 --- a/src/modules/matches/matches.controller.ts +++ b/src/modules/matches/matches.controller.ts @@ -71,7 +71,17 @@ export class MatchesController { @ApiQuery({ name: "page", required: false, type: Number }) @ApiQuery({ name: "limit", required: false, type: Number }) @ApiQuery({ name: "sport", required: false, enum: Sport }) - @ApiResponse({ status: 200, description: "Paginated list of matches" }) + @ApiResponse({ + status: 200, + description: "Paginated list of matches", + schema: { + type: "object", + properties: { + data: { type: "array", items: { type: "object" } }, + meta: { type: "object" }, + }, + }, + }) async listMatches( @Query("page") page?: string, @Query("limit") limit?: string, @@ -112,6 +122,7 @@ export class MatchesController { @ApiResponse({ status: 200, description: "Match details with lineups, stats, odds, events", + schema: { type: "object" }, }) @ApiResponse({ status: 404, description: "Match not found" }) async getMatchDetails(@Param("id") id: string) { diff --git a/src/modules/predictions/predictions.controller.ts b/src/modules/predictions/predictions.controller.ts index 8cc80d8..53a285b 100755 --- a/src/modules/predictions/predictions.controller.ts +++ b/src/modules/predictions/predictions.controller.ts @@ -56,6 +56,11 @@ export class PredictionsController { */ @Get("test/:id") @ApiOperation({ summary: "Refetch match data and get prediction" }) + @ApiResponse({ + status: 200, + description: "Prediction details", + schema: { type: "object" }, + }) @ApiParam({ name: "id", description: "Match ID" }) async getTestPrediction(@Param("id") id: string) { return this.predictionsService.testPrediction(id); @@ -91,7 +96,12 @@ export class PredictionsController { @Public() @ApiOperation({ summary: "Get prediction for a specific match" }) @ApiParam({ name: "matchId", description: "Match ID" }) - @ApiResponse({ status: 200, type: MatchPredictionDto }) + @ApiResponse({ + status: 200, + description: "Match prediction", + schema: { type: "object" }, + type: MatchPredictionDto, + }) @ApiResponse({ status: 404, description: "Match not found" }) async getPrediction( @Param("matchId") matchId: string, @@ -145,6 +155,7 @@ export class PredictionsController { @ApiResponse({ status: 200, description: "Smart coupon generated successfully", + schema: { type: "object" }, }) async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise { const coupon = await this.predictionsService.getSmartCoupon( diff --git a/src/modules/spor-toto/spor-toto.controller.ts b/src/modules/spor-toto/spor-toto.controller.ts index 3570ced..314ff0b 100644 --- a/src/modules/spor-toto/spor-toto.controller.ts +++ b/src/modules/spor-toto/spor-toto.controller.ts @@ -54,6 +54,7 @@ export class SporTotoController { @ApiResponse({ status: 200, description: "Sync result with action (created/updated/unchanged)", + schema: { type: "object" }, }) async syncFromApi() { const result = await this.sporTotoService.syncFromApi(); @@ -82,6 +83,7 @@ export class SporTotoController { @ApiResponse({ status: 200, description: "Array of bulletins with matches and results", + schema: { type: "object" }, }) async listBulletins( @Query("status") status?: TotoBulletinStatus, @@ -105,6 +107,7 @@ export class SporTotoController { @ApiResponse({ status: 200, description: "Bulletin with matches and results", + schema: { type: "object" }, }) @ApiResponse({ status: 404, description: "Bulletin not found" }) async getBulletin(@Param("id") id: string) { @@ -123,7 +126,11 @@ export class SporTotoController { "Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.", }) @ApiBody({ type: CreateBulletinDto }) - @ApiResponse({ status: 201, description: "Created bulletin with matches" }) + @ApiResponse({ + status: 201, + description: "Created bulletin with matches", + schema: { type: "object" }, + }) @ApiResponse({ status: 409, description: "Bulletin with this gameCycleNo already exists", @@ -145,7 +152,11 @@ export class SporTotoController { }) @ApiParam({ name: "id", description: "Bulletin UUID" }) @ApiBody({ type: UpdateResultsDto }) - @ApiResponse({ status: 200, description: "Updated bulletin with results" }) + @ApiResponse({ + status: 200, + description: "Updated bulletin with results", + schema: { type: "object" }, + }) @ApiResponse({ status: 404, description: "Bulletin not found" }) async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) { const bulletin = await this.sporTotoService.updateResults(id, dto); @@ -162,7 +173,11 @@ export class SporTotoController { "Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.", }) @ApiParam({ name: "id", description: "Bulletin UUID" }) - @ApiResponse({ status: 200, description: "Pool distribution and EV stats" }) + @ApiResponse({ + status: 200, + description: "Pool distribution and EV stats", + schema: { type: "object" }, + }) async getBulletinStats(@Param("id") id: string) { const stats = await this.sporTotoService.getBulletinStats(id); return { success: true, data: stats }; @@ -181,7 +196,11 @@ export class SporTotoController { type: Number, description: "Number of results (default: 20)", }) - @ApiResponse({ status: 200, description: "Rollover history with trend data" }) + @ApiResponse({ + status: 200, + description: "Rollover history with trend data", + schema: { type: "object" }, + }) async getRolloverHistory(@Query("limit") limit?: string) { const history = await this.sporTotoService.getRolloverHistory( Number(limit) || 20, @@ -204,6 +223,7 @@ export class SporTotoController { @ApiResponse({ status: 200, description: "Generated columns with strategy, cost, and column strings", + schema: { type: "object" }, }) async generateColumns(@Body() dto: GenerateColumnsDto) { const result = await this.sporTotoService.generateColumns(dto); @@ -223,6 +243,7 @@ export class SporTotoController { @ApiResponse({ status: 200, description: "Evaluation results with correct counts per column", + schema: { type: "object" }, }) async evaluateColumns(@Body() dto: EvaluateColumnsDto) { const result = await this.sporTotoService.evaluateColumns( @@ -248,6 +269,7 @@ export class SporTotoController { status: 200, description: "Prediction result with per-match analysis, system coupon, and EV report with play recommendation", + schema: { type: "object" }, }) async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) { this.logger.log( diff --git a/src/scripts/run-feeder-repair.ts b/src/scripts/run-feeder-repair.ts new file mode 100644 index 0000000..c1856f7 --- /dev/null +++ b/src/scripts/run-feeder-repair.ts @@ -0,0 +1,123 @@ +/** + * Repair Feeder - Fix incomplete matches + * Usage: npm run feeder:repair + * + * Finds matches in DB that are missing stats or lineups + * and re-fetches them from the API. + */ + +import { NestFactory } from "@nestjs/core"; +import { Logger } from "@nestjs/common"; +import { PrismaService } from "../database/prisma.service"; +import { FeederService } from "../modules/feeder/feeder.service"; + +async function bootstrap() { + process.env.FEEDER_MODE = "historical"; + + const logger = new Logger("FeederRepair"); + + logger.log("🔧 Starting feeder repair scan..."); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AppModule } = require("../app.module"); + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ["log", "error", "warn"], + }); + + const prisma = app.get(PrismaService); + const feederService = app.get(FeederService); + + try { + // Find football matches missing stats (no football_team_stats rows) + const matchesMissingStats = await prisma.$queryRaw< + Array<{ id: string; match_name: string }> + >` + SELECT m.id, m.match_name + FROM matches m + LEFT JOIN football_team_stats fts ON fts.match_id = m.id + WHERE m.sport = 'football' + AND m.state = 'Ended' + AND fts.id IS NULL + ORDER BY m.mst_utc DESC + `; + + // Find football matches missing lineups (< 18 participation rows) + const matchesMissingLineups = await prisma.$queryRaw< + Array<{ id: string; match_name: string; cnt: bigint }> + >` + SELECT m.id, m.match_name, COUNT(mpp.id) as cnt + FROM matches m + LEFT JOIN match_player_participation mpp ON mpp.match_id = m.id + WHERE m.sport = 'football' + AND m.state = 'Ended' + GROUP BY m.id, m.match_name + HAVING COUNT(mpp.id) < 18 + ORDER BY m.mst_utc DESC + `; + + // Combine unique match IDs + const repairSet = new Set(); + for (const m of matchesMissingStats) repairSet.add(m.id); + for (const m of matchesMissingLineups) repairSet.add(m.id); + + logger.log( + `📊 Found ${repairSet.size} incomplete matches (${matchesMissingStats.length} missing stats, ${matchesMissingLineups.length} missing lineups)`, + ); + + if (repairSet.size === 0) { + logger.log("✅ No incomplete matches found. Everything is clean!"); + await app.close(); + process.exit(0); + } + + let repaired = 0; + let failed = 0; + const matchIds = Array.from(repairSet); + + for (let i = 0; i < matchIds.length; i++) { + const matchId = matchIds[i]; + + // Rate limiting + if (i > 0 && i % 10 === 0) { + logger.log( + `⏸️ Cooldown after 10 repairs... (${repaired} repaired, ${failed} failed, ${matchIds.length - i} remaining)`, + ); + await new Promise((r) => setTimeout(r, 5000)); + } + + await new Promise((r) => setTimeout(r, 500)); + + try { + const result = await feederService.refreshMatch(matchId, "all"); + if (result.success) { + repaired++; + if (repaired % 25 === 0) { + logger.log(`🔧 Progress: ${repaired}/${matchIds.length} repaired`); + } + } else { + failed++; + logger.warn( + `❌ [${matchId}] Repair failed: ${result.error || "unknown"}`, + ); + } + } catch (e: any) { + failed++; + logger.error(`❌ [${matchId}] Repair exception: ${e.message}`); + } + } + + logger.log( + `🎉 REPAIR COMPLETE: ${repaired} repaired, ${failed} failed out of ${matchIds.length} total`, + ); + } catch (error: any) { + logger.error(`❌ Repair failed: ${error.message}`); + logger.error(error.stack); + process.exit(1); + } finally { + await app.close(); + } + + process.exit(0); +} + +void bootstrap(); diff --git a/ts_error.txt b/ts_error.txt new file mode 100644 index 0000000000000000000000000000000000000000..9d437eb0f1ce460776dba9c425faa00975dfc917 GIT binary patch literal 3496 zcmds)T~8B16o$`LFHHOo8!jxOP+C5OYBZ*RdZDP`6-83ErPe^H?V>bBf4cg-XPDBa z6*O{zWHP(6JF{ob`<{r76D!jzT&woZdc5g!t=fUr>;vN#<4tSx ze$AggGpbrXx0YMaE#zi$8@WO*&%J8TSew~%JF$IMwAiJNjG9Xrewt-oGNLAXcH}yp z*dgz=cgA&gFkj`VYpKg@yY8oGuQg>`u_arz+jh&I@+Lt-%XKYVk*5>{HNMr8*4*RW z9s1N6-q*|>Ta#-I9TMB%>!?H2?j!pY2D(Jnrv6DvK%9U=Eo%1Nu1k|f$ypO;75K@p zO%t}d*0&e%alkmP8NSP#UNM*1mUZ|hh0_Ci1IuY{`VwCYoLp@0GO{x?k*%9{7pXh8 zYxlXoWgmHyv|Y!)Q|=v}TD)sJ(@|I9+Q3&2X?=UcsOH`ibzMikyrT=+I%@+i@z65G zK!v#@S`E2%_KK?mcf$;JS)C!j%gTh_urJ)V!EpZ7G%{MqYBGO}$7z?8HHy*9kxiPH z4vOX8Z&&nS=ivzMh(Fxl``oB5LRy$f$OdgR+V_Y^&_ec39d!ro6emK_cPno(BYTMJ zvdauHTX1XCooDG;AR-&ibjE1(Y0jm$ks#h>7v-W5>C?pd5-1nJBmFcJtP_|l`FzRe z}k9 z`G}a3-9y}r@_Ts>94Ps7s)dVH2HR!ad9I0m-u+hY;$CZ zIotCLc2hVI`em>O5160GL9CL>o@I6F>Xg$-I-dDcd_^a~A-vW6+zOT13Kg6(xBP89 zn~eIHa?2+otL51>RAbsz`8%BPJ?!vkU