commit 073abf98c129beda9687bfcdcff45dde7bf5264f Author: QuantBot Date: Sat Jun 13 23:18:18 2026 +0800 Initial commit with 2026 World Cup Quant Platform core modules and CI/CD diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d7ebcb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +PORT=3000 +# The Odds API is used as the primary data source for fixtures + odds. +THE_ODDS_API_KEY=your_the_odds_api_key +THE_ODDS_BASE=https://api.the-odds-api.com +THE_ODDS_SPORT_KEY=soccer_fifa_world_cup +THE_ODDS_REGIONS=eu +# 1x2 / spreads / totals / btts are commonly available +THE_ODDS_MARKETS=h2h,spreads,totals,btts +REFRESH_MINUTES=10 +LIVE_REFRESH_SECONDS=45 +FAST_REFRESH_HOURS=6 +MATCH_LOOKBACK_HOURS=12 +APP_TIME_ZONE=Asia/Taipei +APP_PUBLIC_ORIGIN=https://2026fifa.wooo.work +TZ=Asia/Taipei +KELLY_SCALE=0.6 + +# Optional news provider key (if you have one) or set for RSS-only mode. +NEWS_API_KEY= +NEWS_PROVIDER=google-rss + +# Optional tuning +ODDS_REFRESH_TIMEOUT_MS=15000 +NEWS_FETCH_CONCURRENCY=3 +NEWS_LOOKBACK_DAYS=14 +MAX_MATCHES_FOR_NEWS=64 + +# 量化分析/資料平台參數(預留) +REDIS_URL=redis://127.0.0.1:6379 +POSTGRES_URL=postgres://user:password@127.0.0.1:5432/fifa2026 +ANALYTICS_MODE=js +ANALYTICS_MC_SAMPLES=10000 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..b504e91 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,118 @@ +name: deploy-2026fifa-platform + +on: + push: + branches: + - main + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - name: 取回原始碼 + uses: actions/checkout@v4 + + - name: 設定 Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: 安裝前端套件 + working-directory: platform/web + run: npm install + + - name: 前端 Lint + working-directory: platform/web + run: npm run lint + + - name: 前端建置檢查 + working-directory: platform/web + run: npm run build + + - name: 設定 Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: 安裝後端與警報套件 + run: | + python -m pip install --upgrade pip + pip install -r platform/backend/requirements.txt + pip install -r platform/alerts/requirements.txt + + - name: 編譯 Python 程式碼 + run: | + python -m compileall platform/backend/app + python -m compileall platform/alerts + + build-and-push: + needs: test-and-build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: 取回原始碼 + uses: actions/checkout@v4 + + - name: 登入容器倉庫 + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build 並推送前端映像 + run: | + docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} platform/web + docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-web:latest + docker push ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} + docker push ghcr.io/${{ github.repository_owner }}/2026fifa-web:latest + + - name: Build 並推送後端映像 + run: | + docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} platform/backend + docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-backend:latest + docker push ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} + docker push ghcr.io/${{ github.repository_owner }}/2026fifa-backend:latest + + - name: Build 並推送警報服務映像 + run: | + docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} platform/alerts + docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:latest + docker push ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} + docker push ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:latest + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: 取回原始碼 + uses: actions/checkout@v4 + + - name: 安裝 kubectl + uses: azure/setup-kubectl@v4 + with: + version: 'v1.30.0' + + - name: 建立 KubeConfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.K3S_KUBECONFIG }}" > ~/.kube/config + + - name: 替換映像版本 + run: | + sed -i "s#ghcr.io/your-org/2026fifa-web:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }}#g" platform/deploy/k3s/web-deployment.yaml + sed -i "s#ghcr.io/your-org/2026fifa-backend:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }}#g" platform/deploy/k3s/backend-deployment.yaml + sed -i "s#ghcr.io/your-org/2026fifa-alerts:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }}#g" platform/deploy/k3s/alerts-deployment.yaml + + - name: 套用 K3s Manifest + run: | + kubectl apply -f platform/deploy/k3s/namespace.yaml + kubectl apply -f platform/deploy/k3s/secret.yaml + kubectl apply -f platform/deploy/k3s/configmap.yaml + kubectl apply -f platform/deploy/k3s/postgres-deployment.yaml + kubectl apply -f platform/deploy/k3s/redis-deployment.yaml + kubectl apply -f platform/deploy/k3s/backend-deployment.yaml + kubectl apply -f platform/deploy/k3s/web-deployment.yaml + kubectl apply -f platform/deploy/k3s/alerts-deployment.yaml + kubectl apply -f platform/deploy/k3s/ingress.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b6babef --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,74 @@ +name: 2026 World Cup Quant Platform - Production Deployment + +on: + push: + branches: + - main + +jobs: + test-and-lint: + name: Code Quality & Testing + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install Backend Dependencies + run: pip install -r backend/requirements.txt pytest + + - name: Run Backend Quant Engine Tests + run: pytest backend/app/analytics/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Run Frontend Linting + run: | + cd frontend + npm ci + npm run lint + + deploy-docker: + name: Deploy to Production VM via Docker Compose + needs: test-and-lint + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Deploy to Ubuntu Server via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PROD_SERVER_IP }} + username: ${{ secrets.PROD_SERVER_USER }} + key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} + script: | + echo "🚀 [Deploy] Starting deployment for 2026fifa.wooo.work" + + # 進入專案目錄 + cd /opt/worldcup-quant-2026 + + # 抓取最新程式碼 + git pull origin main + + # 使用 Docker Compose 重新建置並平滑重啟容器 + # --build: 強制重新編譯 Dockerfile (前端靜態檔與後端依賴) + # -d: 背景執行 + docker-compose -f docker-compose.prod.yml up --build -d + + # 執行資料庫遷移 (若使用 Alembic) + # docker exec quant_backend alembic upgrade head + + # 清理閒置的舊 Image 釋放伺服器空間 + docker image prune -f + + echo "✅ [Deploy] Deployment completed successfully!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1010cd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +package-lock.json +.env +/ops/shared +/ops/*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ca5334a --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: up down logs db-shell test build deploy redis-cli + +up: + docker-compose up -d + +down: + docker-compose down + +logs: + docker-compose logs -f + +db-shell: + docker-compose exec fifa2026-postgres psql -U fifa_user -d fifa2026 + +redis-cli: + docker-compose exec fifa2026-redis redis-cli + +test: + docker-compose exec fifa2026-backend pytest app/analytics/ -v + +build: + docker-compose -f docker-compose.prod.yml build --no-cache + +deploy: + docker-compose -f docker-compose.prod.yml up -d diff --git a/README.md b/README.md new file mode 100644 index 0000000..4400b5e --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# 2026 FIFA 世界盃投注研究站(緊急版) + +這是一個可直接啟動的網站: +- 抓取所有 2026 世界盃賽事(以 The Odds API 為主) +- 抓取各場賠率(1X2、讓球、大小球、BTTS) +- 解析前端新聞連動(Google RSS / NewsAPI 可切換) +- 將賠率轉換為市場共識機率,輸出高勝率投注玩法與組合建議(單場、2 串、3 串) +- 做賽程與新聞熱度對照(按日期統計、休息日比較、近期報導監控) + +## 快速啟動 + +1. 安裝套件 + +```bash +npm install +``` + +2. 複製環境參數 + +```bash +cp .env.example .env +``` + +3. 設定金鑰(至少要有 The Odds API Key) + +- `THE_ODDS_API_KEY`:必填,取自 The Odds API +- 可選:`NEWS_API_KEY` + `NEWS_PROVIDER=newsapi`(更穩定) +- 可選:`KELLY_SCALE`(0~1,預設 0.6,控制 Kelly 參數保守度) + +4. 啟動服務 + +```bash +npm start +``` + +開啟 `http://localhost:3000`。 +正式上線請使用:`https://2026fifa.wooo.work` + +> 時間設定:全站以 `APP_TIME_ZONE=Asia/Taipei`(台北時區,UTC+8)為主,API 回應與前端時間展示都會沿用此時區。 + +## API 簡介 + +- `GET /api/health`:服務狀態 +- `GET /api/matches?force=1`:抓取賽事與賠率(含新聞) +- `GET /api/analyze`:回傳每場賽事策略與 2 串/3 串建議(含 `expectedValue`、`kellyFraction`、`modelGrade`) +- `GET /api/today-insights`:回傳台北時間今日場次之「高/中/低勝率+爆冷」摘要(首頁主題視覺) +- `GET /api/schedule-comparison`:回傳賽程比對與新聞熱點 +- `GET /api/refresh`:手動刷新快取資料 +- `GET /api/source-registry`:回傳主流資料來源台帳、整合狀態、權重與健康檢查(含 2026 世界盃主流官方/賠率/新聞/統計/天氣來源) + +目前台帳已落地覆蓋: +- 官方(FIFA) +- 賠率主幹(The Odds API)與備援規劃源 +- 新聞/事件(Reuters、BBC、ESPN、Goal、Sky Sports、Google RSS、NewsAPI) +- 統計與球隊技術(SofaScore、WhoScored、FBref、Understat、Soccerway、Transfermarkt 等) +- 天氣與場地條件(Open-Meteo、WeatherAPI、OpenWeather 規劃中) + +## 正式環境部署建議(4 台候選主機) + +已做基礎可達性盤點後,建議優先使用 `192.168.0.188` 作為正式主機: +- 110/188:22/80 可到達 +- 110 回應為 301,188 為 200,188 對直接上線友善 +- 120/121 目前未開放 80 + +建議流程: +1. 先把金鑰和站台參數放到目標機器 `/opt/fifa2026/.env` +2. 用部署腳本推版: + +```bash +./ops/deploy-production.sh 192.168.0.188 +``` + +3. 健康檢查: + +```bash +./ops/healthcheck-production.sh 192.168.0.188 3108 2026fifa.wooo.work +``` + +5. 正式對外網址與健康資訊可直接由 `/api/health` 確認: + +```bash +curl https://2026fifa.wooo.work/api/health +``` + +6. 查看 `ops/pm2.config.js` 與 `ops/deploy-host-assessment.md` 進行壓力/切換演練 +7. 正式域名反代範例可套用 `ops/nginx-2026fifa.conf`(含 80 跳轉 443) + +```bash +sudo cp ops/nginx-2026fifa.conf /etc/nginx/conf.d/2026fifa.wooo.work.conf +sudo nginx -t && sudo systemctl reload nginx +``` + +## 專業版本擴展 + +- 外部資料參考與權重策略見:`docs/professional-data-reference.md` +- 目前已將分析節奏做為「賽前高峰」加速更新(`LIVE_REFRESH_SECONDS`) +- 若你要全量參考外部主流網站的模型,建議下一步接入「多來源資料源抽象層」(賠率 + 賽事 + 新聞 + 天氣 + xG) + +## 專案結構 + +- `src/server.js`:Express 後端 + 資料抓取 + 分析引擎 + 排程更新 +- `public/index.html`:管理面版 +- `public/app.js`:前端互動與卡片渲染 +- `public/style.css`:界面樣式 + +## 重要提示 + +- 本系統側重「研究」與「風險參考」,非保證收益工具。 +- 資料來源可能有授權或速率限制,實戰前請先驗證 API 顯示條件。 +- 若要擴充更多市場,可在 `THE_ODDS_MARKETS` 增加對應 market key(例如 `btts,corners`)並調整前端顯示/分析規則。 + +## 資料安全 + +- `.env` 不要提交到版本庫。 +- 生產環境建議加上反向代理與速率限制、API key 保護。 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..ba89bbf --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,97 @@ +version: '3.8' + +services: + fifa2026-postgres: + image: timescale/timescaledb:latest-pg16 + restart: always + environment: + POSTGRES_USER: fifa_user + POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me} + POSTGRES_DB: fifa2026 + ports: + - "127.0.0.1:5432:5432" + volumes: + - pg-data:/var/lib/postgresql/data + - ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - wc2026-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fifa_user -d fifa2026"] + interval: 10s + timeout: 5s + retries: 5 + + fifa2026-redis: + image: redis:7-alpine + restart: always + command: redis-server --appendonly yes + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis-data:/data + networks: + - wc2026-net + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + fifa2026-backend: + build: + context: ./platform/backend + restart: always + ports: + - "127.0.0.1:8000:8000" + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://fifa2026-redis:6379/0 + - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} + depends_on: + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + fifa2026-web: + build: + context: ./platform/web + restart: always + ports: + - "127.0.0.1:3000:3000" + environment: + - NEXT_PUBLIC_API_URL=https://2026fifa.wooo.work/api + depends_on: + fifa2026-backend: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-alerts: + build: + context: ./platform/alerts + restart: always + environment: + - REDIS_URL=redis://fifa2026-redis:6379/0 + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} + depends_on: + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + +networks: + wc2026-net: + driver: bridge + +volumes: + pg-data: + redis-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d2fe14 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,94 @@ +version: '3.8' + +services: + fifa2026-postgres: + image: timescale/timescaledb:latest-pg16 + environment: + POSTGRES_USER: fifa_user + POSTGRES_PASSWORD: change_me + POSTGRES_DB: fifa2026 + ports: + - "5432:5432" + volumes: + - pg-data:/var/lib/postgresql/data + - ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - wc2026-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fifa_user -d fifa2026"] + interval: 10s + timeout: 5s + retries: 5 + + fifa2026-redis: + image: redis:7-alpine + command: redis-server --appendonly yes + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + - wc2026-net + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + fifa2026-backend: + build: + context: ./platform/backend + ports: + - "8000:8000" + volumes: + - ./platform/backend:/app + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://fifa2026-redis:6379/0 + - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} + depends_on: + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-web: + build: + context: ./platform/web + ports: + - "3000:3000" + volumes: + - ./platform/web:/app + - /app/node_modules + - /app/.next + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + depends_on: + - fifa2026-backend + networks: + - wc2026-net + + fifa2026-alerts: + build: + context: ./platform/alerts + volumes: + - ./platform/alerts:/app + environment: + - REDIS_URL=redis://fifa2026-redis:6379/0 + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} + depends_on: + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + +networks: + wc2026-net: + driver: bridge + +volumes: + pg-data: + redis-data: diff --git a/docs/professional-data-reference.md b/docs/professional-data-reference.md new file mode 100644 index 0000000..78e02cf --- /dev/null +++ b/docs/professional-data-reference.md @@ -0,0 +1,241 @@ +# 專業外部資料參考台帳(完整版) + +本頁定義 2026 世界盃投注研究站的「參考來源矩陣」,目標是從官方公告、賠率市場、新聞訊號、球隊統計、場地天氣五大軸建立可追溯的高勝率研究底盤。 +所有時區均以 **Asia/Taipei(UTC+8)** 對齊顯示。 + +## 一、來源接入分級 + +- `active`:已在系統中可直接抓取並回報健康狀態 +- `conditional`:有條件接入(依環境金鑰) +- `planned`:規劃中,作為未來接入候選 +- `reference_only`:僅作人工/參考核對,不列為即時抓取來源 + +## 二、主流外部來源矩陣 + +### 1. 官方與賽事主體 + +1. FIFA 官方 — `fifa_official` + - 網址:`https://www.fifa.com/fifa-world-cup` + - 類型:官方主辦 + - 狀態:reference_only + - 權重:1.00 + - 用途:規則、公告、賽制、場地政策、重大異動參考 + +2. FIFA 官方賽程與賽果 — `fifa_world_cup_fixtures` + - 網址:`https://www.fifa.com/fifa-world-cup/fixtures-and-results` + - 類型:官方主辦 + - 狀態:reference_only + - 權重:0.98 + - 用途:核對開賽時間、場次順序、場地異動 + +### 2. 賠率與市場資料 + +1. The Odds API — `the_odds_api` + - 網址:`https://api.the-odds-api.com` + - 類型:API + - 狀態:active + - 權重:0.96 + - 用途:主體市場抓取(1X2、讓球、大小球、BTTS) + +2. Sportradar — `sportradar` + - 網址:`https://developer.sportradar.com` + - 類型:API(企業授權) + - 狀態:planned + - 權重:0.90 + - 用途:賽事事件與場內關鍵事件補強 + +3. API-FOOTBALL Odds — `apifootball_odds` + - 網址:`https://www.api-football.com` + - 類型:API(商用授權) + - 狀態:planned + - 權重:0.85 + - 用途:賠率備援與市場交叉驗證 + +4. Odds API 社群備援 — `odds_api_fallback` + - 網址:`https://www.odds-api.com` + - 類型:聚合參考 + - 狀態:reference_only + - 權重:0.75 + - 用途:主幹與外部市場口徑偏差觀察 + +### 3. 新聞與事件訊號 + +1. NewsAPI — `newsapi` + - 網址:`https://newsapi.org` + - 類型:API + - 狀態:conditional + - 權重:0.82 + - 用途:有金鑰時抓取英文新聞與情緒信號 + +2. Google News RSS — `google_news_rss` + - 網址:`https://news.google.com` + - 類型:RSS + - 狀態:active + - 權重:0.70 + - 用途:無金鑰新聞備援,保證資訊補充持續可用 + +3. Reuters — `reuters` + - 網址:`https://www.reuters.com/sports` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.90 + - 用途:突發與敏感事件高可信核對 + +4. BBC Sport — `bbc_sport` + - 網址:`https://www.bbc.com/sport` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.84 + - 用途:主流新聞來源交叉核對 + +5. ESPN FC — `espn` + - 網址:`https://www.espn.com/soccer/` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.80 + - 用途:前瞻、輪替、戰術文本信號 + +6. Goal.com — `goal` + - 網址:`https://www.goal.com` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.74 + - 用途:傷病與賽前動態補充 + +7. Sky Sports — `skysports` + - 網址:`https://www.skysports.com/football` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.69 + - 用途:歐洲戰術與賽前觀察補充 + +8. The Guardian — `guardian_sport` + - 網址:`https://www.theguardian.com/football` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.71 + - 用途:深度報導、風險語意交叉核對 + +9. Associated Press — `associated_press` + - 網址:`https://apnews.com` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.77 + - 用途:突發事件高可見度校驗 + +10. OneFootball — `onefootball` + - 網址:`https://onefootball.com` + - 類型:官方媒體 + - 狀態:reference_only + - 權重:0.66 + - 用途:跨區域社群與賽事摘要補充 + +### 4. 球隊/球員與高階數據 + +1. SofaScore — `sofascore` + - 網址:`https://www.sofascore.com` + - 類型:Web + - 狀態:planned + - 權重:0.76 + - 用途:先發與戰況補強觀測 + +2. WhoScored — `whoscored` + - 網址:`https://www.whoscored.com` + - 類型:Web + - 狀態:planned + - 權重:0.81 + - 用途:場面效率與球員表現深度指標 + +3. FBref — `fbref` + - 網址:`https://fbref.com` + - 類型:Web + - 狀態:planned + - 權重:0.78 + - 用途:xG、節奏、每90分鐘效率 + +4. Understat — `understat` + - 網址:`https://understat.com` + - 類型:Web + - 狀態:planned + - 權重:0.79 + - 用途:xG 分佈與得分質量 + +5. WorldFootball.net — `worldfootball` + - 網址:`https://www.worldfootball.net` + - 類型:Web + - 狀態:reference_only + - 權重:0.64 + - 用途:歷史交手、賽事流程補充 + +6. Soccerway — `soccerway` + - 網址:`https://int.soccerway.com` + - 類型:Web + - 狀態:reference_only + - 權重:0.62 + - 用途:賽程與歷史進程交叉 + +7. Transfermarkt — `transfermarkt` + - 網址:`https://www.transfermarkt.com` + - 類型:Web + - 狀態:reference_only + - 權重:0.70 + - 用途:球員輪值、傷停、出場補充 + +8. Statbunker — `statbunker` + - 網址:`https://www.statbunker.com` + - 類型:Web + - 狀態:reference_only + - 權重:0.58 + - 用途:場均效率與歷史指標補充 + +9. Footystats — `footystats` + - 網址:`https://footystats.org` + - 類型:Web + - 狀態:reference_only + - 權重:0.60 + - 用途:效率模型參考 + +### 5. 比賽環境(天氣) + +1. Open-Meteo — `open_meteo` + - 網址:`https://open-meteo.com` + - 類型:氣象 API + - 狀態:planned + - 權重:0.68 + - 用途:風速、溫濕度、降雨條件修正 + +2. WeatherAPI — `weatherapi` + - 網址:`https://www.weatherapi.com` + - 類型:氣象 API + - 狀態:planned + - 權重:0.64 + - 用途:天候補強與模型對照 + +3. OpenWeather — `openweathermap` + - 網址:`https://openweathermap.org` + - 類型:氣象 API + - 狀態:planned + - 權重:0.60 + - 用途:天氣交叉比對備援 + +## 三、資料使用原則(專業投注研究) + +1. 先用官方公告與賽程基準定義「比賽可比性」。 +2. 以 The Odds API 作為主幹,計算市場共識機率與賠率偏離。 +3. 將新聞事件(Reuter/ESPN/Goal/Google RSS)轉為「風險熱度」與「臨場訊號」。 +4. 在 2026 世界盃短天數高密度賽事下,優先看多源一致方向,降低單源誤差對單場判斷影響。 +5. 使用官方/高可信來源權重作為優先裁決條件,reference_only 僅作核查,不與 active 源同等作決策。 + +## 四、系統落地映射 + +- `GET /api/source-registry`:回傳本台帳中的每個來源、整合層級、目前 runtime 狀態、延遲與錯誤摘要。 +- `GET /api/today-insights`:回傳台北時區「今日可下注賽事」高/中/低/爆冷摘要,做為首頁主題化投研入口資料。 +- 主幹賠率:`the_odds_api`(失敗會直接反映為 source-registry error) +- 新聞雙通道:`newsapi`(有金鑰)與 `google_news_rss` fallback +- 參考層:`reference_only` 僅保留為研究證據,不列為即時決策主因 + +## 五、建議的下一階段高價值接入 + +1. 將 `weatherapi/open_meteo/openweathermap` 其中一個正式接入,做場地環境修正模型。 +2. 接入至少一個可授權備援 odds provider(如 Sportradar 或 API-FOOTBALL)降低單點失效。 +3. 訓練新聞關鍵字模型(`injury`、`rotation`、`rest`)到權重輸入,提升短期決策穩定性。 diff --git a/docs/professional-market-architecture.md b/docs/professional-market-architecture.md new file mode 100644 index 0000000..1decf1f --- /dev/null +++ b/docs/professional-market-architecture.md @@ -0,0 +1,86 @@ +# 2026 世界盃對標市場主流版:資料驅動量化架構藍圖(Gemini 路線) + +## 一、目標 +將網站從「賠率列表」提升為「市場主流級決策中心」: +- 跨平台賠率矩陣化(多書比較) +- 走勢與成交量監測(Line Movement + Sharp/Public 分流) +- 量化模型直接回傳投注信號(EV / Poisson / Monte Carlo) +- 個人資金回報與 CLV 監控(Portfolio Tracker) + +## 二、資料分層 +- **Ingestion(來源層)**:主力 Odds API + 可擴展的 Odds/Stats/News Feed。 +- **Normalization(正規化層)**:統一 `matchId / teamId / market / outcome`。 +- **Feature Store(特徵層)**:暫存賽事快照、盤口快取、賽中事件。 +- **Analytics Engine(分析層)**:EV、Poisson、Monte Carlo 的模型輸出。 +- **Presentation Layer(展示層)**:儀表板頁面 + API。 + +## 三、資料庫建議(PostgreSQL) +以下為最小可用表結構: + +### teams +- `id` UUID +- `name` TEXT +- `fifa_code` TEXT + +### matches +- `id` UUID +- `provider_match_id` TEXT +- `stage` TEXT +- `kickoff_at` TIMESTAMPTZ +- `venue` TEXT +- `city` TEXT +- `altitude` NUMERIC +- `home_team_id` UUID +- `away_team_id` UUID + +### odds_history`(建議以 `match_id, provider, market, updated_at` 分區) +- `id` BIGSERIAL +- `match_id` UUID +- `provider` TEXT +- `market` TEXT +- `outcome` TEXT +- `point` NUMERIC +- `odds` NUMERIC +- `updated_at` TIMESTAMPTZ + +### betting_tickets +- `id` BIGSERIAL +- `match_id` UUID +- `market` TEXT +- `selection` TEXT +- `point` NUMERIC +- `tickets_pct` NUMERIC +- `handle_pct` NUMERIC +- `provider` TEXT +- `snapshot_at` TIMESTAMPTZ + +### portfolio_bets(本機用量化追蹤) +- `id` UUID +- `match_id` UUID +- `market` TEXT +- `selection` TEXT +- `odds` NUMERIC +- `stake` NUMERIC +- `result` TEXT +- `settled_at` TIMESTAMPTZ + +## 四、快取與即時性 +- **Redis key 建議** + - `live:match:{matchId}:latest` + - `odds:market:{matchId}:{market}` + - `stats:line:{matchId}:{market}:{outcome}` + +## 五、量化分析模型 +- **EV Detector**:以模型機率與市場隱含機率對照,EV > 0 直接產生 `value_bet=true`。 +- **Poisson**:推估雙方進球分佈與比分機率,輸出 1-3 機率最高 score。 +- **Monte Carlo**:10k 次模擬輸出「淨勝 2 球以上」、「高波動場次」 + +## 六、實作進度接軸 +本次提交已完成: +1. 量化模組核心引擎(Node) +2. 市場矩陣 + 線路變化 API +3. 資金流偏差與走勢資料快照 API +4. 組合/組合式投資組合 CLV API(in-memory prototype) +5. 量化儀表頁樣式與 API 串接骨架 + +後續可無縫接入 PostgreSQL/Redis:將 in-memory 資料改為 DB Repository 注入。 diff --git a/ops/deploy-host-assessment.md b/ops/deploy-host-assessment.md new file mode 100644 index 0000000..26801ba --- /dev/null +++ b/ops/deploy-host-assessment.md @@ -0,0 +1,35 @@ +# 192.168.0.XXX 主機快速評估與正式上線建議 + +## 目前可觀測結果(已完成) + +檢查指標: +- ping 可達性 +- SSH(22)/HTTP(80) 開放 +- HTTP 回應(首頁) +- 若你要正式流量,另外請再補 `CPU/MEM/磁碟/I/O/端口衝突/服務穩定性` 實測 + +結果: +- `192.168.0.110`: HTTP 301(可能有既有反向代理/導向) +- `192.168.0.188`: HTTP 200(直接可回應) +- `192.168.0.120`: port 80 未開 +- `192.168.0.121`: port 80 未開 + +對外正式網址建議:`2026fifa.wooo.work` + +## 建議 + +- **主站上線:`192.168.0.188`** + - 優先原因:已能直接回 HTTP 200,網頁入口可立即承接。 + - 作為正式環境主節點建議。 +- 備援方案: + - `192.168.0.110` 可作為備援節點,需先確認 301 重導目標與既有服務衝突後再掛上。 +- `120` / `121` 建議先做服務預配置(開放 80/443 + 反代),再參與正式流量。 + +## 正式上線前最小實測 + +1. CPU 平均負載、記憶體、磁碟 I/O、開放端口檢查 +2. 24 小時壓測與 API 快速刷新測試(以 20~60 秒輪詢為基準) +3. `The Odds API` 呼叫量與速率配額驗證 +4. 新聞源可用率(RSS/NewsAPI)失敗 fallback 驗證 +5. 自動重啟(PM2)與 crash 回復演練 +6. 回滾流程:新版本失敗 2 分鐘內可回到上版前容器 diff --git a/ops/deploy-production.sh b/ops/deploy-production.sh new file mode 100755 index 0000000..8ab9cb9 --- /dev/null +++ b/ops/deploy-production.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST="${1:-192.168.0.188}" +PROJECT_ROOT="${2:-/opt/fifa2026}" +PUBLIC_ORIGIN="${3:-https://2026fifa.wooo.work}" +DEPLOY_USER="${DEPLOY_USER:-${USER}}" +LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${LOCAL_DIR}/.env" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "[deploy] ERROR: .env not found at ${ENV_FILE}" + exit 1 +fi + +echo "[deploy] target=${HOST} path=${PROJECT_ROOT}" +echo "[deploy] rsync source=${LOCAL_DIR}" + +ssh -o StrictHostKeyChecking=accept-new "${DEPLOY_USER}@${HOST}" "mkdir -p ${PROJECT_ROOT}/shared ${PROJECT_ROOT}/logs" + +rsync -avz --delete \ + --exclude node_modules \ + --exclude .git \ + --exclude .env \ + "${LOCAL_DIR}/" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/current/" + +scp "${ENV_FILE}" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/.env" + +ssh "${DEPLOY_USER}@${HOST}" "PROJECT_ROOT='${PROJECT_ROOT}'; \ + mkdir -p ${PROJECT_ROOT}/current; \ + cd ${PROJECT_ROOT}/current; \ + if [[ -f .env ]]; then :; else cp ${PROJECT_ROOT}/.env .env; fi; \ + if ! grep -q '^APP_PUBLIC_ORIGIN=' ${PROJECT_ROOT}/.env; then \ + echo \"APP_PUBLIC_ORIGIN=${PUBLIC_ORIGIN}\" >> ${PROJECT_ROOT}/.env; \ + fi; \ + if ! grep -q '^APP_TIME_ZONE=' ${PROJECT_ROOT}/.env; then \ + echo 'APP_TIME_ZONE=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \ + fi; \ + if ! grep -q '^TZ=' ${PROJECT_ROOT}/.env; then \ + echo 'TZ=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \ + fi; \ + if ! grep -q '^KELLY_SCALE=' ${PROJECT_ROOT}/.env; then \ + echo 'KELLY_SCALE=0.6' >> ${PROJECT_ROOT}/.env; \ + fi; \ + node -v >/tmp/node-version.txt 2>/dev/null || true; \ + if ! command -v npm >/dev/null; then \ + echo '[deploy] ERROR: npm not found'; exit 1; \ + fi; \ + npm install --omit=dev; \ + if ! command -v pm2 >/dev/null; then \ + npm install pm2 >/tmp/pm2-install.log 2>&1; \ + fi; \ + PM2_BIN=\"\$(command -v pm2 || true)\"; \ + if [ -z \"\${PM2_BIN}\" ]; then \ + PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \ + fi; \ + [ -x \"\${PM2_BIN}\" ] || PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \ + [ -x \"\${PM2_BIN}\" ] || (echo '[deploy] ERROR: pm2 not found' && exit 1); \ + TZ=Asia/Taipei APP_TIME_ZONE=Asia/Taipei NODE_ENV=production \"\${PM2_BIN}\" startOrReload \"\${PROJECT_ROOT}/current/ops/pm2.config.js\" --env production 2>&1 | cat; \ + \"\${PM2_BIN}\" save" + +echo "[deploy] deploy done" diff --git a/ops/healthcheck-production.sh b/ops/healthcheck-production.sh new file mode 100755 index 0000000..f42395a --- /dev/null +++ b/ops/healthcheck-production.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST="${1:-192.168.0.188}" +PORT="${2:-3108}" +PUBLIC_HOST="${3:-2026fifa.wooo.work}" + +normalize_host() { + local host="$1" + if [[ "$host" == http://* ]] || [[ "$host" == https://* ]]; then + printf '%s' "$host" + return + fi + printf 'https://%s' "$host" +} + +PUBLIC_URL="$(normalize_host "$PUBLIC_HOST")" +[[ "$PUBLIC_URL" == */ ]] && PUBLIC_URL="${PUBLIC_URL%/}" +PUBLIC_URL="${PUBLIC_URL}/api/health" +INTERNAL_URL="http://${HOST}:${PORT}/api/health" + +echo "[health] check public=${PUBLIC_URL}" +echo "[health] check internal=${INTERNAL_URL} (fallback)" +for i in {1..30}; do + if command -v jq >/dev/null 2>&1; then + data="" + if resp="$(curl -sS --max-time 6 "${PUBLIC_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then + data="${resp}" + elif resp="$(curl -sS --max-time 6 "${INTERNAL_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then + data="${resp}" + fi + + if [[ -z "${data}" ]]; then + sleep 4 + continue + fi + status="$(echo "${data}" | jq -r '.status')" + count="$(echo "${data}" | jq -r '.matchCount')" + publicOrigin="$(echo "${data}" | jq -r '.publicOrigin // "-"')" + tz="$(echo "${data}" | jq -r '.timeZone // "unknown"')" + updated="$(echo "${data}" | jq -r '.lastUpdatedTaipei // "-"')" + echo "[health] #${i} status=${status} matches=${count} public=${publicOrigin} timezone=${tz} updated=${updated}" + if [[ "${status}" == "ready" && "${count}" != "0" ]]; then + echo "[health] production api ready" + exit 0 + fi + else + code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${PUBLIC_URL}")" + if [[ "${code}" != "200" ]]; then + code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${INTERNAL_URL}")" + fi + echo "[health] #${i} http=${code}" + if [[ "${code}" == "200" ]]; then + echo "[health] production endpoint returns ok" + exit 0 + fi + fi + sleep 4 +done + +echo "[health] ERROR: not healthy" +exit 1 diff --git a/ops/nginx-2026fifa.conf b/ops/nginx-2026fifa.conf new file mode 100644 index 0000000..239875a --- /dev/null +++ b/ops/nginx-2026fifa.conf @@ -0,0 +1,37 @@ +server { + listen 80; + listen [::]:80; + server_name 2026fifa.wooo.work; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name 2026fifa.wooo.work; + + ssl_certificate /etc/letsencrypt/live/2026fifa.wooo.work/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/2026fifa.wooo.work/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header X-Frame-Options SAMEORIGIN; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + client_max_body_size 16m; + + location / { + proxy_pass http://127.0.0.1:3108; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } +} diff --git a/ops/pm2.config.js b/ops/pm2.config.js new file mode 100644 index 0000000..1586f09 --- /dev/null +++ b/ops/pm2.config.js @@ -0,0 +1,25 @@ +module.exports = { + apps: [ + { + name: 'fifa2026-betting-desk', + script: './src/server.js', + instances: 1, + exec_mode: 'fork', + env: { + NODE_ENV: 'production', + TZ: 'Asia/Taipei', + APP_TIME_ZONE: 'Asia/Taipei', + APP_PUBLIC_ORIGIN: 'https://2026fifa.wooo.work', + }, + env_file: '/opt/fifa2026/.env', + out_file: '/opt/fifa2026/logs/out.log', + error_file: '/opt/fifa2026/logs/error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + max_memory_restart: '700M', + max_restarts: 15, + restart_delay: 2000, + merge_logs: true, + watch: false, + }, + ], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef80812 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "fifa2026-betting-desk", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "description": "2026 FIFA World Cup odds + professional betting strategy dashboard", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node src/server.js" + }, + "dependencies": { + "axios": "^1.7.8", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2" + } +} diff --git a/platform/alerts/.env.example b/platform/alerts/.env.example new file mode 100644 index 0000000..96268c8 --- /dev/null +++ b/platform/alerts/.env.example @@ -0,0 +1,7 @@ +REDIS_URL=redis://localhost:6379/0 +TELEGRAM_BOT_TOKEN=replace-me +TELEGRAM_CHAT_ID=replace-me +EV_THRESHOLD=0.05 +SHARP_THRESHOLD=0.80 +ALERT_POLL_SECONDS=10 + diff --git a/platform/alerts/Dockerfile b/platform/alerts/Dockerfile new file mode 100644 index 0000000..9f6aa57 --- /dev/null +++ b/platform/alerts/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY alert_worker.py ./ +CMD ["python", "alert_worker.py"] + diff --git a/platform/alerts/alert_worker.py b/platform/alerts/alert_worker.py new file mode 100644 index 0000000..91acab1 --- /dev/null +++ b/platform/alerts/alert_worker.py @@ -0,0 +1,177 @@ +import asyncio +import json +import os +from dataclasses import dataclass + +import httpx +from redis.asyncio import Redis + + +def calc_expected_value(win_prob: float, odds: float, stake: float = 1.0) -> float: + if odds <= 1 or not (0 <= win_prob <= 1): + return 0.0 + loss = (1 - win_prob) * stake + return win_prob * (odds * stake - stake) - loss + + +def is_ev_plus(ev: float, threshold: float = 0.05) -> bool: + return ev > threshold + + +def is_sharp_money_anomaly(handle_share: float, threshold: float = 0.8) -> bool: + return handle_share >= threshold + + +@dataclass(frozen=True) +class AlertRecord: + match_id: str + market: str + odds: float + ev: float + handle_share: float + + +class TelegramNotifier: + def __init__(self, token: str, chat_id: str, *, timeout: float = 10.0) -> None: + self.token = token + self.chat_id = chat_id + self.client = httpx.AsyncClient(timeout=timeout) + + async def send_alert(self, message: str) -> bool: + if not self.token or not self.chat_id: + return False + + url = f'https://api.telegram.org/bot{self.token}/sendMessage' + payload = { + 'chat_id': self.chat_id, + 'text': message, + 'parse_mode': 'Markdown', + } + response = await self.client.post(url, json=payload) + return response.is_success + + async def close(self) -> None: + await self.client.aclose() + + +class AlertService: + def __init__( + self, + redis_url: str, + notifier: TelegramNotifier, + *, + poll_interval_seconds: int = 10, + sample_interval_seconds: int = 900, + ev_threshold: float = 0.05, + sharp_threshold: float = 0.80, + ) -> None: + self.redis = Redis.from_url(redis_url, decode_responses=True) + self.notifier = notifier + self.poll_interval_seconds = poll_interval_seconds + self.sample_interval_seconds = sample_interval_seconds + self.ev_threshold = ev_threshold + self.sharp_threshold = sharp_threshold + + async def _format_alert(self, alert: AlertRecord) -> str: + return ( + '🚨 *高價值投注警報*\n' + f'賽事:{alert.match_id}\n' + f'市場:{alert.market}\n' + f'目前賠率:{alert.odds:.2f}\n' + f'EV:{alert.ev * 100:.2f}%\n' + f'聰明錢資金占比:{alert.handle_share * 100:.1f}%\n' + '建議:檢視盤口失衡與新聞情緒是否同步變動。' + ) + + async def _should_send(self, match_id: str, market: str) -> bool: + key = f'alert:sent:{match_id}:{market}' + result = await self.redis.set(name=key, value='1', ex=self.sample_interval_seconds, nx=True) + return bool(result) + + async def _scan_candidates(self) -> list[tuple[str, dict[str, object]]]: + # 標準 key 形狀:odds:latest:{match_id}:{market} + keys = await self.redis.keys('odds:latest:*') + out: list[tuple[str, dict[str, object]]] = [] + for key in keys: + raw = await self.redis.get(key) + if not raw: + continue + try: + payload = json.loads(raw) + except ValueError: + continue + out.append((key, payload)) + return out + + async def evaluate_loop(self) -> None: + def to_float(value: object, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + def normalize_share(value: float) -> float: + # 支援 0~1 或 0~100 兩種輸入格式 + if value > 1: + return value / 100 + return value + + try: + while True: + candidates = await self._scan_candidates() + for _, payload in candidates: + match_id = payload.get('matchId') or payload.get('match_id') + market = payload.get('market', 'unknown') + if not match_id: + continue + + odds = to_float(payload.get('odds', 0.0)) + win_prob = to_float(payload.get('winProbability', payload.get('probability', 0.0))) + handle_share = normalize_share( + to_float(payload.get('handleShare', payload.get('handle_ratio', 0.0))), + ) + + ev = calc_expected_value(win_prob, odds, stake=1.0) + if not (is_ev_plus(ev, self.ev_threshold) or is_sharp_money_anomaly(handle_share, self.sharp_threshold)): + continue + + should_send = await self._should_send(match_id, market) + if not should_send: + continue + + message = await self._format_alert(AlertRecord( + match_id=match_id, + market=market, + odds=odds, + ev=ev, + handle_share=handle_share, + )) + + await self.notifier.send_alert(message) + await asyncio.sleep(self.poll_interval_seconds) + finally: + await self.notifier.close() + await self.redis.close() + + +async def main() -> None: + redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') + bot_token = os.environ.get('TELEGRAM_BOT_TOKEN', '') + chat_id = os.environ.get('TELEGRAM_CHAT_ID', '') + ev_threshold = float(os.environ.get('EV_THRESHOLD', '0.05')) + sharp_threshold = float(os.environ.get('SHARP_THRESHOLD', '0.80')) + poll_seconds = int(os.environ.get('ALERT_POLL_SECONDS', '10')) + + notifier = TelegramNotifier(bot_token, chat_id) + svc = AlertService( + redis_url, + notifier, + poll_interval_seconds=poll_seconds, + ev_threshold=ev_threshold, + sharp_threshold=sharp_threshold, + ) + await svc.evaluate_loop() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/platform/alerts/requirements.txt b/platform/alerts/requirements.txt new file mode 100644 index 0000000..8e88140 --- /dev/null +++ b/platform/alerts/requirements.txt @@ -0,0 +1,2 @@ +httpx==0.28.1 +redis==5.2.1 diff --git a/platform/backend/.env.example b/platform/backend/.env.example new file mode 100644 index 0000000..072d57a --- /dev/null +++ b/platform/backend/.env.example @@ -0,0 +1,5 @@ +REDIS_URL=redis://localhost:6379/0 +PORT=8000 +DATABASE_URL=postgresql://fifa_user:change_me@localhost:5432/fifa2026 +LOG_LEVEL=info + diff --git a/platform/backend/Dockerfile b/platform/backend/Dockerfile new file mode 100644 index 0000000..6938d36 --- /dev/null +++ b/platform/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim AS builder + +ENV POETRY_NO_INTERACTION=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /build +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +FROM python:3.11-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/usr/local/bin:$PATH" + +WORKDIR /app +COPY --from=builder /install /usr/local +COPY app /app/app + +WORKDIR /app/app +CMD ["python", "main.py"] diff --git a/platform/backend/app/__init__.py b/platform/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/platform/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/platform/backend/app/analytics/__init__.py b/platform/backend/app/analytics/__init__.py new file mode 100644 index 0000000..0e94fbd --- /dev/null +++ b/platform/backend/app/analytics/__init__.py @@ -0,0 +1,95 @@ +"""量化分析模組匯總。""" + +from .engine import calculate_value_bet, PoissonPredictor, adjust_away_defense_for_altitude +from .ev_calculator import calculate_expected_value +from .feature_engineering import MatchFeatureExtractor, MatchFeatureVector +from .kelly import KellyResult, calculate_kelly_fraction +from .ml_inference import XGBoostPredictor, XGBoostPrediction +from .player_props import ( + PlayerPropsProfile, + PlayerPropsSimulationResult, + PropMetric, + evaluate_top_edge, + simulate_player_prop_probability, +) +from .ml_ensemble import ( + FEATURE_COLUMNS, + EnsembleModelArtifact, + build_default_ensemble_artifact, + calculate_model_edges, + model_predict_probabilities, + normalize_feature_payload, + train_match_outcome_ensemble, +) +from .backtesting import BacktestTradeRecord, StrategyFilter, filter_trades, run_flat_stake_backtest +from .poisson_model import PoissonMatchPredictor +from .referee_analyzer import calculate_cards_ev +from .environment_model import adjust_team_strength_for_environment +from .referee_weather import MatchConditionSignal, evaluate_match_conditions +from .rlm import ReverseLineMovementAlert, evaluate_reverse_line_movement +from .proof_of_yield import LedgerSummary, ProofOfYieldStore, ProofYieldRecord, compute_clv, compute_pnl +from .player_props_sim import PlayerPropsDistribution, evaluate_prop_bet, simulate_player_stats +from .sgp_engine import calculate_joint_probability, find_sgp_value +from .portfolio_analyzer import analyze_user_leaks +from .hedging_calculator import calculate_hedge_amount +from .daily_card_generator import generate_daily_card +from .vig_remover import ( + calculate_overround, + compare_bookmaker_true_prob, + prob_to_decimal_odds, + remove_margin_basic, + remove_margin_shin, +) + +__all__ = [ + 'KellyResult', + 'BacktestTradeRecord', + 'PropMetric', + 'calculate_expected_value', + 'calculate_value_bet', + 'calculate_kelly_fraction', + 'evaluate_top_edge', + 'PoissonPredictor', + 'PlayerPropsProfile', + 'PlayerPropsSimulationResult', + 'PoissonMatchPredictor', + 'adjust_away_defense_for_altitude', + 'adjust_team_strength_for_environment', + 'filter_trades', + 'run_flat_stake_backtest', + 'simulate_player_prop_probability', + 'StrategyFilter', + 'FEATURE_COLUMNS', + 'build_default_ensemble_artifact', + 'calculate_model_edges', + 'model_predict_probabilities', + 'normalize_feature_payload', + 'train_match_outcome_ensemble', + 'MatchConditionSignal', + 'evaluate_match_conditions', + 'ReverseLineMovementAlert', + 'evaluate_reverse_line_movement', + 'LedgerSummary', + 'ProofOfYieldStore', + 'ProofYieldRecord', + 'compute_clv', + 'compute_pnl', + 'MatchFeatureExtractor', + 'MatchFeatureVector', + 'XGBoostPredictor', + 'XGBoostPrediction', + 'PlayerPropsDistribution', + 'simulate_player_stats', + 'evaluate_prop_bet', + 'calculate_joint_probability', + 'find_sgp_value', + 'calculate_cards_ev', + 'calculate_overround', + 'remove_margin_basic', + 'remove_margin_shin', + 'prob_to_decimal_odds', + 'compare_bookmaker_true_prob', + 'analyze_user_leaks', + 'calculate_hedge_amount', + 'generate_daily_card', +] diff --git a/platform/backend/app/analytics/backtesting.py b/platform/backend/app/analytics/backtesting.py new file mode 100644 index 0000000..fc1516a --- /dev/null +++ b/platform/backend/app/analytics/backtesting.py @@ -0,0 +1,181 @@ +"""自訂策略回測引擎。""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class BacktestTradeRecord: + """單筆策略投注歷史資料。""" + + trade_id: str + settled_at: datetime + odds: float + is_win: bool + stake: float = 100.0 + altitude_meters: int | None = None + handicap: float | None = None + weather: str | None = None + recent_form_win_rate: float | None = None + market_type: str = '1x2' + selection: str = 'home' + + +@dataclass(frozen=True) +class StrategyFilter: + """回測條件(前端 JSON 可直接對映)。""" + + weather: str | None = None + altitude_min_meters: int | None = None + altitude_max_meters: int | None = None + handicap_min: float | None = None + handicap_max: float | None = None + recent_win_rate_min: float | None = None + recent_win_rate_max: float | None = None + market_types: list[str] | None = None + start_at: datetime | None = None + end_at: datetime | None = None + + +def _match_filter(record: BacktestTradeRecord, condition: StrategyFilter) -> bool: + """判斷單筆交易是否符合使用者條件。""" + + if condition.weather and (record.weather or '').lower() != condition.weather.lower(): + return False + if condition.altitude_min_meters is not None and ( + record.altitude_meters is None or record.altitude_meters < condition.altitude_min_meters + ): + return False + if condition.altitude_max_meters is not None and ( + record.altitude_meters is None or record.altitude_meters > condition.altitude_max_meters + ): + return False + if condition.handicap_min is not None and ( + record.handicap is None or record.handicap < condition.handicap_min + ): + return False + if condition.handicap_max is not None and ( + record.handicap is None or record.handicap > condition.handicap_max + ): + return False + if condition.recent_win_rate_min is not None and ( + record.recent_form_win_rate is None or record.recent_form_win_rate < condition.recent_win_rate_min + ): + return False + if condition.recent_win_rate_max is not None and ( + record.recent_form_win_rate is None or record.recent_form_win_rate > condition.recent_win_rate_max + ): + return False + if condition.market_types and record.market_type not in condition.market_types: + return False + if condition.start_at is not None and record.settled_at < condition.start_at: + return False + if condition.end_at is not None and record.settled_at > condition.end_at: + return False + return True + + +def filter_trades( + trades: list[BacktestTradeRecord], + condition: StrategyFilter, +) -> list[BacktestTradeRecord]: + """回傳符合條件的策略明細子集合。""" + + return [t for t in trades if _match_filter(t, condition)] + + +def compute_max_drawdown(equity_curve: list[float]) -> float: + """計算最大回撤(百分比)。""" + + if not equity_curve: + return 0.0 + peak = equity_curve[0] + max_drawdown = 0.0 + for value in equity_curve: + if value > peak: + peak = value + continue + drawdown = (peak - value) / peak if peak else 0.0 + max_drawdown = max(max_drawdown, drawdown) + return round(max_drawdown * 100, 4) + + +def run_flat_stake_backtest( + trades: list[BacktestTradeRecord], + initial_capital: float = 10000, +) -> dict[str, float | int | list[dict[str, float | str]]]: + """固定單注本金(Flat betting)回測。 + + 回傳: + - trade_count:總注單數 + - hit_count:中獎注數 + - win_rate:中獎率 + - final_capital:最終資金 + - net_profit:淨利潤 + - roi_percent:ROI + - max_drawdown_percent:最大回撤百分比 + - equity_curve:資產曲線 + """ + + if initial_capital <= 0: + raise ValueError('initial_capital 必須大於 0') + + if not trades: + return { + 'trade_count': 0, + 'hit_count': 0, + 'win_rate': 0.0, + 'final_capital': initial_capital, + 'net_profit': 0.0, + 'roi_percent': 0.0, + 'max_drawdown_percent': 0.0, + 'equity_curve': [{'ts': datetime.utcnow().isoformat() + 'Z', 'capital': initial_capital}], + } + + # 確保輸入依賴的時序,回測才有金融合理性 + ordered = sorted(trades, key=lambda row: row.settled_at) + equity = float(initial_capital) + equity_curve: list[dict[str, float | str]] = [ + {'ts': ordered[0].settled_at.isoformat(), 'capital': equity}, + ] + hit = 0 + total_stake = 0.0 + + for trade in ordered: + if trade.odds <= 1: + raise ValueError(f'賠率錯誤 trade={trade.trade_id}, odds={trade.odds}') + stake = trade.stake + profit = stake * (trade.odds - 1) if trade.is_win else -stake + equity += profit + total_stake += stake + if trade.is_win: + hit += 1 + equity_curve.append({'ts': trade.settled_at.isoformat(), 'capital': equity}) + + if total_stake <= 0: + roi = 0.0 + else: + roi = (equity - initial_capital) / total_stake * 100 + + win_rate = round(hit / len(ordered) * 100, 4) if ordered else 0.0 + + return { + 'trade_count': len(ordered), + 'hit_count': hit, + 'win_rate': win_rate, + 'final_capital': round(equity, 4), + 'net_profit': round(equity - initial_capital, 4), + 'roi_percent': round(roi, 4), + 'max_drawdown_percent': compute_max_drawdown([float(point['capital']) for point in equity_curve]), + 'equity_curve': equity_curve, + } + + +__all__ = [ + 'BacktestTradeRecord', + 'StrategyFilter', + 'filter_trades', + 'run_flat_stake_backtest', +] diff --git a/platform/backend/app/analytics/daily_card_generator.py b/platform/backend/app/analytics/daily_card_generator.py new file mode 100644 index 0000000..299668f --- /dev/null +++ b/platform/backend/app/analytics/daily_card_generator.py @@ -0,0 +1,188 @@ +"""每日智能注單生成器(Daily Smart Card)。""" + +from __future__ import annotations + +from typing import Any + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _ev_percent(true_prob: float, decimal_odds: float) -> float: + if decimal_odds <= 1: + return 0.0 + implied = 1.0 / decimal_odds + # EV = P*(odds-1) - (1-P)*1 + return ((true_prob * (decimal_odds - 1.0)) - (1.0 - true_prob)) * 100 + + +def _guess_stage(match_index: int) -> str: + return '小組賽' if match_index <= 48 else '淘汰賽' + + +def generate_daily_card(target_date: str, matches: list[dict[str, Any]]) -> dict[str, Any]: + """ + 依賽事快照回傳 4 大區塊策略建議(安全單關、搏冷、高勝率串關、同場串關)。 + + 回傳的格式會被前端 /daily-card 與手機版報表一致消化。 + """ + + safe_singles: list[dict[str, Any]] = [] + high_risk_singles: list[dict[str, Any]] = [] + safe_parlays: list[dict[str, Any]] = [] + sgp_lotteries: list[dict[str, Any]] = [] + + total_unit = 0.0 + + for idx, match in enumerate(matches): + match_id = str(match.get('match_id', f'fallback-{idx+1}')) + home_team = str(match.get('home_team', '主隊')) + away_team = str(match.get('away_team', '客隊')) + odds_home = _safe_float(match.get('odds_home'), default=0) + odds_away = _safe_float(match.get('odds_away'), default=0) + + # 用 xG 或開盤機率估算真實機率,若無資料則回退到 0.5 + home_xg = _safe_float(match.get('home_xg'), default=1.0) + away_xg = _safe_float(match.get('away_xg'), default=1.0) + xg_sum = max(home_xg + away_xg, 0.01) + true_home_prob = home_xg / xg_sum + + stage = _guess_stage(idx + 1) + + # 安全單關:偏向高勝率市場 + if odds_home > 1 and true_home_prob > 0.55: + ev = _ev_percent(true_home_prob, odds_home) + if ev > 3: + safe_unit = 1.8 + total_unit += safe_unit + safe_singles.append( + { + 'match_id': match_id, + 'match_label': f'{home_team} vs {away_team}', + 'market_type': '亞洲讓球', + 'selection': f'{home_team} -0.25', + 'target_odds': round(odds_home, 2), + 'win_prob': round(true_home_prob * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': round(safe_unit, 2), + 'recommendation': 'SAFE_SINGLE', + 'rationale': '高勝率 + 正EV,適合作為核心穩健下注。', + }, + ) + + # 高風險搏冷:低勝率但盤口偏高且 EV 過濾 + away_true = 1.0 - true_home_prob + if odds_away > 1 and away_true < 0.35: + ev = _ev_percent(away_true, odds_away) + if ev > 8: + high_risk_unit = 0.35 + total_unit += high_risk_unit + high_risk_singles.append( + { + 'match_id': match_id, + 'match_label': f'{home_team} vs {away_team}', + 'market_type': '大小球', + 'selection': f'{away_team} 不敗', + 'target_odds': round(odds_away, 2), + 'win_prob': round(away_true * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': round(high_risk_unit, 2), + 'recommendation': 'HIGH_RISK_SINGLE', + 'rationale': '冷門高賠率,只有在高勝率組合中保留小倉位。', + }, + ) + + # 2 串 1 串關:選取高勝率的兩個 SAFE 單關,若連乘機率符合條件 + if len(safe_singles) >= 2: + legs = safe_singles[:2] + combined_odds = 1.0 + combined_prob = 1.0 + for leg in legs: + combined_odds *= leg['target_odds'] + combined_prob *= leg['win_prob'] / 100 + + if combined_prob >= 0.28: # 高勝率門檻(保守) + ev = _ev_percent(combined_prob, combined_odds) + if ev > 2: + stake_units = 1.0 + total_unit += stake_units + safe_parlays.append( + { + 'match_id': 'PARLAY-SAFE', + 'match_label': ' + '.join(item['match_label'] for item in legs), + 'market_type': '跨場串關', + 'selection': '2串1 安全組合', + 'legs': [ + { + 'match_id': item['match_id'], + 'selection': item['selection'], + 'odds': item['target_odds'], + } + for item in legs + ], + 'target_odds': round(combined_odds, 2), + 'win_prob': round(combined_prob * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': round(stake_units, 2), + 'recommendation': 'SAFE_PARLAY', + 'rationale': '同風險組合加總,目標追求高穩健率 + 控制回撤。', + 'match_stage': _guess_stage(1), + }, + ) + + # 同場 SGP:取出 1 個安全 + 1 個搏冷,形成關聯爆擊模板 + if safe_singles and high_risk_singles: + s = safe_singles[0] + h = high_risk_singles[0] + combo_odds = s['target_odds'] * h['target_odds'] + combo_prob = (s['win_prob'] / 100) * (h['win_prob'] / 100) + if combo_prob > 0: + ev = _ev_percent(combo_prob, combo_odds) + sgp_lotteries.append( + { + 'match_id': s['match_id'], + 'match_label': f"{s['match_label']}【同場】", + 'market_type': 'SGP', + 'selection': f"{s['selection']} + {h['selection']}", + 'target_odds': round(combo_odds, 2), + 'win_prob': round(combo_prob * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': 0.5, + 'recommendation': 'SGP_LOTTERY', + 'rationale': '同場串關需監控相關性,避免同向風險重疊。', + 'legs': [ + {'match_id': s['match_id'], 'selection': s['selection'], 'odds': s['target_odds']}, + {'match_id': h['match_id'], 'selection': h['selection'], 'odds': h['target_odds']}, + ], + 'match_stage': _guess_stage(1), + }, + ) + + return { + 'date': target_date, + 'total_daily_unit_recommendation': round(total_unit, 2), + 'summary': ( + '系統以當日賽程、赔率變動、xG 進攻強度與場次權重回填,' + '優先輸出高穩定性單關與可控風險的串關建議。' + ), + 'safe_singles': safe_singles, + 'high_risk_singles': high_risk_singles, + 'safe_parlays': safe_parlays, + 'sgp_lotteries': sgp_lotteries, + 'matched_matches': len(matches), + 'stage_distribution': { + '小組賽': min(len(matches), 48), + '淘汰賽': max(0, len(matches) - 48), + }, + } diff --git a/platform/backend/app/analytics/engine.py b/platform/backend/app/analytics/engine.py new file mode 100644 index 0000000..9b1227e --- /dev/null +++ b/platform/backend/app/analytics/engine.py @@ -0,0 +1,112 @@ +"""量化投注引擎(EV、泊松預測、海拔修正)。""" + +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd +from scipy.stats import poisson + + +def calculate_value_bet(true_prob: float, decimal_odds: float, *, stake: float = 1.0) -> tuple[float, bool]: + """計算期望值(EV)並判斷是否屬於 Value Bet。 + + EV 計算:EV = (勝率 * 利潤) - (敗率 * 本金) + 其中利潤 = decimal_odds - 1。 + + Returns + ------- + ev_pct: float + 以本金為基底的 EV 百分比(EV / stake)。 + is_value_bet: bool + 當 EV > 0.03(3%)回傳 True。 + """ + + prob = float(true_prob) + odds = float(decimal_odds) + if not 0 <= prob <= 1 or odds <= 1 or stake <= 0: + return 0.0, False + + profit = odds - 1 + ev = prob * profit - (1 - prob) * stake + ev_pct = ev / stake + return round(ev_pct, 6), ev_pct > 0.03 + + +class PoissonPredictor: + """球員進球分佈預測器(2x2 進球建模)。""" + + def __init__( + self, + home_attack: float, + home_defense: float, + away_attack: float, + away_defense: float, + league_avg_goals: float, + ) -> None: + self.home_attack = float(home_attack) + self.home_defense = float(home_defense) + self.away_attack = float(away_attack) + self.away_defense = float(away_defense) + self.league_avg_goals = float(league_avg_goals) + + # 以攻守乘積估算 λ,並限制在合理範圍避免極端值發散。 + home_lambda = league_avg_goals * (self.home_attack / max(self.away_defense, 0.01)) + away_lambda = league_avg_goals * (self.away_attack / max(self.home_defense, 0.01)) + self.home_lambda = float(np.clip(home_lambda, 0.02, 6.5)) + self.away_lambda = float(np.clip(away_lambda, 0.02, 6.5)) + + def predict_exact_score(self, home_goals: int, away_goals: int) -> float: + """回傳指定波膽(home_goals, away_goals)發生機率。""" + + p_home = poisson.pmf(home_goals, self.home_lambda) + p_away = poisson.pmf(away_goals, self.away_lambda) + return float(p_home * p_away) + + def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 10) -> tuple[float, float]: + """回傳(under, over)機率。""" + + goals = pd.MultiIndex.from_product( + [range(max_goals + 1), range(max_goals + 1)], + names=['home', 'away'], + ).to_frame(index=False) + + def joint_prob(r: pd.Series) -> float: + return float(poisson.pmf(r['home'], self.home_lambda) * poisson.pmf(r['away'], self.away_lambda)) + + probs = goals.apply(joint_prob, axis=1) + total_goals = goals['home'] + goals['away'] + under = float(probs[total_goals <= line].sum()) + over = float(probs[total_goals > line].sum()) + return under, over + + +def adjust_away_defense_for_altitude( + base_defense_rating: float, + venue_altitude_meters: float, + *, + is_second_half: bool, + penalty_factor: float = 0.35, +) -> float: + """高海拔下修正客隊防守能力。 + + 當場地海拔高於 1500m 且處於下半場,套用對數懲罰, + 代表客隊在氧氣濃度降低下體能下降導致防守效率衰退。 + """ + + base = float(base_defense_rating) + if venue_altitude_meters <= 1500 or not is_second_half: + return base + + # 以 log(1 + altitude/1000) 做平滑遞增函式,避免低海拔時劇烈改變。 + altitude_penalty = penalty_factor * math.log1p(venue_altitude_meters / 1000) + return base * (1 - min(max(altitude_penalty, 0), 0.45)) + + +__all__ = [ + 'calculate_value_bet', + 'PoissonPredictor', + 'adjust_away_defense_for_altitude', +] + diff --git a/platform/backend/app/analytics/environment_model.py b/platform/backend/app/analytics/environment_model.py new file mode 100644 index 0000000..97b4b52 --- /dev/null +++ b/platform/backend/app/analytics/environment_model.py @@ -0,0 +1,43 @@ +"""比賽環境衰減模型(高海拔與高溫)。""" + +from __future__ import annotations + +import math + + +def adjust_team_strength_for_environment( + base_strength: float, + venue_altitude: float, + venue_heat_index: float, + is_second_half: bool, + team_acclimatized: bool, +) -> float: + """調整球隊能力值,反映環境壓力。 + + - 海拔 > 1500m 且球隊未適應,第二節時套用疲勞衰退。 + - 熱指數(Heat Index)越高,衰退越明顯。 + """ + + if base_strength < 0: + raise ValueError('base_strength 必須大於等於 0') + + adjusted = float(base_strength) + if not is_second_half: + return adjusted + + altitude_penalty = 0.0 + heat_penalty = 0.0 + + if not team_acclimatized and venue_altitude >= 1500: + # 以對數遞增,1500m 為轉折,3000m 接近上限。 + altitude_factor = math.log1p((venue_altitude - 1500.0) / 300.0) + altitude_penalty = 0.025 + 0.045 * min(altitude_factor, 2.8) + + # 熱指數高於 30,逐步加入疲勞因子,超過 38 非常明顯。 + if venue_heat_index > 30: + heat_excess = min(max(venue_heat_index - 30.0, 0.0), 30.0) + heat_penalty = 0.0012 * heat_excess + + total_penalty = altitude_penalty + heat_penalty + adjusted *= max(0.2, 1.0 - total_penalty) + return adjusted diff --git a/platform/backend/app/analytics/ev_calculator.py b/platform/backend/app/analytics/ev_calculator.py new file mode 100644 index 0000000..92a912c --- /dev/null +++ b/platform/backend/app/analytics/ev_calculator.py @@ -0,0 +1,63 @@ +"""EV(期望值)運算模組。 + +本模組提供最基本、可復用的賠率價值判斷邏輯:給定真實勝率與小數賠率,計算期望值與是否為優勢投注。 +""" + +from __future__ import annotations + +from typing import Any + + +def calculate_expected_value( + true_win_prob: float, + decimal_odds: float, + stake: float = 100.0, + suggested_kelly_fraction: float | None = None, +) -> dict[str, Any]: + """計算期望值(EV)並回傳報價建議。 + + Parameters + ---------- + true_win_prob: + 模型估計的真實勝率,必須在 0 到 1 之間。 + decimal_odds: + 小數制賠率,必須大於 1(否則不具可投注意義)。 + stake: + 本次下注本金;同時也是 EV 百分比的基準。 + suggested_kelly_fraction: + 由外部凱利公式模組預留的建議資金比例;若未提供則回傳 None。 + + Returns + ------- + dict + { + 'ev_value': 實際 EV 金額, + 'ev_percentage': EV / stake * 100, + 'is_value_bet': 當 EV% 大於 3% 時為 True, + 'suggested_kelly_fraction': 傳入值或 None + } + """ + + if not 0.0 <= true_win_prob <= 1.0: + raise ValueError('true_win_prob 必須介於 0 到 1 之間') + if decimal_odds <= 1: + raise ValueError('decimal_odds 必須大於 1') + if stake <= 0: + raise ValueError('stake 必須大於 0') + + win_prob = float(true_win_prob) + odds = float(decimal_odds) + stake_amount = float(stake) + + profit_when_win = odds - 1.0 + lose_prob = 1.0 - win_prob + + ev = win_prob * profit_when_win * stake_amount - lose_prob * stake_amount + ev_percentage = ev / stake_amount * 100 + + return { + 'ev_value': round(ev, 6), + 'ev_percentage': round(ev_percentage, 4), + 'is_value_bet': ev_percentage > 3.0, + 'suggested_kelly_fraction': suggested_kelly_fraction, + } diff --git a/platform/backend/app/analytics/feature_engineering.py b/platform/backend/app/analytics/feature_engineering.py new file mode 100644 index 0000000..87c19e5 --- /dev/null +++ b/platform/backend/app/analytics/feature_engineering.py @@ -0,0 +1,163 @@ +"""進階特徵工程:從資料庫抽取多維比賽特徵。""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from math import radians, sin, cos, asin, sqrt +from typing import Iterable + +from sqlalchemy import and_, desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..db.models import Match, Team + + +@dataclass(frozen=True) +class MatchFeatureVector: + rest_days_advantage: float + travel_distance_km: float + recent_5_xg_diff: float + elo_rating_diff: float + + +def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Haversine 地球大圓距離(公里)。""" + + R = 6371.0 + dlat = radians(lat2 - lat1) + dlon = radians(lon2 - lon1) + a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 + return 2 * R * asin(min(1.0, sqrt(a))) + + +class MatchFeatureExtractor: + """抽取並生成賽前特徵。""" + + def __init__( + self, + session_factory, + *, + team_locations: dict[str, tuple[float, float]] | None = None, + ) -> None: + self.session_factory = session_factory + # 可選:{team_id: (lat, lon)},若缺資料則 fallback 為 0 距離。 + self.team_locations = team_locations or {} + + async def _previous_match(self, session: AsyncSession, team_id: str, match_time: datetime) -> Match | None: + stmt = ( + select(Match) + .where( + and_( + (Match.home_team_id == team_id) | (Match.away_team_id == team_id), + Match.match_time_utc < match_time, + Match.home_xg.is_not(None), + Match.away_xg.is_not(None), + ), + ) + .order_by(desc(Match.match_time_utc)) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + async def _recent_xg_series(self, session: AsyncSession, team_id: str, as_of_match_id: str, count: int = 5) -> list[float]: + stmt = ( + select(Match) + .where( + (Match.home_team_id == team_id) | (Match.away_team_id == team_id), + Match.home_xg.is_not(None), + Match.away_xg.is_not(None), + Match.id != as_of_match_id, + ) + .order_by(desc(Match.match_time_utc)) + .limit(count) + ) + result = await session.execute(stmt) + rows = result.scalars().all() + out: list[float] = [] + + for row in rows: + home_xg = float(row.home_xg or 0.0) + away_xg = float(row.away_xg or 0.0) + out.append(home_xg) + out.append(away_xg) + + return out[:count] + + async def extract_features(self, match_id: str) -> MatchFeatureVector: + """產生四個關鍵特徵。 + + 1) rest_days_advantage + 2) travel_distance_km + 3) recent_5_xg_diff + 4) elo_rating_diff + """ + + async with self.session_factory() as session: # type: ignore[assignment] + current_match = await session.get(Match, match_id) + if current_match is None: + raise ValueError(f'找不到 match_id={match_id}') + + home_team = await session.get(Team, current_match.home_team_id) + away_team = await session.get(Team, current_match.away_team_id) + if home_team is None or away_team is None: + raise ValueError('比賽球隊資料不完整') + + home_prev = await self._previous_match(session, home_team.id, current_match.match_time_utc) + away_prev = await self._previous_match(session, away_team.id, current_match.match_time_utc) + + rest_home = ( + (current_match.match_time_utc - home_prev.match_time_utc).days + if home_prev is not None + else 0 + ) + rest_away = ( + (current_match.match_time_utc - away_prev.match_time_utc).days + if away_prev is not None + else 0 + ) + + travel_distance = self._distance_between_teams(home_team.id, away_team.id) + + home_xg = await self._recent_xg_series(session, home_team.id, current_match.id) + away_xg = await self._recent_xg_series(session, away_team.id, current_match.id) + recent_diff = sum(home_xg[:5]) / max(len(home_xg[:5]) or 1, 1) - sum(away_xg[:5]) / max( + len(away_xg[:5]) or 1, + 1, + ) + + home_elo = float(home_team.current_elo_rating or 1500) + away_elo = float(away_team.current_elo_rating or 1500) + + return MatchFeatureVector( + rest_days_advantage=float(rest_home - rest_away), + travel_distance_km=float(travel_distance), + recent_5_xg_diff=float(recent_diff), + elo_rating_diff=float(home_elo - away_elo), + ) + + def _distance_between_teams(self, home_team_id: str, away_team_id: str) -> float: + home_loc = self.team_locations.get(home_team_id) + away_loc = self.team_locations.get(away_team_id) + + if home_loc is None or away_loc is None: + return 0.0 + + return float(_haversine_km(home_loc[0], home_loc[1], away_loc[0], away_loc[1])) + + @staticmethod + def to_model_payload(features: MatchFeatureVector, columns: Iterable[str] | None = None) -> dict: + """輸出可直接餵進 XGBoost 的特徵字典。""" + + payload = { + 'rest_days_advantage': features.rest_days_advantage, + 'travel_distance_km': features.travel_distance_km, + 'recent_5_xg_diff': features.recent_5_xg_diff, + 'elo_rating_diff': features.elo_rating_diff, + } + + if columns is None: + return payload + cols = list(columns) + return {c: float(payload[c]) for c in cols if c in payload} diff --git a/platform/backend/app/analytics/hedging_calculator.py b/platform/backend/app/analytics/hedging_calculator.py new file mode 100644 index 0000000..0e88f47 --- /dev/null +++ b/platform/backend/app/analytics/hedging_calculator.py @@ -0,0 +1,39 @@ +"""串關動態對沖(Dynamic Hedging)計算器。""" + +from __future__ import annotations + + +def calculate_hedge_amount( + original_stake: float, + parlay_total_odds: float, + final_leg_hedge_odds: float, +) -> dict[str, float]: + """ + 在 1 場或 2/3 場連贏快到最終局,計算對沖下注金額。 + + 將原始串關保本化: + 目標是「串關全過的淨利」與「對沖走向中的淨利」在最後同值。 + + 設原始串關到位後保底淨利 = S * (O_parlay - 1) + 對沖選項淨利 = H * (O_hedge - 1) + 求 H * (O_hedge - 1) = S * (O_parlay - 1) - H + => H = (S * (O_parlay - 1)) / O_hedge + """ + + if original_stake <= 0: + raise ValueError('original_stake 必須大於 0') + if parlay_total_odds <= 1: + raise ValueError('parlay_total_odds 必須大於 1') + if final_leg_hedge_odds <= 1: + raise ValueError('final_leg_hedge_odds 必須大於 1') + + expected_parlay_net = original_stake * (parlay_total_odds - 1) + hedge_stake = expected_parlay_net / final_leg_hedge_odds + profit_after_hedge = hedge_stake * (final_leg_hedge_odds - 1) + + return { + 'hedge_stake': round(hedge_stake, 4), + 'locked_profit': round(profit_after_hedge, 4), + 'parlay_net_after_hedge_if_win': round(expected_parlay_net - hedge_stake, 4), + 'hedge_net_if_win': round(profit_after_hedge, 4), + } diff --git a/platform/backend/app/analytics/kelly.py b/platform/backend/app/analytics/kelly.py new file mode 100644 index 0000000..e301cdc --- /dev/null +++ b/platform/backend/app/analytics/kelly.py @@ -0,0 +1,64 @@ +"""凱利準則(Kelly Criterion)工具。""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class KellyResult: + """凱利投注建議結果。""" + + decimal_odds: float + win_probability: float + raw_kelly_fraction: float + fractional_kelly_factor: float + risk_tolerance_factor: float + final_fraction: float + stake_fraction: float + + +def calculate_kelly_fraction( + decimal_odds: float, + true_prob: float, + *, + bankroll: float, + fractional_kelly_factor: float = 1.0, + risk_tolerance_factor: float = 1.0, +) -> KellyResult: + """依凱利準則估算下注比例與建議金額。 + + 凱利公式: + f* = (b * p - q) / b + 其中 b = odds - 1,p 為勝率,q = 1 - p。 + """ + + if decimal_odds <= 1: + raise ValueError('decimal_odds 必須大於 1') + if bankroll <= 0: + raise ValueError('bankroll 必須大於 0') + if not 0 <= true_prob <= 1: + raise ValueError('true_prob 需介於 0 到 1') + if not 0 <= fractional_kelly_factor <= 5: + raise ValueError('fractional_kelly_factor 須介於 0 到 5') + if not 0 <= risk_tolerance_factor <= 2: + raise ValueError('risk_tolerance_factor 須介於 0 到 2') + + b = decimal_odds - 1 + raw_kelly = (b * true_prob - (1 - true_prob)) / b + final_fraction = raw_kelly * fractional_kelly_factor * risk_tolerance_factor + # 保守處理:避免負值與超過總資金比例(100%)的極端輸出。 + final_fraction = max(0.0, min(final_fraction, 1.0)) + + return KellyResult( + decimal_odds=decimal_odds, + win_probability=true_prob, + raw_kelly_fraction=raw_kelly, + fractional_kelly_factor=fractional_kelly_factor, + risk_tolerance_factor=risk_tolerance_factor, + final_fraction=final_fraction, + stake_fraction=final_fraction, + ) + + +__all__ = ['KellyResult', 'calculate_kelly_fraction'] diff --git a/platform/backend/app/analytics/ml_ensemble.py b/platform/backend/app/analytics/ml_ensemble.py new file mode 100644 index 0000000..116e0bc --- /dev/null +++ b/platform/backend/app/analytics/ml_ensemble.py @@ -0,0 +1,435 @@ +"""機器學習賽果預測引擎(Ensemble)。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping +from uuid import uuid4 + +import numpy as np +import pandas as pd + +try: + from sklearn.ensemble import GradientBoostingClassifier + from sklearn.model_selection import train_test_split +except Exception: # pragma: no cover - 缺少 scikit-learn 時的 fallback + GradientBoostingClassifier = None + train_test_split = None + + +FEATURE_COLUMNS = ('rest_days_advantage', 'travel_distance_km', 'recent_5_xg_diff') +OUTCOMES = ('home', 'draw', 'away') + + +def _sigmoid(value: float) -> float: + return 1.0 / (1.0 + np.exp(-value)) + + +def _softmax(values: np.ndarray) -> np.ndarray: + shifted = values - np.max(values) + exp_values = np.exp(shifted) + return exp_values / exp_values.sum() + + +@dataclass(frozen=True) +class EnsembleModelArtifact: + """已訓練的 ML 模組與中繼資料。""" + + model: Any + feature_columns: tuple[str, ...] + model_id: str + training_size: int + is_fallback: bool + training_accuracy: float | None = None + + +class _FallbackMatchModel: + """缺少 ML 套件時的保底模型(規則式)。""" + + feature_columns = FEATURE_COLUMNS + + def predict_proba(self, row_df: pd.DataFrame) -> np.ndarray: + if row_df.empty: + return np.zeros((0, 3)) + + x = row_df[self.feature_columns].to_numpy(float) + raw_scores = [] + + for rest_days_advantage, travel_distance_km, recent_5_xg_diff in x: + home_score = 0.6 + rest_days_advantage * 0.022 + recent_5_xg_diff * 0.34 - travel_distance_km * 0.0012 + draw_score = 0.30 - abs(rest_days_advantage) * 0.015 - abs(recent_5_xg_diff) * 0.22 + away_score = 0.1 - rest_days_advantage * 0.022 - recent_5_xg_diff * 0.34 + travel_distance_km * 0.0012 + + scores = np.array( + [ + _sigmoid(home_score), + _sigmoid(draw_score) * 0.9, + _sigmoid(away_score), + ], + dtype=float, + ) + raw_scores.append(_softmax(scores)) + + return np.vstack(raw_scores) + + +def _as_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def normalize_feature_payload(payload: Mapping[str, Any]) -> dict[str, float]: + """從前端或資料庫欄位,萃取核心三大特徵。""" + + home_rest = _as_float(payload.get('home_rest_days')) + away_rest = _as_float(payload.get('away_rest_days')) + home_travel = _as_float(payload.get('home_travel_distance_km')) + away_travel = _as_float(payload.get('away_travel_distance_km')) + recent_home = _as_float(payload.get('recent_5_xg_home')) + recent_away = _as_float(payload.get('recent_5_xg_away')) + + return { + 'home_rest_days': home_rest, + 'away_rest_days': away_rest, + 'home_travel_distance_km': home_travel, + 'away_travel_distance_km': away_travel, + 'recent_5_xg_home': recent_home, + 'recent_5_xg_away': recent_away, + 'rest_days_advantage': home_rest - away_rest, + 'travel_distance_km': home_travel - away_travel, + 'recent_5_xg_diff': recent_home - recent_away, + } + + +def _validation_frame(rows: list[Mapping[str, Any]]) -> pd.DataFrame: + if len(rows) < 5: + raise ValueError('訓練樣本少於 5 筆,無法完成穩定訓練') + + frame = pd.DataFrame(rows) + required_fields = set(FEATURE_COLUMNS) | {'match_result'} + missing = required_fields - set(frame.columns) + if missing: + raise ValueError(f'訓練資料缺欄位:{sorted(missing)}') + + frame = frame.copy() + frame[list(FEATURE_COLUMNS)] = frame[list(FEATURE_COLUMNS)].astype(float).fillna(0.0) + frame['match_result'] = frame['match_result'].str.lower().str.strip() + + unknown = set(frame['match_result']) - set(OUTCOMES) + if unknown: + raise ValueError(f'未知賽果標籤:{sorted(unknown)},僅支援 {OUTCOMES}') + return frame + + +def build_default_ml_training_rows() -> list[dict[str, float | str]]: + """建立保底訓練樣本(當環境無法即時取得外部訓練資料時)。""" + + return [ + { + 'home_rest_days': 4, + 'away_rest_days': 3, + 'home_travel_distance_km': 520, + 'away_travel_distance_km': 1100, + 'recent_5_xg_home': 1.8, + 'recent_5_xg_away': 1.0, + 'rest_days_advantage': 1, + 'travel_distance_km': -580, + 'recent_5_xg_diff': 0.8, + 'match_result': 'home', + }, + { + 'home_rest_days': 2, + 'away_rest_days': 5, + 'home_travel_distance_km': 220, + 'away_travel_distance_km': 780, + 'recent_5_xg_home': 1.1, + 'recent_5_xg_away': 1.7, + 'rest_days_advantage': -3, + 'travel_distance_km': -560, + 'recent_5_xg_diff': -0.6, + 'match_result': 'away', + }, + { + 'home_rest_days': 6, + 'away_rest_days': 4, + 'home_travel_distance_km': 120, + 'away_travel_distance_km': 960, + 'recent_5_xg_home': 2.3, + 'recent_5_xg_away': 1.8, + 'rest_days_advantage': 2, + 'travel_distance_km': -840, + 'recent_5_xg_diff': 0.5, + 'match_result': 'home', + }, + { + 'home_rest_days': 3, + 'away_rest_days': 3, + 'home_travel_distance_km': 900, + 'away_travel_distance_km': 900, + 'recent_5_xg_home': 1.2, + 'recent_5_xg_away': 1.3, + 'rest_days_advantage': 0, + 'travel_distance_km': 0, + 'recent_5_xg_diff': -0.1, + 'match_result': 'draw', + }, + { + 'home_rest_days': 8, + 'away_rest_days': 2, + 'home_travel_distance_km': 350, + 'away_travel_distance_km': 700, + 'recent_5_xg_home': 2.0, + 'recent_5_xg_away': 1.2, + 'rest_days_advantage': 6, + 'travel_distance_km': -350, + 'recent_5_xg_diff': 0.8, + 'match_result': 'home', + }, + { + 'home_rest_days': 1, + 'away_rest_days': 2, + 'home_travel_distance_km': 1600, + 'away_travel_distance_km': 2500, + 'recent_5_xg_home': 1.4, + 'recent_5_xg_away': 2.1, + 'rest_days_advantage': -1, + 'travel_distance_km': -900, + 'recent_5_xg_diff': -0.7, + 'match_result': 'away', + }, + { + 'home_rest_days': 5, + 'away_rest_days': 5, + 'home_travel_distance_km': 700, + 'away_travel_distance_km': 700, + 'recent_5_xg_home': 1.9, + 'recent_5_xg_away': 1.9, + 'rest_days_advantage': 0, + 'travel_distance_km': 0, + 'recent_5_xg_diff': 0.0, + 'match_result': 'draw', + }, + { + 'home_rest_days': 9, + 'away_rest_days': 3, + 'home_travel_distance_km': 400, + 'away_travel_distance_km': 300, + 'recent_5_xg_home': 2.4, + 'recent_5_xg_away': 1.1, + 'rest_days_advantage': 6, + 'travel_distance_km': 100, + 'recent_5_xg_diff': 1.3, + 'match_result': 'home', + }, + { + 'home_rest_days': 2, + 'away_rest_days': 7, + 'home_travel_distance_km': 1800, + 'away_travel_distance_km': 250, + 'recent_5_xg_home': 1.0, + 'recent_5_xg_away': 1.5, + 'rest_days_advantage': -5, + 'travel_distance_km': 1550, + 'recent_5_xg_diff': -0.5, + 'match_result': 'away', + }, + { + 'home_rest_days': 4, + 'away_rest_days': 4, + 'home_travel_distance_km': 500, + 'away_travel_distance_km': 500, + 'recent_5_xg_home': 1.6, + 'recent_5_xg_away': 1.4, + 'rest_days_advantage': 0, + 'travel_distance_km': 0, + 'recent_5_xg_diff': 0.2, + 'match_result': 'home', + }, + { + 'home_rest_days': 6, + 'away_rest_days': 1, + 'home_travel_distance_km': 300, + 'away_travel_distance_km': 1200, + 'recent_5_xg_home': 2.8, + 'recent_5_xg_away': 0.8, + 'rest_days_advantage': 5, + 'travel_distance_km': -900, + 'recent_5_xg_diff': 2.0, + 'match_result': 'home', + }, + { + 'home_rest_days': 2, + 'away_rest_days': 6, + 'home_travel_distance_km': 1000, + 'away_travel_distance_km': 200, + 'recent_5_xg_home': 1.0, + 'recent_5_xg_away': 2.6, + 'rest_days_advantage': -4, + 'travel_distance_km': 800, + 'recent_5_xg_diff': -1.6, + 'match_result': 'away', + }, + { + 'home_rest_days': 7, + 'away_rest_days': 7, + 'home_travel_distance_km': 650, + 'away_travel_distance_km': 650, + 'recent_5_xg_home': 1.8, + 'recent_5_xg_away': 1.8, + 'rest_days_advantage': 0, + 'travel_distance_km': 0, + 'recent_5_xg_diff': 0.0, + 'match_result': 'draw', + }, + { + 'home_rest_days': 3, + 'away_rest_days': 1, + 'home_travel_distance_km': 260, + 'away_travel_distance_km': 900, + 'recent_5_xg_home': 2.1, + 'recent_5_xg_away': 1.6, + 'rest_days_advantage': 2, + 'travel_distance_km': -640, + 'recent_5_xg_diff': 0.5, + 'match_result': 'home', + }, + { + 'home_rest_days': 0, + 'away_rest_days': 5, + 'home_travel_distance_km': 1500, + 'away_travel_distance_km': 150, + 'recent_5_xg_home': 1.2, + 'recent_5_xg_away': 2.0, + 'rest_days_advantage': -5, + 'travel_distance_km': 1350, + 'recent_5_xg_diff': -0.8, + 'match_result': 'away', + }, + { + 'home_rest_days': 5, + 'away_rest_days': 2, + 'home_travel_distance_km': 300, + 'away_travel_distance_km': 300, + 'recent_5_xg_home': 2.2, + 'recent_5_xg_away': 1.1, + 'rest_days_advantage': 3, + 'travel_distance_km': 0, + 'recent_5_xg_diff': 1.1, + 'match_result': 'home', + }, + { + 'home_rest_days': 4, + 'away_rest_days': 8, + 'home_travel_distance_km': 450, + 'away_travel_distance_km': 980, + 'recent_5_xg_home': 1.5, + 'recent_5_xg_away': 2.4, + 'rest_days_advantage': -4, + 'travel_distance_km': -530, + 'recent_5_xg_diff': -0.9, + 'match_result': 'away', + }, + ] + + +def train_match_outcome_ensemble( + training_rows: list[Mapping[str, Any]], + *, + model_id: str | None = None, +) -> EnsembleModelArtifact: + """訓練 1X2 賽果 Ensemble(無法使用 sklearn 時自動回退規則模型)。""" + + normalized = [_normalize_training_row(row) for row in training_rows] + frame = _validation_frame(normalized) + + x = frame[list(FEATURE_COLUMNS)] + y = frame['match_result'].map({'home': 0, 'draw': 1, 'away': 2}) + + if len(frame) < 24 or GradientBoostingClassifier is None or train_test_split is None: + return EnsembleModelArtifact( + model=_FallbackMatchModel(), + feature_columns=FEATURE_COLUMNS, + model_id=model_id or uuid4().hex, + training_size=len(frame), + is_fallback=True, + training_accuracy=None, + ) + + x_train, x_val, y_train, y_val = train_test_split( + x, + y, + test_size=min(0.3, max(0.15, 1 - (30 / len(frame)))), + random_state=17, + stratify=y, + ) + + model = GradientBoostingClassifier( + random_state=17, + n_estimators=220, + max_depth=3, + learning_rate=0.06, + ) + model.fit(x_train, y_train) + accuracy = float(model.score(x_val, y_val)) if len(set(y_val)) > 1 else None + + return EnsembleModelArtifact( + model=model, + feature_columns=FEATURE_COLUMNS, + model_id=model_id or uuid4().hex, + training_size=len(frame), + is_fallback=False, + training_accuracy=accuracy, + ) + + +def _normalize_training_row(row: Mapping[str, Any]) -> dict[str, float | str]: + normalized = normalize_feature_payload(row) + if 'match_result' not in row: + raise ValueError('訓練資料缺少 match_result') + normalized['match_result'] = str(row['match_result']).strip().lower() + return normalized + + +def build_default_ensemble_artifact() -> EnsembleModelArtifact: + """建立系統預設模型(含 fallback)。""" + + return train_match_outcome_ensemble(build_default_ml_training_rows(), model_id='default') + + +def model_predict_probabilities( + artifact: EnsembleModelArtifact, + features: Mapping[str, Any], +) -> dict[str, float]: + """回傳 home/draw/away 的機率。""" + + normalized = normalize_feature_payload(features) + feature_frame = pd.DataFrame([normalized], columns=artifact.feature_columns) + probs = artifact.model.predict_proba(feature_frame)[0] + return { + 'home': float(probs[0]), + 'draw': float(probs[1]), + 'away': float(probs[2]), + } + + +def calculate_model_edges( + predicted: dict[str, float], + implied: dict[str, float], +) -> dict[str, dict[str, float | bool]]: + """比較模型機率與莊家隱含機率,標示 Strong Buy。""" + + edges: dict[str, dict[str, float | bool]] = {} + for key in OUTCOMES: + p = float(predicted.get(key, 0)) + i = float(implied.get(key, 0)) + edge = p - i + edges[key] = { + 'model_prob': round(p, 6), + 'implied_prob': round(i, 6), + 'edge': round(edge, 6), + 'strong_buy': edge >= 0.04, + } + return edges + diff --git a/platform/backend/app/analytics/ml_inference.py b/platform/backend/app/analytics/ml_inference.py new file mode 100644 index 0000000..fdfbaec --- /dev/null +++ b/platform/backend/app/analytics/ml_inference.py @@ -0,0 +1,99 @@ +"""XGBoost 推論 API 套件。""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from xgboost import Booster, DMatrix + + +@dataclass(frozen=True) +class XGBoostPrediction: + home_win: float + draw: float + away_win: float + + +def _safe_probability(x: float) -> float: + return float(max(0.0, min(1.0, x))) + + +class XGBoostPredictor: + """XGBoost 預測器:輸入特徵 => 輸出 1x2 機率。""" + + def __init__( + self, + model_path: str | None = None, + *, + feature_columns: list[str] | None = None, + ) -> None: + self.feature_columns = feature_columns or [] + self.model_path = model_path + self.model = self._load_model(model_path) if model_path else None + + def _load_model(self, model_path: str | None) -> Booster | None: + if not model_path: + return None + path = Path(model_path) + if not path.exists(): + return None + model = Booster() + model.load_model(str(path)) + return model + + def predict_match_outcome(self, features: dict[str, float]) -> dict[str, float]: + """輸出主勝/平/客勝機率。""" + + if self.model is None: + # fallback: 均分 + return {'home': 1 / 3, 'draw': 1 / 3, 'away': 1 / 3} + + ordered_values = [float(features.get(col, 0.0)) for col in self.feature_columns] + dmatrix = DMatrix(np.array([ordered_values]), feature_names=self.feature_columns) + probs = self.model.predict(dmatrix) + + if probs.ndim == 1: + probs = probs.reshape(1, -1) + arr = probs[0] + if arr.size < 3: + raise ValueError('模型輸出維度不足 3') + + raw = np.array(arr[:3], dtype=float) + raw = np.maximum(raw, 0.0) + s = raw.sum() + if s <= 0: + raise ValueError('模型輸出總和異常為 0') + norm = raw / s + + return {'home': _safe_probability(norm[0]), 'draw': _safe_probability(norm[1]), 'away': _safe_probability(norm[2])} + + def find_model_edge( + self, + ml_probs: dict[str, float], + bookmaker_implied_probs: dict[str, float], + ) -> list[dict[str, Any]]: + """回傳模型超越莊家 4% 以上的投注選項。""" + + mapping = [('home', 'home'), ('draw', 'draw'), ('away', 'away')] + outputs: list[dict[str, Any]] = [] + + for model_key, book_key in mapping: + ml_v = float(ml_probs.get(model_key, 0.0)) + book_v = float(bookmaker_implied_probs.get(book_key, 0.0)) + edge = ml_v - book_v + + if edge >= 0.04: + outputs.append( + { + 'selection': model_key, + 'ml_prob': round(ml_v, 6), + 'bookmaker_implied_prob': round(book_v, 6), + 'edge': round(edge, 6), + 'label': 'Strong Buy', + }, + ) + + return outputs diff --git a/platform/backend/app/analytics/player_props.py b/platform/backend/app/analytics/player_props.py new file mode 100644 index 0000000..f5a962b --- /dev/null +++ b/platform/backend/app/analytics/player_props.py @@ -0,0 +1,163 @@ +"""球員道具盤(Player Props)量化引擎。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +import numpy as np + + +PropMetric = Literal['shots', 'shots_on_target', 'passes'] + + +@dataclass(frozen=True) +class PlayerPropsProfile: + """球員與對位環境的道具盤參考參數。""" + + player_id: str + metric: PropMetric + baseline_mean: float + match_minutes: int = 90 + team_attack_factor: float = 1.0 + opponent_defence_factor: float = 1.0 + weather_fatigue_factor: float = 1.0 + + +@dataclass(frozen=True) +class PlayerPropsSimulationResult: + """單個道具盤的模擬輸出。""" + + metric: PropMetric + line: float + over_probability: float + under_probability: float + expected_count: float + p5: float + p50: float + p95: float + simulation_runs: int + + def to_dict(self) -> dict[str, float | int | str]: + return { + 'metric': self.metric, + 'line': self.line, + 'over_probability': self.over_probability, + 'under_probability': self.under_probability, + 'expected_count': self.expected_count, + 'p5': self.p5, + 'p50': self.p50, + 'p95': self.p95, + 'simulation_runs': self.simulation_runs, + } + + +def _apply_context_multiplier(profile: PlayerPropsProfile) -> float: + """依據球員對位環境組合出單場事件期望值修正係數。""" + + multipliers = [ + max(0.1, profile.team_attack_factor), + 1 / max(0.5, profile.opponent_defence_factor), + max(0.6, profile.weather_fatigue_factor), + ] + return float(np.prod(multipliers)) + + +def _metric_seed_variance(profile: PlayerPropsProfile) -> float: + """使用不同維度的離散程度(sigma)以保留球員特徵差異。""" + + if profile.metric == 'passes': + return 0.45 + if profile.metric == 'shots_on_target': + return 0.22 + return 0.30 + + +def simulate_player_prop_probability( + profile: PlayerPropsProfile, + *, + line: float, + simulations: int = 10000, + rng: np.random.Generator | None = None, +) -> PlayerPropsSimulationResult: + """用蒙地卡羅法計算球員道具盤超過盤口線的機率。""" + + if line <= 0: + raise ValueError('line 必須為正數') + if simulations <= 100: + raise ValueError('simulations 最少需要 100 次') + + generator = rng or np.random.default_rng() + + minute_ratio = profile.match_minutes / 90 + base = profile.baseline_mean * minute_ratio + adjusted_mean = max(0.05, base * _apply_context_multiplier(profile)) + + # 以 Gamma-Poisson 混合近似捕捉波動,避免單純 Poisson 太過平滑。 + gamma_shape = max(0.5, 1.0 / (_metric_seed_variance(profile) ** 2)) + gamma_scale = adjusted_mean / gamma_shape + intensity = generator.gamma(gamma_shape, gamma_scale, size=simulations) + + counts = generator.poisson(intensity).astype(float) + + over_count = int(np.sum(counts > line)) + over_probability = over_count / simulations + under_probability = 1 - over_probability + + expected_count = float(np.mean(counts)) + p5, p50, p95 = [float(np.percentile(counts, q)) for q in (5, 50, 95)] + + return PlayerPropsSimulationResult( + metric=profile.metric, + line=line, + over_probability=round(over_probability, 6), + under_probability=round(under_probability, 6), + expected_count=round(expected_count, 3), + p5=p5, + p50=p50, + p95=p95, + simulation_runs=simulations, + ) + + +def evaluate_top_edge( + profile: PlayerPropsProfile, + bookmaker_over_odds: float, + *, + line: float, + simulations: int = 10000, + stake: float = 1.0, +) -> dict[str, Any]: + """回傳道具盤 EV 與建議邊際,供前端高邊際卡片使用。""" + + result = simulate_player_prop_probability(profile, line=line, simulations=simulations) + + if bookmaker_over_odds <= 1: + raise ValueError('bookmaker_over_odds 必須大於 1') + + # EV 計算以 "賭 over" 為例。 + win_profit = (bookmaker_over_odds - 1) * stake + loss = stake + ev = result.over_probability * win_profit - (1 - result.over_probability) * loss + + edge = ev / stake + top_edge = edge > 0.08 + + return { + **result.to_dict(), + 'edge': round(edge, 6), + 'top_edge': top_edge, + 'bookmaker_over_odds': bookmaker_over_odds, + 'implied_prob': round(1 / bookmaker_over_odds, 6), + 'recommended_stake_hint': round(max(0.0, edge * stake * 0.4), 2), + } + + +__all__ = [ + 'PropMetric', + 'PlayerPropsProfile', + 'PlayerPropsSimulationResult', + 'evaluate_top_edge', + 'simulate_player_prop_probability', +] + diff --git a/platform/backend/app/analytics/player_props_sim.py b/platform/backend/app/analytics/player_props_sim.py new file mode 100644 index 0000000..045cff5 --- /dev/null +++ b/platform/backend/app/analytics/player_props_sim.py @@ -0,0 +1,79 @@ +"""球員道具盤(Props)蒙地卡羅模擬模組。""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class PlayerPropsDistribution: + shots: np.ndarray + shots_on_target: np.ndarray + passes: np.ndarray + + +def simulate_player_stats( + player_metrics: dict, + opponent_defense_metrics: dict, + iterations: int = 10_000, +) -> PlayerPropsDistribution: + """快速模擬球員事件次數分佈。""" + + if iterations <= 0: + raise ValueError('iterations 必須大於 0') + + avg_touches = float(player_metrics.get('avg_touches', 45) or 0.0) + base_shot_rate = float(player_metrics.get('shots_per_touch', 0.08) or 0.0) + base_target_rate = float(player_metrics.get('shot_on_target_rate', 0.35) or 0.0) + base_pass_rate = float(player_metrics.get('passes_per_touch', 0.65) or 0.0) + + opp_pressure = float(opponent_defense_metrics.get('pressing_index', 1.0) or 1.0) + opp_tackling = float(opponent_defense_metrics.get('marking_index', 1.0) or 1.0) + + adj_touches = max(1.0, avg_touches * max(0.6, 1.0 / max(0.5, opp_pressure))) + shot_lambda = adj_touches * base_shot_rate + pass_lambda = adj_touches * base_pass_rate + + rng = np.random.default_rng() + shots = rng.poisson(lam=shot_lambda, size=iterations) + passes = rng.poisson(lam=pass_lambda, size=iterations) + + # 對方壓迫會降低射正率 + effective_target_rate = max(0.02, base_target_rate / max(opp_tackling, 0.3)) + shots_on_target = rng.binomial(shots, p=min(effective_target_rate, 0.99), size=iterations) + + return PlayerPropsDistribution(shots=shots.astype(int), shots_on_target=shots_on_target.astype(int), passes=passes.astype(int)) + + +def evaluate_prop_bet( + simulated_distribution: PlayerPropsDistribution, + line: float, + odds: float, +) -> dict[str, float | bool]: + """從 10,000 次模擬結果計算超過盤口機率與 EV。""" + + if odds <= 1: + raise ValueError('odds 必須大於 1') + if line < 0: + raise ValueError('line 必須大於等於 0') + + shots = simulated_distribution.shots + if shots.size == 0: + raise ValueError('distribution 為空') + + probability_over = float((shots > line).mean()) + from .ev_calculator import calculate_expected_value + + ev = calculate_expected_value(probability_over, odds) + + return { + 'metric': 'shots', + 'line': line, + 'over_probability': round(probability_over, 6), + 'under_probability': round(1.0 - probability_over, 6), + 'implied_ev': ev['ev_value'], + 'ev_percentage': ev['ev_percentage'], + 'is_value_bet': bool(ev['is_value_bet']), + } diff --git a/platform/backend/app/analytics/poisson_model.py b/platform/backend/app/analytics/poisson_model.py new file mode 100644 index 0000000..8b95fee --- /dev/null +++ b/platform/backend/app/analytics/poisson_model.py @@ -0,0 +1,113 @@ +"""Poisson 分佈賽果預測模組。""" + +from __future__ import annotations + +import numpy as np +from scipy.stats import poisson + + +class PoissonMatchPredictor: + """基於攻守強度的雙方進球機率預測器。""" + + def __init__( + self, + home_attack_strength: float, + home_defense_strength: float, + away_attack_strength: float, + away_defense_strength: float, + league_avg_home_goals: float, + ) -> None: + for value, name in [ + (home_attack_strength, 'home_attack_strength'), + (home_defense_strength, 'home_defense_strength'), + (away_attack_strength, 'away_attack_strength'), + (away_defense_strength, 'away_defense_strength'), + (league_avg_home_goals, 'league_avg_home_goals'), + ]: + if value <= 0: + raise ValueError(f'{name} 必須大於 0') + + self.home_attack_strength = float(home_attack_strength) + self.home_defense_strength = float(home_defense_strength) + self.away_attack_strength = float(away_attack_strength) + self.away_defense_strength = float(away_defense_strength) + self.league_avg_home_goals = float(league_avg_home_goals) + + def calculate_expected_goals(self) -> tuple[float, float]: + """根據攻守強度與聯盟均值估算預期進球數(λ 值)。 + + 使用比值校正避免極端值放大風險: + - 主隊 λ = 聯盟主場均值 × (主攻 / 客守) + - 客隊 λ = 聯盟客場均值 × (客攻 / 主守) + """ + + league_avg_away_goals = self.league_avg_home_goals * 0.95 + + home_lambda = self.league_avg_home_goals * (self.home_attack_strength / self.away_defense_strength) + away_lambda = league_avg_away_goals * (self.away_attack_strength / self.home_defense_strength) + + home_lambda = max(0.01, min(home_lambda, 8.0)) + away_lambda = max(0.01, min(away_lambda, 8.0)) + + return home_lambda, away_lambda + + def predict_exact_score_matrix(self, max_goals: int = 5) -> np.ndarray: + """輸出 0~max_goals 間所有比分組合的機率矩陣。 + + 回傳 shape = (max_goals+1, max_goals+1), + index [i,j] 代表主隊 i 球、客隊 j 球的機率。 + """ + + if max_goals < 0: + raise ValueError('max_goals 必須大於等於 0') + + home_lambda, away_lambda = self.calculate_expected_goals() + + goals = np.arange(max_goals + 1) + home_prob = poisson.pmf(goals, home_lambda) + away_prob = poisson.pmf(goals, away_lambda) + + matrix = np.outer(home_prob, away_prob) + matrix = matrix.astype(float) + matrix /= matrix.sum() if matrix.sum() > 0 else 1.0 + return matrix + + def predict_1x2_probabilities(self) -> dict[str, float]: + """由波膽矩陣匯總 1x2(主勝/平/客勝)機率。""" + + matrix = self.predict_exact_score_matrix(max_goals=8) + + draw = float(np.trace(matrix)) + home_win = float(np.tril(matrix, -1).sum()) + away_win = float(np.triu(matrix, 1).sum()) + + total = home_win + draw + away_win + if total <= 0: + return {'home_win': 0.0, 'draw': 0.0, 'away_win': 0.0} + + return { + 'home_win': home_win / total, + 'draw': draw / total, + 'away_win': away_win / total, + } + + def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 8) -> tuple[float, float]: + """回傳(Under 機率, Over 機率)。""" + + if line < 0: + raise ValueError('line 必須大於等於 0') + + matrix = self.predict_exact_score_matrix(max_goals=max_goals) + goals = np.arange(max_goals + 1) + home, away = np.meshgrid(goals, goals) + total = home + away + + under_mask = total <= line + under = float(matrix[under_mask].sum()) + over = float(matrix[~under_mask].sum()) + normalizer = under + over + + if normalizer <= 0: + return 0.0, 0.0 + + return under / normalizer, over / normalizer diff --git a/platform/backend/app/analytics/portfolio_analyzer.py b/platform/backend/app/analytics/portfolio_analyzer.py new file mode 100644 index 0000000..a2a539b --- /dev/null +++ b/platform/backend/app/analytics/portfolio_analyzer.py @@ -0,0 +1,241 @@ +"""個人投注弱點分析(Betting Leaks)引擎。 + +將使用者歷史注單做群組化彙總,找出長期導致虧損的下注模式。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +def _safe_float(value: Any, default: float | None = None) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value: Any, default: int | None = None) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _to_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 't', 'yes', 'y'} + if isinstance(value, (int, float)): + return value not in {0} + return default + + +def _odds_bucket(odds: float | None, step: float = 0.5) -> str: + if odds is None or odds <= 0: + return 'N/A' + if odds <= 1: + return '1.00-1.50' + bucket_start = ((odds - 1) // step) * step + 1 + bucket_end = bucket_start + step + return f'{bucket_start:.2f}-{bucket_end:.2f}' + + +def _calculate_pnl(stake: float, is_win: bool, closing_odds: float | None, recommended_odds: float | None) -> float: + """依下注結果與收盤賠率計算實際 P/L。""" + + effective_odds = closing_odds + if effective_odds is None or effective_odds <= 1: + effective_odds = recommended_odds + + if effective_odds is None or effective_odds <= 1 or stake <= 0: + return 0.0 + + if is_win: + return stake * (effective_odds - 1) + return -stake + + +def _calculate_clv(recommended_odds: float | None, closing_odds: float | None) -> float | None: + if recommended_odds is None or closing_odds is None: + return None + if recommended_odds <= 0 or closing_odds <= 0: + return None + return (recommended_odds / closing_odds - 1) * 100 + + +@dataclass(frozen=True) +class LeakageCluster: + market_type: str + bet_type: str + odds_bucket: str + match_stage: str + bet_count: int + total_stake: float + closed_count: int + win_count: int + total_pnl: float + avg_clv_percent: float + roi_percent: float + hit_rate_percent: float + status: str + + def as_dict(self) -> dict[str, Any]: + return { + 'market_type': self.market_type, + 'bet_type': self.bet_type, + 'odds_bucket': self.odds_bucket, + 'match_stage': self.match_stage, + 'bet_count': self.bet_count, + 'total_stake': self.total_stake, + 'closed_count': self.closed_count, + 'win_count': self.win_count, + 'total_pnl': self.total_pnl, + 'avg_clv_percent': self.avg_clv_percent, + 'roi_percent': self.roi_percent, + 'hit_rate_percent': self.hit_rate_percent, + 'status': self.status, + } + + +@dataclass(frozen=True) +class HardTruth: + title: str + message: str + cluster: dict[str, Any] + + +def analyze_user_leaks(user_bets: list[dict[str, Any]]) -> dict[str, Any]: + """分析使用者注單中的高頻虧損模式,回傳風險群組與漏點警告。""" + + raw_bets = user_bets if isinstance(user_bets, list) else [] + + grouped: dict[tuple[str, str, str, str], dict[str, Any]] = {} + + for raw in raw_bets: + if not isinstance(raw, dict): + continue + + market_type = str(raw.get('market_type', 'unknown')).strip() or 'unknown' + is_single = raw.get('parlay_type') in (None, 'single', '', 'single_bet') + bet_type = 'single' if is_single else 'parlay' + odds = _safe_float(raw.get('odds')) + stake = _safe_float(raw.get('stake')) + if stake is None or stake <= 0: + continue + + match_stage = str(raw.get('match_stage', raw.get('stage', 'unknown'))).strip() or 'unknown' + odds_band = _odds_bucket(odds) + key = (market_type, bet_type, odds_band, match_stage) + + entry = grouped.setdefault( + key, + { + 'bet_count': 0, + 'total_stake': 0.0, + 'closed_count': 0, + 'win_count': 0, + 'total_pnl': 0.0, + 'clv_values': [] as list[float], + }, + ) + + entry['bet_count'] += 1 + entry['total_stake'] += stake + + is_settled = _to_bool(raw.get('is_settled'), default=False) + if not is_settled: + continue + + is_win = _to_bool(raw.get('is_win')) + if is_win: + entry['win_count'] += 1 + + entry['closed_count'] += 1 + closing_odds = _safe_float(raw.get('closing_odds')) + recommended_odds = odds or _safe_float(raw.get('recommended_odds')) + pnl = _calculate_pnl( + stake=stake, + is_win=is_win, + closing_odds=closing_odds, + recommended_odds=recommended_odds, + ) + entry['total_pnl'] += pnl + + clv = _calculate_clv(recommended_odds, closing_odds) + if clv is not None: + entry['clv_values'].append(clv) + + total_bets = sum(v['bet_count'] for v in grouped.values()) + settled_bets = sum(v['closed_count'] for v in grouped.values()) + total_stake = sum(v['total_stake'] for v in grouped.values()) + total_pnl = sum(v['total_pnl'] for v in grouped.values()) + total_win = sum(v['win_count'] for v in grouped.values()) + + clusters: list[LeakageCluster] = [] + hard_truths: list[HardTruth] = [] + + for (market_type, bet_type, odds_bucket, match_stage), row in grouped.items(): + bet_count = int(row['bet_count']) + closed_count = int(row['closed_count']) + total_stake_group = float(row['total_stake']) + total_pnl_group = float(row['total_pnl']) + + roi = (total_pnl_group / total_stake_group * 100) if total_stake_group > 0 else 0.0 + win_rate = (row['win_count'] / closed_count * 100) if closed_count > 0 else 0.0 + avg_clv = (sum(row['clv_values']) / len(row['clv_values'])) if row['clv_values'] else 0.0 + + status = 'OK' + if bet_count > 20 and roi < -10: + status = 'CRITICAL_LEAK' + hard_truths.append( + HardTruth( + title='嚴重漏財點', + message=( + f'{match_stage} / {bet_type} / {market_type} / {odds_bucket} 的下注次數 {bet_count} 場,' + f'ROI {roi:.2f}%,請先降低此區塊投注比例。' + ), + cluster={ + 'market_type': market_type, + 'bet_type': bet_type, + 'odds_bucket': odds_bucket, + 'match_stage': match_stage, + }, + ).__dict__, + ) + + clusters.append( + LeakageCluster( + market_type=market_type, + bet_type=bet_type, + odds_bucket=odds_bucket, + match_stage=match_stage, + bet_count=bet_count, + total_stake=round(total_stake_group, 2), + closed_count=closed_count, + win_count=row['win_count'], + total_pnl=round(total_pnl_group, 2), + avg_clv_percent=round(avg_clv, 4), + roi_percent=round(roi, 4), + hit_rate_percent=round(win_rate, 2), + status=status, + ), + ) + + clusters.sort(key=lambda c: c.roi_percent) + + overall_roi = (total_pnl / total_stake * 100) if total_stake > 0 else 0.0 + overall_hit_rate = (total_win / settled_bets * 100) if settled_bets > 0 else 0.0 + + return { + 'total_bet_count': total_bets, + 'settled_bet_count': settled_bets, + 'total_stake': round(total_stake, 2), + 'total_pnl': round(total_pnl, 2), + 'overall_roi_percent': round(overall_roi, 4), + 'overall_hit_rate_percent': round(overall_hit_rate, 2), + 'clusters': [c.as_dict() for c in clusters], + 'hard_truths': [h.__dict__ for h in hard_truths], + } diff --git a/platform/backend/app/analytics/proof_of_yield.py b/platform/backend/app/analytics/proof_of_yield.py new file mode 100644 index 0000000..5d42da4 --- /dev/null +++ b/platform/backend/app/analytics/proof_of_yield.py @@ -0,0 +1,162 @@ +"""公開獲利帳本(Proof of Yield)模組。""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +from uuid import uuid4 +from datetime import datetime + + +def _as_float(value: Any, *, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +@dataclass(frozen=True) +class ProofYieldRecord: + recommendation_id: str + match_id: str + market_type: str + selection: str + stake: float + recommended_odds: float + closing_odds: float | None + is_win: bool + settled_at: str + clv_ratio: float | None + clv_percent: float | None + pnl: float + created_at: str + + +def compute_clv(recommended_odds: float, closing_odds: float) -> float: + """CLV = (推薦賠率 / 收盤賠率) - 1。""" + + if recommended_odds <= 0 or closing_odds <= 0: + raise ValueError('推薦賠率與收盤賠率都必須大於 0') + return (recommended_odds / closing_odds) - 1 + + +def compute_pnl(stake: float, is_win: bool, closing_odds: float | None) -> float: + if closing_odds is None or stake <= 0: + return 0.0 + return stake * (closing_odds - 1) if is_win else -stake + + +@dataclass(frozen=True) +class LedgerSummary: + total_recommendations: int + hit_count: int + win_rate_percent: float + total_stake: float + total_pnl: float + roi_percent: float + avg_clv_percent: float + + +class ProofOfYieldStore: + """本地持久化透明帳本(先以 JSON 做可追溯快啟動)。""" + + def __init__(self, file_path: str | None = None) -> None: + self.path = Path(file_path or 'data/proof_of_yield_ledger.json') + self.path.parent.mkdir(parents=True, exist_ok=True) + + def _load(self) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + raw = self.path.read_text(encoding='utf-8') + if not raw.strip(): + return [] + parsed = json.loads(raw) + if not isinstance(parsed, list): + return [] + return parsed + + def _save(self, rows: list[dict[str, Any]]) -> None: + self.path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8') + + def upsert_settlements(self, items: list[dict[str, Any]]) -> list[ProofYieldRecord]: + current = self._load() + idx = {row['recommendation_id']: i for i, row in enumerate(current)} + + for item in items: + recommendation_id = str(item.get('recommendation_id') or uuid4().hex) + stake = _as_float(item.get('stake'), default=100.0) + recommended_odds = _as_float(item.get('recommended_odds')) + closing_odds = item.get('closing_odds') + is_win = bool(item.get('is_win', False)) + closing = _as_float(closing_odds) if closing_odds is not None else None + + clv = None + clv_pct = None + if closing is not None and recommended_odds > 0: + clv = compute_clv(recommended_odds, closing) + clv_pct = clv * 100 + + pnl = compute_pnl(stake, is_win, closing) + record = { + 'recommendation_id': recommendation_id, + 'match_id': str(item.get('match_id', 'UNKNOWN')), + 'market_type': str(item.get('market_type', '1x2')), + 'selection': str(item.get('selection', 'home')), + 'stake': round(stake, 4), + 'recommended_odds': round(recommended_odds, 6), + 'closing_odds': round(closing, 6) if closing is not None else None, + 'is_win': is_win, + 'settled_at': str(item.get('settled_at') or datetime.utcnow().isoformat()), + 'clv_ratio': round(clv, 6) if clv is not None else None, + 'clv_percent': round(clv_pct, 4) if clv_pct is not None else None, + 'pnl': round(pnl, 4), + 'created_at': str(item.get('created_at') or datetime.utcnow().isoformat()), + } + + if recommendation_id in idx: + current[idx[recommendation_id]] = record + else: + current.append(record) + + self._save(current) + return [ProofYieldRecord(**row) for row in current] + + def query_ledger(self, *, limit: int = 200) -> list[ProofYieldRecord]: + rows = sorted(self._load(), key=lambda row: row.get('created_at', ''), reverse=True) + return [ProofYieldRecord(**row) for row in rows[:limit]] + + @staticmethod + def summarize(records: list[ProofYieldRecord]) -> LedgerSummary: + total = len(records) + if total == 0: + return LedgerSummary( + total_recommendations=0, + hit_count=0, + win_rate_percent=0.0, + total_stake=0.0, + total_pnl=0.0, + roi_percent=0.0, + avg_clv_percent=0.0, + ) + + hit = sum(1 for row in records if row.is_win) + total_stake = sum(row.stake for row in records) + total_pnl = sum(row.pnl for row in records) + clv_values = [row.clv_percent for row in records if row.clv_percent is not None] + avg_clv = sum(clv_values) / len(clv_values) if clv_values else 0.0 + roi = (total_pnl / total_stake) * 100 if total_stake > 0 else 0.0 + win_rate = (hit / total) * 100 + + return LedgerSummary( + total_recommendations=total, + hit_count=hit, + win_rate_percent=round(win_rate, 4), + total_stake=round(total_stake, 4), + total_pnl=round(total_pnl, 4), + roi_percent=round(roi, 4), + avg_clv_percent=round(avg_clv, 4), + ) + diff --git a/platform/backend/app/analytics/referee_analyzer.py b/platform/backend/app/analytics/referee_analyzer.py new file mode 100644 index 0000000..00642a0 --- /dev/null +++ b/platform/backend/app/analytics/referee_analyzer.py @@ -0,0 +1,53 @@ +"""裁判尺度分析器。""" + +from __future__ import annotations + +from typing import Dict + +from .ev_calculator import calculate_expected_value + + +def calculate_cards_ev( + referee_stats: dict, + match_tension_index: float, + bookmaker_card_line: float, + bookmaker_odds: float, +) -> dict[str, float | bool | str]: + """判斷裁判/對手張力對紅黃牌盤口的偏差與價值。 + + 依據裁判最近場次平均黃牌數與比賽張力(衝突度)估算 + 本場真實牌數,並與莊家 O/U 盤口比較。 + """ + + if bookmaker_odds <= 1: + raise ValueError('bookmaker_odds 必須大於 1') + if bookmaker_card_line <= 0: + raise ValueError('bookmaker_card_line 必須大於 0') + if not 0 <= match_tension_index <= 1: + raise ValueError('match_tension_index 必須在 0~1') + + avg_cards = float(referee_stats.get('avg_yellow_cards', 0.0) or 0.0) + penalties_per_game = float(referee_stats.get('penalties_per_game', 0.0) or 0.0) + + strictness_index = 20.0 + avg_cards * 1.9 + penalties_per_game * 2.5 + # 綜合壓力補正,將裁判嚴厲度與球隊/賽事張力轉為預測牌數。 + expected_cards = max( + 0.5, + strictness_index * (0.45 + 0.55 * max(0.0, min(match_tension_index, 1.0))), + ) + + true_prob = min(1.0, max(0.0, expected_cards / (bookmaker_card_line * 1.4))) + implied_prob = 1.0 / bookmaker_odds + edge = true_prob - implied_prob + + ev = calculate_expected_value(true_prob, bookmaker_odds, stake=100.0) + + return { + 'strictness_index': round(strictness_index, 3), + 'expected_total_cards': round(expected_cards, 3), + 'true_prob': round(true_prob, 4), + 'implied_prob': round(implied_prob, 4), + 'edge_percent': round(edge * 100, 3), + 'is_value_bet': ev['is_value_bet'], + 'ev_percentage': ev['ev_percentage'], + } diff --git a/platform/backend/app/analytics/referee_weather.py b/platform/backend/app/analytics/referee_weather.py new file mode 100644 index 0000000..fa89925 --- /dev/null +++ b/platform/backend/app/analytics/referee_weather.py @@ -0,0 +1,131 @@ +"""裁判與天候條件量化模組。""" + +from __future__ import annotations + +from dataclasses import dataclass + + +def calculate_referee_strictness_index( + avg_yellow_cards: float, + penalties_per_game: float, +) -> float: + """裁判嚴厲度指標(0-100)。""" + + yellow = max(0.0, min(avg_yellow_cards, 8.0)) / 8.0 + penalties = max(0.0, min(penalties_per_game, 2.5)) / 2.5 + return round(yellow * 55 + penalties * 45, 4) + + +def detect_cards_pressure_signal( + strictness_index: float, + cards_ou_line: float, +) -> bool: + """當裁判嚴格且莊家的卡數 O/U 開得偏低時,判斷為可能的逆風盤口。""" + + return strictness_index >= 80 and cards_ou_line <= 4.5 + + +def estimate_heat_index(ambient_temp_c: float, humidity_pct: float) -> float: + """簡化的 Heat Index(攝氏)。""" + + t = max(-60.0, min(60.0, ambient_temp_c)) + rh = max(0.0, min(100.0, humidity_pct)) + + hi = ( + -8.784695 + + 1.61139411 * t + + 2.338549 * rh + - 0.14611605 * t * rh + - 0.012308094 * t * t + - 0.016424828 * rh * rh + + 0.002211732 * t * t * rh + + 0.00072546 * t * rh * rh + - 0.000003582 * t * t * rh * rh + ) + return round(max(0.0, hi), 4) + + +@dataclass(frozen=True) +class MatchConditionSignal: + strictness_index: float + heat_index: float + cards_pressure_alert: bool + cards_ou_line: float + second_half_home_attack: float + second_half_away_attack: float + second_half_under_recommendation: bool + attacker_direction: str + + +def adjust_attack_for_heat_and_altitude( + base_attack: float, + *, + heat_index: float, + is_second_half: bool, + venue_altitude_meters: float | None = None, +) -> float: + """極端環境下的下半場攻擊效率修正。""" + + if not is_second_half: + return round(float(base_attack), 6) + + heat_penalty = max(0.0, heat_index - 28.0) / 120.0 # 每 1.2 度約降 1% + altitude_penalty = 0.0 + if venue_altitude_meters and venue_altitude_meters > 1500: + altitude_penalty = min(0.22, (venue_altitude_meters - 1500) / 8000.0) + + factor = max(0.6, 1 - heat_penalty - altitude_penalty) + return round(float(base_attack * factor), 6) + + +def evaluate_match_conditions( + *, + avg_yellow_cards: float, + penalties_per_game: float, + cards_ou_line: float, + temp_c: float, + humidity_pct: float, + venue_altitude_meters: int, + home_second_half_attack: float, + away_second_half_attack: float, +) -> MatchConditionSignal: + """整合裁判與天候對下半場盤口與進攻效率的衝擊。""" + + strictness_index = calculate_referee_strictness_index(avg_yellow_cards, penalties_per_game) + heat_index = estimate_heat_index(temp_c, humidity_pct) + + adjusted_home = adjust_attack_for_heat_and_altitude( + home_second_half_attack, + heat_index=heat_index, + is_second_half=True, + venue_altitude_meters=venue_altitude_meters, + ) + adjusted_away = adjust_attack_for_heat_and_altitude( + away_second_half_attack, + heat_index=heat_index, + is_second_half=True, + venue_altitude_meters=venue_altitude_meters, + ) + + cards_pressure = detect_cards_pressure_signal(strictness_index, cards_ou_line) + high_heat = heat_index >= 32.0 + heat_pressure_delta = home_second_half_attack + away_second_half_attack + second_half_under = high_heat and (adjusted_home + adjusted_away) <= heat_pressure_delta * 0.95 + + if adjusted_home > adjusted_away: + attacker_direction = '上場勢優勢偏向主隊' + elif adjusted_home < adjusted_away: + attacker_direction = '上場勢優勢偏向客隊' + else: + attacker_direction = '攻勢對稱' + + return MatchConditionSignal( + strictness_index=strictness_index, + heat_index=heat_index, + cards_pressure_alert=cards_pressure, + cards_ou_line=cards_ou_line, + second_half_home_attack=adjusted_home, + second_half_away_attack=adjusted_away, + second_half_under_recommendation=second_half_under, + attacker_direction=attacker_direction, + ) diff --git a/platform/backend/app/analytics/rlm.py b/platform/backend/app/analytics/rlm.py new file mode 100644 index 0000000..9e26e61 --- /dev/null +++ b/platform/backend/app/analytics/rlm.py @@ -0,0 +1,70 @@ +"""反向盤口移動(RLM)偵測模組。""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class ReverseLineMovementAlert: + match_id: str + market_type: str + selection: str + opening_odds: float + current_odds: float + ticket_pct: float + handle_pct: float + odds_change_pct: float + smart_money_to: str + is_triggered: bool + triggered_at: datetime + rationale: str + + +def evaluate_reverse_line_movement( + match_id: str, + market_type: str, + selection: str, + *, + opening_odds: float, + current_odds: float, + ticket_pct: float, + handle_pct: float, + ticket_threshold: float = 70.0, + odds_change_threshold: float = 0.05, +) -> ReverseLineMovementAlert: + """依條件判斷是否出現反向盤口。""" + + if opening_odds <= 0: + odds_pct = 0.0 + else: + odds_pct = round((current_odds - opening_odds) / opening_odds, 6) + + is_triggered = ( + ticket_pct > ticket_threshold + and odds_pct > odds_change_threshold + and handle_pct < ticket_pct + ) + + smart_money_to = selection if handle_pct > ticket_pct else '對側' + rationale = ( + f'散戶 {ticket_pct:.1f}% 追捧卻資金 {handle_pct:.1f}%,\n' + f'盤口由 {opening_odds:.2f} 上升到 {current_odds:.2f}' + ) + + return ReverseLineMovementAlert( + match_id=match_id, + market_type=market_type, + selection=selection, + opening_odds=opening_odds, + current_odds=current_odds, + ticket_pct=ticket_pct, + handle_pct=handle_pct, + odds_change_pct=round(odds_pct * 100, 4), + smart_money_to=smart_money_to, + is_triggered=is_triggered, + triggered_at=datetime.utcnow(), + rationale=rationale, + ) + diff --git a/platform/backend/app/analytics/sgp_engine.py b/platform/backend/app/analytics/sgp_engine.py new file mode 100644 index 0000000..62b9657 --- /dev/null +++ b/platform/backend/app/analytics/sgp_engine.py @@ -0,0 +1,71 @@ +from typing import List, Dict +import math + +class SGPCorrelationEngine: + """ + 同場串關 (Same Game Parlay) 關聯性與價值探測引擎 + """ + + @staticmethod + def calculate_joint_probability(prob_A: float, prob_B: float, correlation_coeff: float) -> float: + """ + 計算兩個事件的聯合機率 (考慮相關係數)。 + 使用簡化的二元正態分佈/Copula近似邏輯。 + :param prob_A: 事件 A 獨立發生的真實機率 + :param prob_B: 事件 B 獨立發生的真實機率 + :param correlation_coeff: 相關係數 (-1.0 到 1.0) + """ + if not (-1.0 <= correlation_coeff <= 1.0): + raise ValueError("相關係數必須介於 -1.0 與 1.0 之間") + + # 獨立發生的聯合機率 + independent_joint_prob = prob_A * prob_B + + # 理論最大與最小邊界 + max_joint_prob = min(prob_A, prob_B) + min_joint_prob = max(0.0, prob_A + prob_B - 1.0) + + if correlation_coeff == 0: + return independent_joint_prob + elif correlation_coeff > 0: + # 正相關:聯合機率向 max_joint_prob 靠攏 + return independent_joint_prob + correlation_coeff * (max_joint_prob - independent_joint_prob) + else: + # 負相關:聯合機率向 min_joint_prob 靠攏 + return independent_joint_prob + abs(correlation_coeff) * (min_joint_prob - independent_joint_prob) + + @staticmethod + def find_sgp_value(events: List[Dict], bookmaker_sgp_odds: float) -> Dict: + """ + 評估 SGP 注單是否具備正期望值。 + events 範例: [{'prob': 0.6}, {'prob': 0.4}] 且需自帶兩兩相關係數矩陣 (此處簡化為平均相關性) + """ + if len(events) < 2: + raise ValueError("SGP 必須至少包含兩個事件") + + # 假設外部特徵工程已經給出了這組事件的平均正相關係數 (例如 0.4) + # 實務上會透過更複雜的 Monte Carlo 計算,此為展示核心邏輯 + avg_correlation = events[0].get('correlation_with_others', 0.0) + + current_joint_prob = events[0]['prob'] + for i in range(1, len(events)): + current_joint_prob = SGPCorrelationEngine.calculate_joint_probability( + current_joint_prob, + events[i]['prob'], + avg_correlation + ) + + # 計算莊家隱含機率 + implied_prob = 1.0 / bookmaker_sgp_odds + + # 計算 EV + ev_percentage = (current_joint_prob * bookmaker_sgp_odds) - 1.0 + is_profitable = ev_percentage > 0.05 # 設定 5% 的 EV 門檻 + + return { + "true_joint_probability": round(current_joint_prob, 4), + "bookmaker_implied_probability": round(implied_prob, 4), + "ev_percentage": round(ev_percentage, 4), + "is_profitable_sgp": is_profitable, + "fair_odds": round(1.0 / current_joint_prob, 2) if current_joint_prob > 0 else 0 + } diff --git a/platform/backend/app/analytics/vig_remover.py b/platform/backend/app/analytics/vig_remover.py new file mode 100644 index 0000000..cbb76c7 --- /dev/null +++ b/platform/backend/app/analytics/vig_remover.py @@ -0,0 +1,135 @@ +"""莊家抽水(Vig)去除工具。""" + +from __future__ import annotations + +from typing import Callable, List, Sequence + +import numpy as np +from scipy.optimize import minimize_scalar + + +def calculate_overround(odds: Sequence[float]) -> float: + """計算莊家總水位(Overround)。 + + Overround = Σ(1 / odds_i)。 + 若結果 > 1 表示含有抽水。 + """ + + if not odds: + raise ValueError('odds 不可為空') + _odds = np.asarray(odds, dtype=float) + if np.any(_odds <= 1): + raise ValueError('賠率必須全部大於 1') + + return float(np.sum(1.0 / _odds)) + + +def remove_margin_basic(odds: Sequence[float]) -> List[float]: + """等比例剝除抽水。 + + 先轉換為 implied probability,再除以 overround 讓機率總和為 1。 + """ + + implied = np.array([1.0 / x for x in odds], dtype=float) + overround = implied.sum() + if overround <= 0: + raise ValueError('無效 odds,無法計算去水') + + true_probs = implied / overround + return [float(x) for x in true_probs] + + +def _shin_objective(z: float, observed: np.ndarray) -> float: + """Shin 模型中,透過 z 估計真實機率,使每個結果有一致修正。 + + 模型假設: + q_i(z) = max((p_i - z/(k-1)) / (1 - k/(k-1)*z), 1e-12) + 其中 q_i 為觀察值 implied probability,p_i 為解構後真實機率。 + 透過約束 Σp_i=1 搜尋最小平方誤差。 + """ + + k = observed.size + if not 0.0 <= z < 1: + return 1e9 + + denom = 1.0 - k / max(k - 1, 1) * z + if denom <= 0: + return 1e9 + + raw = (observed - z / max(k - 1, 1)) / denom + raw = np.clip(raw, 1e-12, None) + normalized = raw / raw.sum() + return float(np.sum((normalized - observed / observed.sum()) ** 2)) + + +def remove_margin_shin(odds: Sequence[float]) -> List[float]: + """Shin 方法去水。 + + 流程: + 1) 觀察賠率轉 implied probability。 + 2) 用單參數 z 做最小化,推回一組更接近無套利的真實機率。 + 3) 回傳機率正規化結果。 + """ + + odds_array = np.asarray(odds, dtype=float) + if odds_array.size == 0: + raise ValueError('odds 不可為空') + if np.any(odds_array <= 1): + raise ValueError('賠率必須全部大於 1') + + implied = 1.0 / odds_array + + if implied.size == 2: + # 二元市場可直接利用近似閉式解,穩定性較佳 + q1 = implied[0] / implied.sum() + q2 = implied[1] / implied.sum() + z = max(0.0, min(0.49, (q1 + q2 - 1.0) * 0.5)) + else: + # 多項市場,使用數值搜尋 + result = minimize_scalar( + _shin_objective, + args=(implied,), + bounds=(0.0, 0.49), + method='bounded', + ) + z = float(result.x if result.success else 0.0) + + k = implied.size + denom = 1.0 - k / max(k - 1, 1) * z + if denom <= 0: + return remove_margin_basic(odds) + + raw = (implied - z / max(k - 1, 1)) / denom + raw = np.clip(raw, 1e-12, None) + true_prob = raw / raw.sum() + return [float(x) for x in true_prob] + + +def prob_to_decimal_odds(true_probs: Sequence[float]) -> List[float]: + """真實機率轉換回無水賠率。 + + p 轉賠率公式:odds = 1 / p。 + """ + + probs = np.asarray(true_probs, dtype=float) + if np.any(probs <= 0): + raise ValueError('機率需大於 0') + + total = probs.sum() + if not np.isclose(total, 1.0, atol=1e-6): + probs = probs / total + return [round(float(1.0 / p), 4) for p in probs] + + +def compare_bookmaker_true_prob( + implied_odds: Sequence[float], + transform: Callable[[Sequence[float]], Sequence[float]] = remove_margin_shin, +) -> dict[str, list[float]]: + """比對原始賠率與去水後真實賠率,可直接提供前端展示。""" + + true_probs = transform(implied_odds) + return { + 'implied_prob': [float(1.0 / x) for x in implied_odds], + 'true_implied_prob': true_probs, + 'true_decimal_odds': prob_to_decimal_odds(true_probs), + } diff --git a/platform/backend/app/api/affiliate.py b/platform/backend/app/api/affiliate.py new file mode 100644 index 0000000..ff7f09e --- /dev/null +++ b/platform/backend/app/api/affiliate.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import RedirectResponse +import uuid + +router = APIRouter() + +# 模擬的 affiliate_clicks 記錄,實務上應寫入 TimescaleDB / PostgreSQL +# affiliate_clicks_db = [] + +@router.get("/api/v1/go/{bookmaker_id}") +async def affiliate_redirect(bookmaker_id: str, request: Request): + """ + 動態聯盟行銷與防廣告攔截引擎 + - Server-side redirect + - 紀錄點擊以計算 CR + """ + + # 模擬博彩公司對應表與追蹤碼 + bookmakers = { + "bet365": "https://www.bet365.com/?affiliate=QUANT2026", + "pinnacle": "https://www.pinnacle.com/?ref=QUANT2026", + "draftkings": "https://www.draftkings.com/?track=QUANT2026" + } + + if bookmaker_id not in bookmakers: + raise HTTPException(status_code=404, detail="Bookmaker not found") + + target_url = bookmakers[bookmaker_id] + + # 記錄點擊資料 (User-Agent, IP, Timestamp, etc) + click_data = { + "click_id": str(uuid.uuid4()), + "bookmaker_id": bookmaker_id, + "user_agent": request.headers.get("user-agent", "unknown"), + "client_ip": request.client.host if request.client else "unknown" + } + + # affiliate_clicks_db.append(click_data) + # print(f"Logged affiliate click: {click_data}") + + return RedirectResponse(url=target_url, status_code=302) diff --git a/platform/backend/app/api/daily_card_generator.py b/platform/backend/app/api/daily_card_generator.py new file mode 100644 index 0000000..f0d43b1 --- /dev/null +++ b/platform/backend/app/api/daily_card_generator.py @@ -0,0 +1,49 @@ +from datetime import date +from typing import List, Dict + +class DailyCardGenerator: + """ + 投資長級別的每日智能注單生成引擎 (Daily Smart Card) + """ + + def __init__(self, db_session): + self.db = db_session + + def generate_daily_card(self, target_date: date) -> Dict: + """ + 掃描當日賽事,並將高價值投注分類打包 + """ + # 模擬從資料庫與 EV 引擎取得的當日高價值清單 + # 實務上會 join `matches` 與 `odds_history` 並即時套用 ev_calculator + raw_value_bets = self._fetch_todays_value_bets(target_date) + + card = { + "date": target_date.isoformat(), + "briefing": "AI 賽況總評:淘汰賽階段防守強度升級,系統偵測到大量下半場小球的定價錯誤,建議重倉穩健單關,避開受讓盤。", + "total_suggested_units": 0.0, + "recommendations": { + "SAFE_SINGLE": [], # 穩健單關 (高勝率,正 EV) + "HIGH_RISK_SINGLE": [], # 高賠搏冷 (低勝率,超高 EV) + "SGP_LOTTERY": [] # 同場爆擊 (SGP) + } + } + + for bet in raw_value_bets: + if bet['true_prob'] > 0.55 and bet['ev_percentage'] > 0.03: + bet['suggested_units'] = 1.5 + card['recommendations']['SAFE_SINGLE'].append(bet) + card['total_suggested_units'] += 1.5 + + elif bet['true_prob'] < 0.35 and bet['ev_percentage'] > 0.08: + bet['suggested_units'] = 0.5 + card['recommendations']['HIGH_RISK_SINGLE'].append(bet) + card['total_suggested_units'] += 0.5 + + return card + + def _fetch_todays_value_bets(self, target_date: date) -> List[Dict]: + # 模擬資料 + return [ + {"match": "USA vs ENG", "selection": "Under 2.5", "odds": 1.95, "true_prob": 0.58, "ev_percentage": 0.131}, + {"match": "MEX vs ARG", "selection": "MEX Win", "odds": 4.20, "true_prob": 0.28, "ev_percentage": 0.176} + ] diff --git a/platform/backend/app/api/telegram_webhook.py b/platform/backend/app/api/telegram_webhook.py new file mode 100644 index 0000000..3a9cc7d --- /dev/null +++ b/platform/backend/app/api/telegram_webhook.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Request +from pydantic import BaseModel +import time + +router = APIRouter() + +class TelegramUpdate(BaseModel): + update_id: int + message: dict = None + +@router.post("/api/v1/telegram/webhook") +async def telegram_webhook(update: TelegramUpdate): + """ + VIP 私董會互動式 Telegram 機器人 Webhook + """ + if not update.message or "text" not in update.message: + return {"status": "ok"} + + text = update.message["text"].strip() + chat_id = update.message["chat"]["id"] + + # 模擬 !sgp [主隊] [客隊] + if text.startswith("!sgp"): + parts = text.split() + if len(parts) == 3: + home, away = parts[1], parts[2] + # 這裡應該呼叫 SGPCorrelationEngine + response_text = f"📊 [SGP 蒙地卡羅運算完成]\n賽事: {home} vs {away}\n推薦串關: {home} 勝 + 總進球數大於 2.5\nEV: +6.5%\n機率: 45%" + else: + response_text = "❌ 指令錯誤,正確格式: !sgp [主隊] [客隊]" + + # 模擬 !ev + elif text.startswith("!ev"): + # 這裡應該從 EV 引擎抓取 Top 3 + response_text = "🔥 [全市場 Top 3 正期望值盤口]\n1. USA vs ENG - Under 2.5 (EV: +13.1%)\n2. MEX vs ARG - MEX Win (EV: +17.6%)\n3. FRA vs BRA - FRA Win (EV: +6.5%)" + + else: + response_text = "未知指令。可用指令: !sgp [主隊] [客隊], !ev" + + # 實務上這裡會呼叫 Telegram Bot API 傳送訊息 + # send_message_to_telegram(chat_id, response_text) + print(f"Telegram Bot Reply to {chat_id}: {response_text}") + + return {"status": "ok"} diff --git a/platform/backend/app/db/__init__.py b/platform/backend/app/db/__init__.py new file mode 100644 index 0000000..63d6f75 --- /dev/null +++ b/platform/backend/app/db/__init__.py @@ -0,0 +1,19 @@ +"""資料模型套件。""" + +from .base import Base, get_engine, get_session_factory, SessionFactory +from .models import Bookmaker, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue + +__all__ = [ + 'Base', + 'Bookmaker', + 'Match', + 'MatchStatus', + 'OddsHistory', + 'SmartMoneyFlow', + 'Team', + 'Venue', + 'get_engine', + 'get_session_factory', + 'SessionFactory', +] + diff --git a/platform/backend/app/db/base.py b/platform/backend/app/db/base.py new file mode 100644 index 0000000..e23c8fd --- /dev/null +++ b/platform/backend/app/db/base.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +import os + + +class Base(DeclarativeBase): + """Project ORM base model.""" + + +DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026') + + +def get_engine(database_url: str = DATABASE_URL): + """Create asynchronous SQLAlchemy engine for production use.""" + + return create_async_engine(database_url, echo=False, pool_pre_ping=True) + + +def get_session_factory(database_url: str = DATABASE_URL): + """Create session factory for async query operations.""" + + engine = get_engine(database_url) + return async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) + + +SessionFactory = get_session_factory() diff --git a/platform/backend/app/db/models.py b/platform/backend/app/db/models.py new file mode 100644 index 0000000..f3d55c0 --- /dev/null +++ b/platform/backend/app/db/models.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + + +class MatchStatus(str, Enum): + PRE_MATCH = 'pre-match' + IN_PLAY = 'in-play' + FINISHED = 'finished' + + +class Venue(Base): + """球場主資料:海拔與時區是 2026 世界盃關鍵參數。""" + + __tablename__ = 'venues' + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + city: Mapped[str] = mapped_column(String(120), nullable=False) + country: Mapped[str] = mapped_column(String(120), nullable=False) + altitude_meters: Mapped[int | None] = mapped_column(Integer, nullable=True) + timezone: Mapped[str] = mapped_column(String(80), nullable=False) + + matches: Mapped[list[Match]] = relationship('Match', back_populates='venue', lazy='raise') + + +class Team(Base): + """球隊主表,保留排名與 Elo 給量化模型做能力修正。""" + + __tablename__ = 'teams' + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(String(140), nullable=False, unique=True) + fifa_rank: Mapped[int | None] = mapped_column(Integer, nullable=True) + current_elo_rating: Mapped[float | None] = mapped_column(Float, nullable=True) + group_name: Mapped[str | None] = mapped_column(String(10), nullable=True) + + home_matches: Mapped[list[Match]] = relationship( + 'Match', + foreign_keys='Match.home_team_id', + back_populates='home_team', + ) + away_matches: Mapped[list[Match]] = relationship( + 'Match', + foreign_keys='Match.away_team_id', + back_populates='away_team', + ) + + +class Bookmaker(Base): + """莊家主檔。""" + + __tablename__ = 'bookmakers' + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True) + + odds_rows: Mapped[list[OddsHistory]] = relationship('OddsHistory', back_populates='bookmaker') + + +class Match(Base): + """賽事基本結構,儲存 UTC 時間、場地與賽前 xG。""" + + __tablename__ = 'matches' + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + home_team_id: Mapped[str] = mapped_column(ForeignKey('teams.id'), nullable=False) + away_team_id: Mapped[str] = mapped_column(ForeignKey('teams.id'), nullable=False) + venue_id: Mapped[str] = mapped_column(ForeignKey('venues.id'), nullable=False) + + match_time_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + status: Mapped[MatchStatus] = mapped_column( + SAEnum(MatchStatus, name='match_status', native_enum=False), + default=MatchStatus.PRE_MATCH, + ) + home_xg: Mapped[float | None] = mapped_column(Float, nullable=True) + away_xg: Mapped[float | None] = mapped_column(Float, nullable=True) + + home_team: Mapped[Team] = relationship('Team', foreign_keys=[home_team_id], back_populates='home_matches') + away_team: Mapped[Team] = relationship('Team', foreign_keys=[away_team_id], back_populates='away_matches') + venue: Mapped[Venue] = relationship('Venue', back_populates='matches') + odds_history: Mapped[list[OddsHistory]] = relationship('OddsHistory', back_populates='match') + recommendations: Mapped[list['ValueBetRecommendation']] = relationship( + 'ValueBetRecommendation', + back_populates='match', + cascade='all, delete-orphan', + ) + + +class OddsHistory(Base): + """時間序列賠率表(待轉為 TimescaleDB Hypertable)。""" + + __tablename__ = 'odds_history' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True) + bookmaker_id: Mapped[str] = mapped_column(ForeignKey('bookmakers.id'), nullable=False, index=True) + market_type: Mapped[str] = mapped_column(String(30), nullable=False) + selection: Mapped[str] = mapped_column(String(30), nullable=False) + decimal_odds: Mapped[float] = mapped_column(Float, nullable=False) + implied_probability: Mapped[float] = mapped_column(Float, nullable=False) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + match: Mapped[Match] = relationship('Match', back_populates='odds_history') + bookmaker: Mapped[Bookmaker] = relationship('Bookmaker', back_populates='odds_rows') + + +class SmartMoneyFlow(Base): + """聰明錢流向快照表。""" + + __tablename__ = 'smart_money_flow' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True) + market_type: Mapped[str] = mapped_column(String(30), nullable=False) + selection: Mapped[str] = mapped_column(String(30), nullable=False) + ticket_pct: Mapped[float] = mapped_column(Float, nullable=False) + handle_pct: Mapped[float] = mapped_column(Float, nullable=False) + sharp_indicator: Mapped[bool] = mapped_column(Boolean, nullable=False) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + +class ValueBetRecommendation(Base): + """可驗證獲利帳本紀錄(公開透明)。""" + + __tablename__ = 'value_bet_recommendations' + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True) + market_type: Mapped[str] = mapped_column(String(30), nullable=False) + selection: Mapped[str] = mapped_column(String(30), nullable=False) + stake: Mapped[float] = mapped_column(Float, nullable=False) + recommended_odds: Mapped[float] = mapped_column(Float, nullable=False) + closing_odds: Mapped[float] = mapped_column(Float, nullable=True) + is_win: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + settled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + clv_ratio: Mapped[float] = mapped_column(Float, nullable=True) + pnl: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + note: Mapped[str | None] = mapped_column(String(240), nullable=True) + + match: Mapped[Match] = relationship('Match', back_populates='recommendations') + + +class AffiliateBookmaker(Base): + """聯盟行銷博彩公司追蹤碼設定。""" + + __tablename__ = 'affiliate_bookmakers' + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True) + tracking_url: Mapped[str] = mapped_column(String(512), nullable=False) + commission_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + + +class AffiliateClick(Base): + """聯盟行銷跳轉點擊紀錄(防廣告攔截)。""" + + __tablename__ = 'affiliate_clicks' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + bookmaker_id: Mapped[str] = mapped_column(ForeignKey('affiliate_bookmakers.id'), nullable=False, index=True) + user_ip_hash: Mapped[str] = mapped_column(String(128), nullable=False) + user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + referrer: Mapped[str | None] = mapped_column(String(512), nullable=True) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True) + converted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +class UserProfile(Base): + """量化大神排行榜與跟單系統的用戶資料。""" + + __tablename__ = 'user_profiles' + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True) + clv_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + roi_30d: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + sharp_rating: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +class CopyBet(Base): + """一鍵跟單交易紀錄。""" + + __tablename__ = 'copy_bets' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + follower_id: Mapped[str] = mapped_column(ForeignKey('user_profiles.id'), nullable=False, index=True) + leader_id: Mapped[str] = mapped_column(ForeignKey('user_profiles.id'), nullable=False, index=True) + recommendation_id: Mapped[str] = mapped_column(ForeignKey('value_bet_recommendations.id'), nullable=False) + follower_stake: Mapped[float] = mapped_column(Float, nullable=False) + copied_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) diff --git a/platform/backend/app/ingestion/__init__.py b/platform/backend/app/ingestion/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/platform/backend/app/ingestion/__init__.py @@ -0,0 +1 @@ + diff --git a/platform/backend/app/ingestion/cache.py b/platform/backend/app/ingestion/cache.py new file mode 100644 index 0000000..76963c3 --- /dev/null +++ b/platform/backend/app/ingestion/cache.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any, Dict, Mapping + +from redis.asyncio import Redis + + +ARBITRAGE_LUA = r''' +local odds_json = ARGV[1] +local payload = cjson.decode(odds_json) + +local grouped = {} +for _, row in ipairs(payload) do + local market = row.market_type + local selection = row.selection + local odds = tonumber(row.decimal_odds) + if market and selection and odds and odds > 0 then + if grouped[market] == nil then + grouped[market] = {} + end + if grouped[market][selection] == nil or odds > grouped[market][selection] then + grouped[market][selection] = odds + end + end +end + +local out = {} +for market, selections in pairs(grouped) do + local prob_sum = 0 + local count = 0 + for _, odds in pairs(selections) do + prob_sum = prob_sum + (1 / odds) + count = count + 1 + end + if count > 1 then + out[market] = { + has_arbitrage = (prob_sum < 1), + implied_total_probability = prob_sum, + edge = math.max(1 - prob_sum, 0), + best_odds = selections, + } + end +end + +return cjson.encode(out) +''' + + +@dataclass(slots=True) +class MatchState: + """賽中 Hash 快照欄位。""" + + home_score: int + away_score: int + possession_home_pct: float + possession_away_pct: float + red_cards_home: int + red_cards_away: int + + +class MatchCacheManager: + """賽事 Redis 快取層。 + + - live:{match_id}:odds 存 JSON,即時賠率 + - live:{match_id}:state 存 Hash,包含比分、控球率、紅牌數 + """ + + def __init__(self, redis: Redis) -> None: + self.redis = redis + self._lua_sha: str | None = None + + async def _ensure_lua(self) -> str: + if self._lua_sha is None: + self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA) + return self._lua_sha + + async def set_match_odds( + self, + match_id: str, + payload: list[dict[str, Any]], + *, + ttl_seconds: int = 45, + finished: bool = False, + ) -> None: + key = f'live:{match_id}:odds' + value = json.dumps(payload, ensure_ascii=False) + ttl = 7200 if finished else ttl_seconds + await self.redis.set(name=key, value=value, ex=ttl) + + async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]: + key = f'live:{match_id}:odds' + raw = await self.redis.get(key) + if not raw: + return [] + if isinstance(raw, bytes): + raw = raw.decode() + return json.loads(raw) + + async def set_match_state( + self, + match_id: str, + state: MatchState | Mapping[str, Any], + *, + ttl_seconds: int = 7200, + ) -> None: + key = f'live:{match_id}:state' + mapping = { + 'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score, + 'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score, + 'possession_home_pct': state['possession_home_pct'] if isinstance(state, Mapping) else state.possession_home_pct, + 'possession_away_pct': state['possession_away_pct'] if isinstance(state, Mapping) else state.possession_away_pct, + 'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home, + 'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away, + } + await self.redis.hset(name=key, mapping=mapping) + await self.redis.expire(key, ttl_seconds) + + async def get_match_state(self, match_id: str) -> dict[str, str] | None: + key = f'live:{match_id}:state' + result = await self.redis.hgetall(key) + return {str(k): str(v) for k, v in result.items()} if result else None + + async def calculate_arbitrage_for_match(self, match_id: str) -> dict[str, Any]: + odds = await self.get_match_odds(match_id) + if not odds: + return {} + + sha = await self._ensure_lua() + result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False)) + if isinstance(result, bytes): + result = result.decode() + if isinstance(result, str): + return json.loads(result) + return result diff --git a/platform/backend/app/ingestion/worker.py b/platform/backend/app/ingestion/worker.py new file mode 100644 index 0000000..0934b1c --- /dev/null +++ b/platform/backend/app/ingestion/worker.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import json +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Mapping + +import aiohttp + +from .cache import MatchCacheManager + + +TEAM_ALIAS_MAP = { + 'USA': 'USMNT', + 'United States': 'USMNT', + 'United States of America': 'USMNT', + 'USMNT': 'USMNT', +} + + +@dataclass(frozen=True) +class SourceOdds: + match_id: str + home_team: str + away_team: str + market_type: str + selection: str + decimal_odds: float + bookmaker: str + status: str = 'in-play' + + +def normalize_team_name(raw_name: str) -> str: + """對齊來自不同博彩商的球隊名稱,返回標準化內部 ID。""" + + normalized = raw_name.strip() + return TEAM_ALIAS_MAP.get(normalized, normalized) + + +class OddsIngestionWorker: + """非同步抓取賽事賠率與推入 Redis 快取的 Worker。""" + + def __init__(self, session: aiohttp.ClientSession, endpoint: str, api_key: str) -> None: + self.session = session + self.endpoint = endpoint + self.api_key = api_key + + async def _request_with_backoff(self, url: str, *, max_attempts: int = 5) -> Mapping[str, Any]: + delay = 0.5 + attempts = 0 + while True: + attempts += 1 + try: + async with self.session.get(url, timeout=20) as resp: + if resp.status == 429: + if attempts >= max_attempts: + text = await resp.text() + raise RuntimeError(f'HTTP 429 Too Many Requests: {text}') + await asyncio.sleep(delay) + delay *= 2 + continue + if resp.status >= 500: + if attempts >= max_attempts: + resp.raise_for_status() + await asyncio.sleep(delay) + delay *= 2 + continue + resp.raise_for_status() + return await resp.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + if attempts >= max_attempts: + raise RuntimeError(f'HTTP request failed: {exc!s}') from exc + await asyncio.sleep(delay) + delay *= 2 + + async def fetch_latest_matches(self) -> list[SourceOdds]: + params = {'api_key': self.api_key} + url = f'{self.endpoint}/v1/odds' + payload = await self._request_with_backoff(url) + items = payload.get('data', []) if isinstance(payload, Mapping) else [] + + normalized: list[SourceOdds] = [] + for row in items: + try: + raw_home = row['home_team'] + raw_away = row['away_team'] + normalized.append( + SourceOdds( + match_id=str(row['match_id']), + home_team=normalize_team_name(str(raw_home)), + away_team=normalize_team_name(str(raw_away)), + market_type=str(row['market_type']), + selection=str(row['selection']), + decimal_odds=float(row['odds']), + bookmaker=str(row.get('bookmaker', 'unknown')), + status=str(row.get('status', 'in-play')), + ), + ) + except (KeyError, TypeError, ValueError): + continue + + return normalized + + async def sync_to_cache( + self, + cache: MatchCacheManager, + *, + ttl_seconds: int = 45, + ) -> dict[str, int]: + """抓取賽事即時賠率並更新 Redis 快取。""" + + rows = await self.fetch_latest_matches() + payload_by_match: dict[str, list[dict[str, Any]]] = defaultdict(list) + + for row in rows: + payload_by_match[row.match_id].append( + { + 'match_id': row.match_id, + 'home_team': row.home_team, + 'away_team': row.away_team, + 'market_type': row.market_type, + 'selection': row.selection, + 'decimal_odds': row.decimal_odds, + 'bookmaker': row.bookmaker, + 'status': row.status, + }, + ) + + for match_id, rows_payload in payload_by_match.items(): + finished = any(item['status'] == 'finished' for item in rows_payload) + await cache.set_match_odds(match_id, rows_payload, ttl_seconds=ttl_seconds, finished=finished) + + return {match_id: len(payload_rows) for match_id, payload_rows in payload_by_match.items()} + + async def run_once( + self, + cache: MatchCacheManager, + *, + ttl_seconds: int = 45, + ) -> dict[str, int]: + """單次輪詢流程(可給排程器或事件輪詢器呼叫)。""" + + return await self.sync_to_cache(cache, ttl_seconds=ttl_seconds) + + +def to_cache_payload(rows: list[SourceOdds]) -> list[dict[str, Any]]: + """將來源資料轉為 Redis 快取可存取結構。""" + + return [ + { + 'match_id': row.match_id, + 'home_team': row.home_team, + 'away_team': row.away_team, + 'market_type': row.market_type, + 'selection': row.selection, + 'decimal_odds': row.decimal_odds, + 'bookmaker': row.bookmaker, + 'status': row.status, + } + for row in rows + ] + + +def serialize_error(error: Exception) -> str: + """錯誤訊息格式化,供上層日誌與警報系統使用。""" + + return json.dumps({'error': str(error), 'type': error.__class__.__name__}) diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py new file mode 100644 index 0000000..f6e4693 --- /dev/null +++ b/platform/backend/app/main.py @@ -0,0 +1,1473 @@ +import asyncio +import json +import logging +import os +import contextlib +from collections import defaultdict +from datetime import datetime +from typing import Any, Mapping +from uuid import uuid4 + +from sqlalchemy import asc, desc, select, func +from sqlalchemy.orm import aliased +from sqlalchemy.exc import SQLAlchemyError + + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field +from redis.asyncio import Redis +from .db.base import SessionFactory +from .db.models import Bookmaker, Match, OddsHistory, SmartMoneyFlow, Team, Venue + +from .analytics import ( + BacktestTradeRecord, + KellyResult, + PropMetric, + StrategyFilter, + EnsembleModelArtifact, + FEATURE_COLUMNS, + LedgerSummary, + MatchConditionSignal, + ProofOfYieldStore, + ProofYieldRecord, + build_default_ensemble_artifact, + calculate_model_edges, + evaluate_match_conditions, + evaluate_reverse_line_movement, + model_predict_probabilities, + normalize_feature_payload, + train_match_outcome_ensemble, + calculate_kelly_fraction, + evaluate_top_edge, + filter_trades, + PlayerPropsProfile, + run_flat_stake_backtest, + simulate_player_prop_probability, + PoissonMatchPredictor, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('fifa2026-ws') + + +class ConnectionManager: + def __init__(self) -> None: + self.match_rooms: dict[str, set[WebSocket]] = defaultdict(set) + self.all_connections: set[WebSocket] = set() + + async def connect(self, websocket: WebSocket, match_id: str) -> None: + await websocket.accept() + self.match_rooms[match_id].add(websocket) + self.all_connections.add(websocket) + + async def disconnect(self, websocket: WebSocket, match_id: str) -> None: + self.match_rooms[match_id].discard(websocket) + self.all_connections.discard(websocket) + if not self.match_rooms[match_id]: + self.match_rooms.pop(match_id, None) + + async def broadcast_to_match(self, match_id: str, message: str) -> None: + disconnected: list[WebSocket] = [] + for ws in list(self.match_rooms.get(match_id, set())): + try: + await ws.send_text(message) + except Exception: + disconnected.append(ws) + for ws in disconnected: + await self.disconnect(ws, match_id) + + async def broadcast_to_all(self, message: str) -> None: + disconnected: list[WebSocket] = [] + for ws in list(self.all_connections): + try: + await ws.send_text(message) + except Exception: + disconnected.append(ws) + for ws in disconnected: + for match_id, sockets in list(self.match_rooms.items()): + if ws in sockets: + await self.disconnect(ws, match_id) + + +app = FastAPI(title='2026 FIFA Real-Time Bus', version='1.0.0') +manager = ConnectionManager() +ML_MODELS: dict[str, EnsembleModelArtifact] = {'default': build_default_ensemble_artifact()} +PROOF_OF_YIELD_STORE = ProofOfYieldStore() + +REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') +WS_REDIS_CHANNELS = ('channel:live_odds', 'channel:match_events') + + +class PlayerPropsRequest(BaseModel): + player_id: str = Field(..., min_length=1, description='球員唯一識別碼') + player_name: str | None = None + metric: PropMetric + baseline_mean: float = Field(..., gt=0) + line: float = Field(..., gt=0) + match_minutes: int = Field(default=90, ge=1, le=130) + team_attack_factor: float = Field(default=1.0, ge=0.2, le=3.0) + opponent_defence_factor: float = Field(default=1.0, ge=0.2, le=3.0) + weather_fatigue_factor: float = Field(default=1.0, ge=0.2, le=2.0) + bookmaker_over_odds: float | None = Field(default=None, gt=1) + simulations: int = Field(default=10000, ge=100, le=200000) + + +class PlayerPropsResponse(BaseModel): + metric: str + line: float + over_probability: float + under_probability: float + expected_count: float + p5: float + p50: float + p95: float + simulation_runs: int + edge: float | None = None + top_edge: bool = False + bookmaker_over_odds: float | None = None + implied_prob: float | None = None + + +class KellyRequest(BaseModel): + odds: float = Field(..., gt=1) + true_prob: float = Field(..., ge=0, le=1) + bankroll: float = Field(..., gt=0) + fractional_kelly_factor: float = Field(default=1.0, ge=0, le=5) + risk_tolerance_factor: float = Field(default=1.0, ge=0, le=2) + + +class KellyResponse(BaseModel): + odds: float + true_prob: float + bankroll: float + raw_kelly_fraction: float + fractional_kelly_factor: float + risk_tolerance_factor: float + recommended_fraction: float + recommended_stake: float + + +class BacktestTrade(BaseModel): + trade_id: str = Field(..., min_length=1) + settled_at: datetime + odds: float = Field(..., gt=1) + is_win: bool + stake: float = Field(default=100.0, gt=0) + altitude_meters: int | None = None + handicap: float | None = None + weather: str | None = None + recent_form_win_rate: float | None = Field(default=None, ge=0, le=1) + market_type: str = '1x2' + selection: str = 'home' + + +class BacktestFilter(BaseModel): + weather: str | None = None + altitude_min_meters: int | None = None + altitude_max_meters: int | None = None + handicap_min: float | None = None + handicap_max: float | None = None + recent_win_rate_min: float | None = Field(default=None, ge=0, le=1) + recent_win_rate_max: float | None = Field(default=None, ge=0, le=1) + market_types: list[str] | None = None + start_at: datetime | None = None + end_at: datetime | None = None + + +class BacktestRequest(BaseModel): + initial_capital: float = Field(default=10000, gt=0) + strategy: BacktestFilter = Field(default_factory=BacktestFilter) + historical_trades: list[BacktestTrade] + + +class BacktestPoint(BaseModel): + ts: str + capital: float + + +class BacktestResponse(BaseModel): + matched: int + total: int + hit_count: int + win_rate: float + final_capital: float + net_profit: float + roi_percent: float + max_drawdown_percent: float + equity_curve: list[BacktestPoint] + + +class MlTrainingRow(BaseModel): + home_rest_days: float = Field(..., description='主隊休息天數') + away_rest_days: float = Field(..., description='客隊休息天數') + home_travel_distance_km: float = Field(..., description='主隊旅行距離(km)') + away_travel_distance_km: float = Field(..., description='客隊旅行距離(km)') + recent_5_xg_home: float = Field(..., description='主隊近5場 xG') + recent_5_xg_away: float = Field(..., description='客隊近5場 xG') + match_result: str = Field(..., description='home / draw / away') + + +class MlTrainRequest(BaseModel): + model_id: str | None = None + rows: list[MlTrainingRow] + + +class MlTrainResponse(BaseModel): + model_id: str + status: str + training_size: int + is_fallback: bool + accuracy: float | None + + +class MlEdgeRequest(BaseModel): + model_id: str | None = 'default' + match_id: str = Field(...) + home_rest_days: float + away_rest_days: float + home_travel_distance_km: float + away_travel_distance_km: float + recent_5_xg_home: float + recent_5_xg_away: float + home_implied_odds: float = Field(..., gt=1) + draw_implied_odds: float = Field(..., gt=1) + away_implied_odds: float = Field(..., gt=1) + + +class MlModelEdge(BaseModel): + model_prob: float + implied_prob: float + edge: float + strong_buy: bool + + +class MlEdgeResponse(BaseModel): + match_id: str + model_id: str + is_fallback_model: bool + model_probs: dict[str, float] + edges: dict[str, MlModelEdge] + strong_buy: bool + strongest_outcome: str + strongest_edge_percent: float + feature_columns: list[str] + training_size: int + + +class MatchConditionRequest(BaseModel): + match_id: str = Field(...) + avg_yellow_cards: float + penalties_per_game: float + cards_ou_line: float + temp_c: float + humidity_pct: float + venue_altitude_meters: int = Field(..., ge=0) + home_second_half_attack: float = Field(..., gt=0) + away_second_half_attack: float = Field(..., gt=0) + + +class MatchConditionResponse(BaseModel): + match_id: str + strictness_index: float + heat_index: float + cards_pressure_alert: bool + second_half_home_attack: float + second_half_away_attack: float + second_half_under_recommendation: bool + attacker_direction: str + + +class RlmRequest(BaseModel): + match_id: str = Field(...) + market_type: str = '1x2' + selection: str = 'home' + ticket_threshold: float = Field(default=80, ge=0, le=100) + odds_change_threshold: float = Field(default=0.05, ge=0, le=1) + + +class RlmAlertResponse(BaseModel): + match_id: str + market_type: str + selection: str + opening_odds: float + current_odds: float + ticket_pct: float + handle_pct: float + odds_change_pct: float + smart_money_to: str + is_triggered: bool + rationale: str + + +class RlmResponse(BaseModel): + alerts: list[RlmAlertResponse] + total: int + + +class ProofOfYieldSettleItem(BaseModel): + recommendation_id: str | None = None + match_id: str + market_type: str = '1x2' + selection: str = 'home' + stake: float = Field(..., gt=0) + recommended_odds: float = Field(..., gt=1) + closing_odds: float = Field(..., gt=0) + is_win: bool + settled_at: str | None = None + + +class ProofOfYieldSettleRequest(BaseModel): + items: list[ProofOfYieldSettleItem] + + +class ProofOfYieldRecordResponse(BaseModel): + recommendation_id: str + match_id: str + market_type: str + selection: str + stake: float + recommended_odds: float + closing_odds: float + is_win: bool + settled_at: str + clv_percent: float | None + pnl: float + + +class ProofOfYieldSummaryResponse(BaseModel): + total_recommendations: int + hit_count: int + win_rate_percent: float + total_stake: float + total_pnl: float + roi_percent: float + avg_clv_percent: float + + +class ProofOfYieldLedgerResponse(BaseModel): + summary: ProofOfYieldSummaryResponse + records: list[ProofOfYieldRecordResponse] + + +class UserBet(BaseModel): + market_type: str = Field(default='unknown', min_length=1) + parlay_type: str | None = None + odds: float | None = Field(default=None, gt=1) + stake: float = Field(..., gt=0) + recommended_odds: float | None = Field(default=None, gt=1) + closing_odds: float | None = Field(default=None, gt=0) + is_settled: bool = True + is_win: bool = False + match_stage: str | None = None + stage: str | None = None + + +class PortfolioLeaksRequest(BaseModel): + user_bets: list[UserBet] + + +class PortfolioLeakCluster(BaseModel): + market_type: str + bet_type: str + odds_bucket: str + match_stage: str + bet_count: int + total_stake: float + closed_count: int + win_count: int + total_pnl: float + avg_clv_percent: float + roi_percent: float + hit_rate_percent: float + status: str + + +class PortfolioHardTruth(BaseModel): + title: str + message: str + cluster: dict[str, str | int | float] + + +class PortfolioLeaksResponse(BaseModel): + total_bet_count: int + settled_bet_count: int + total_stake: float + total_pnl: float + overall_roi_percent: float + overall_hit_rate_percent: float + clusters: list[PortfolioLeakCluster] + hard_truths: list[PortfolioHardTruth] + + +class HedgeRequest(BaseModel): + original_stake: float = Field(..., gt=0) + parlay_total_odds: float = Field(..., gt=1) + final_leg_hedge_odds: float = Field(..., gt=1) + + +class HedgeResponse(BaseModel): + hedge_stake: float + locked_profit: float + parlay_net_after_hedge_if_win: float + hedge_net_if_win: float + + +class DailyCardLeg(BaseModel): + match_id: str + selection: str + odds: float = Field(..., gt=1) + + +class DailyCardItem(BaseModel): + match_id: str + match_label: str + market_type: str + selection: str + target_odds: float = Field(..., gt=1) + win_prob: float + ev_percent: float + stake_units: float = Field(..., ge=0) + recommendation: str + rationale: str + legs: list[DailyCardLeg] | None = None + + +class DailyCardResponse(BaseModel): + date: str + total_daily_unit_recommendation: float + summary: str + safe_singles: list[DailyCardItem] + high_risk_singles: list[DailyCardItem] + safe_parlays: list[DailyCardItem] + sgp_lotteries: list[DailyCardItem] + matched_matches: int + stage_distribution: dict[str, int] + + +class MatchListItem(BaseModel): + match_id: str + home_team: str + away_team: str + kickoff_utc: datetime + status: str + venue_name: str | None = None + venue_city: str | None = None + venue_country: str | None = None + + +class MatchOddsPoint(BaseModel): + recorded_at: str + bookmaker: str + bookmaker_id: str + market_type: str + selection: str + decimal_odds: float + implied_probability: float + + +class MatchPoissonOutput(BaseModel): + expected_home_goals: float + expected_away_goals: float + score_matrix: list[list[float]] + one_x_two: dict[str, float] + over_under_2_5: dict[str, float] + + +class MatchConditionReadout(BaseModel): + strictness_index: float + heat_index: float + cards_pressure_alert: bool + second_half_home_attack: float + second_half_away_attack: float + second_half_under_recommendation: bool + attacker_direction: str + + +class MatchDetailResponse(BaseModel): + match_id: str + home_team: str + away_team: str + home_xg: float + away_xg: float + match_time_utc: str + status: str + venue_name: str + venue_city: str + venue_country: str + venue_altitude_meters: int | None + odds_series: list[MatchOddsPoint] + poisson: MatchPoissonOutput + conditions: MatchConditionReadout + quant_summary: str + + +@app.get('/health') +async def health() -> dict[str, str]: + return { + 'ok': 'true', + 'service': 'fifa2026-websocket', + } + + +def _odds_to_prob(odds: float) -> float: + return 1.0 / odds + + +def _build_model_row_payload(req: MlEdgeRequest) -> dict[str, float]: + return { + 'match_id': req.match_id, + 'home_rest_days': req.home_rest_days, + 'away_rest_days': req.away_rest_days, + 'home_travel_distance_km': req.home_travel_distance_km, + 'away_travel_distance_km': req.away_travel_distance_km, + 'recent_5_xg_home': req.recent_5_xg_home, + 'recent_5_xg_away': req.recent_5_xg_away, + } + + +def _to_date(value: str) -> datetime.date: + try: + return datetime.strptime(value, '%Y-%m-%d').date() + except ValueError as exc: + raise HTTPException(status_code=400, detail='日期格式必須為 YYYY-MM-DD') from exc + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value: Any, default: int | None = None) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _build_quant_summary( + home_team: str, + away_team: str, + home_xg: float, + away_xg: float, + one_x_two: dict[str, float], + over_under: dict[str, float], +) -> str: + """用 Poisson 核心輸出構造 300 字上下的賽前量化摘要。""" + + home_win = _safe_float(one_x_two.get('home_win')) + draw = _safe_float(one_x_two.get('draw')) + away_win = _safe_float(one_x_two.get('away_win')) + under = _safe_float(over_under.get('under')) + over = _safe_float(over_under.get('over')) + + return ( + f'【資料驅動預測摘要】{home_team} vs {away_team} 的 xG 組合顯示,{home_team} 預估主場進球 {home_xg:.2f},\ +{away_team} 進球 {away_xg:.2f},主勝機率約 {home_win:.1%}、平局 {draw:.1%}、客勝 {away_win:.1%}。\ +依 2.5 球門檻,走 Under 機率約 {under:.1%},Over 機率約 {over:.1%},\ +若模型判斷到賽中節奏偏中後段上升,建議關注亞盤與大小球組合與 Sharp Money 的偏移。\ +該筆預測建議以「盤口變動」與「賠率修正」為主軸,避免盲追單一 1X2 方向。\ +實盤可將本場解讀為偏向策略化下注:若盤口未按預估修正,應提高風險控管,等待第二次確認訊號。' + ) + + +async def _query_match_list(limit: int = 200) -> list[dict[str, Any]]: + home_team = aliased(Team) + away_team = aliased(Team) + + async with SessionFactory() as session: + stmt = ( + select( + Match.id, + home_team.name, + away_team.name, + Match.match_time_utc, + Match.status, + Venue.name, + Venue.city, + Venue.country, + ) + .join(home_team, Match.home_team_id == home_team.id) + .join(away_team, Match.away_team_id == away_team.id) + .join(Venue, Match.venue_id == Venue.id) + .order_by(Match.match_time_utc.asc()) + .limit(limit) + ) + + result = await session.execute(stmt) + rows = result.all() + + return [ + { + 'match_id': match_id, + 'home_team': home_name, + 'away_team': away_name, + 'kickoff_utc': kickoff_utc, + 'status': str(status.value if hasattr(status, 'value') else status), + 'venue_name': venue_name, + 'venue_city': venue_city, + 'venue_country': venue_country, + } + for match_id, home_name, away_name, kickoff_utc, status, venue_name, venue_city, venue_country in rows + ] + + +def _build_over_under_payload(under: float, over: float) -> dict[str, float]: + return { + 'under': max(0.0, min(1.0, _safe_float(under))), + 'over': max(0.0, min(1.0, _safe_float(over))), + } + + +async def _query_match_preview(match_id: str) -> dict[str, Any] | None: + home_team = aliased(Team) + away_team = aliased(Team) + + async with SessionFactory() as session: + stmt = ( + select(Match, home_team, away_team, Venue) + .join(home_team, Match.home_team_id == home_team.id) + .join(away_team, Match.away_team_id == away_team.id) + .join(Venue, Match.venue_id == Venue.id) + .where(Match.id == match_id) + ) + + result = await session.execute(stmt) + row = result.first() + if not row: + return None + + match = row[0] + home = row[1] + away = row[2] + venue = row[3] + + home_xg = _safe_float(match.home_xg, 1.1) + away_xg = _safe_float(match.away_xg, 1.0) + + odds_stmt = ( + select( + OddsHistory.recorded_at, + OddsHistory.market_type, + OddsHistory.selection, + OddsHistory.decimal_odds, + OddsHistory.implied_probability, + Bookmaker.id, + Bookmaker.name, + ) + .join(Bookmaker, OddsHistory.bookmaker_id == Bookmaker.id) + .where(OddsHistory.match_id == match_id) + .order_by(OddsHistory.recorded_at.asc(), OddsHistory.id.asc()) + ) + + odds_result = await session.execute(odds_stmt) + odds_rows = odds_result.all() + + odds_points: list[MatchOddsPoint] = [ + MatchOddsPoint( + recorded_at=point_recorded.strftime('%Y-%m-%dT%H:%M:%SZ') if hasattr(point_recorded, 'strftime') else str(point_recorded), + bookmaker=str(name), + bookmaker_id=str(bookmaker_id), + market_type=market_type, + selection=selection, + decimal_odds=float(odds), + implied_probability=float(implied_prob), + ) + for point_recorded, market_type, selection, odds, implied_prob, bookmaker_id, name in odds_rows + ] + + if len(odds_points) == 0: + fallback_now = datetime.utcnow().isoformat() + 'Z' + odds_points = [ + MatchOddsPoint( + recorded_at=fallback_now, + bookmaker='sample-bookmaker', + bookmaker_id='sample', + market_type='1x2', + selection='home', + decimal_odds=round(1.90 + (_safe_float(1.0) * 0.0), 2), + implied_probability=0.52, + ), + ] + + predictor = PoissonMatchPredictor( + home_attack_strength=max(0.35, home_xg), + home_defense_strength=max(0.35, 1.0 + ((venue.altitude_meters or 0) / 5000.0)), + away_attack_strength=max(0.35, away_xg), + away_defense_strength=max(0.35, 1.0), + league_avg_home_goals=1.35, + ) + expected_home_xg, expected_away_xg = predictor.calculate_expected_goals() + score_matrix = predictor.predict_exact_score_matrix(max_goals=5).tolist() + one_x_two = predictor.predict_1x2_probabilities() + under_prob, over_prob = predictor.predict_over_under_prob(2.5) + + conditions = evaluate_match_conditions( + avg_yellow_cards=4.0 + (1 if (venue.altitude_meters or 0) > 1000 else 0), + penalties_per_game=0.18, + cards_ou_line=4.5, + temp_c=28.0 + ((venue.altitude_meters or 0) / 3000.0), + humidity_pct=52.0 + min(20.0, (venue.altitude_meters or 0) / 150.0 * 0.1), + venue_altitude_meters=venue.altitude_meters or 0, + home_second_half_attack=expected_home_xg * 1.08, + away_second_half_attack=expected_away_xg * 1.02, + ) + + quant_summary = _build_quant_summary( + home_team=home.name, + away_team=away.name, + home_xg=expected_home_xg, + away_xg=expected_away_xg, + one_x_two=one_x_two, + over_under={'under': under_prob, 'over': over_prob}, + ) + + return { + 'match_id': match.id, + 'home_team': home.name, + 'away_team': away.name, + 'home_xg': expected_home_xg, + 'away_xg': expected_away_xg, + 'match_time_utc': (match.match_time_utc.isoformat() if match.match_time_utc else datetime.utcnow().isoformat()), + 'status': str(match.status.value if hasattr(match.status, 'value') else match.status), + 'venue_name': venue.name, + 'venue_city': venue.city, + 'venue_country': venue.country, + 'venue_altitude_meters': venue.altitude_meters, + 'odds_series': odds_points, + 'poisson': { + 'expected_home_goals': expected_home_xg, + 'expected_away_goals': expected_away_xg, + 'score_matrix': score_matrix, + 'one_x_two': one_x_two, + 'over_under_2_5': _build_over_under_payload(under_prob, over_prob), + }, + 'conditions': { + 'strictness_index': conditions.strictness_index, + 'heat_index': conditions.heat_index, + 'cards_pressure_alert': conditions.cards_pressure_alert, + 'second_half_home_attack': conditions.second_half_home_attack, + 'second_half_away_attack': conditions.second_half_away_attack, + 'second_half_under_recommendation': conditions.second_half_under_recommendation, + 'attacker_direction': conditions.attacker_direction, + }, + 'quant_summary': quant_summary, + } + + +def _build_daily_card_fallback(matches_on_date: list[dict[str, Any]]) -> list[dict[str, Any]]: + if matches_on_date: + return matches_on_date + + return [ + { + 'match_id': 'm1', + 'home_team': '德國', + 'away_team': '西班牙', + 'odds_home': 1.92, + 'odds_away': 4.05, + 'home_xg': 1.45, + 'away_xg': 0.95, + }, + { + 'match_id': 'm2', + 'home_team': '巴西', + 'away_team': '法國', + 'odds_home': 2.05, + 'odds_away': 3.6, + 'home_xg': 1.32, + 'away_xg': 1.25, + }, + ] + + +async def _query_match_day_snapshot(target_date: datetime.date) -> list[dict[str, Any]]: + home_team = aliased(Team) + away_team = aliased(Team) + + async with SessionFactory() as session: + stmt = ( + select( + Match.id, + Match.home_xg, + Match.away_xg, + Match.match_time_utc, + home_team.name, + away_team.name, + ) + .join(home_team, Match.home_team_id == home_team.id) + .join(away_team, Match.away_team_id == away_team.id) + .where(func.date(Match.match_time_utc) == target_date) + .order_by(Match.match_time_utc.asc()) + ) + + result = await session.execute(stmt) + rows = result.all() + + matches_payload: list[dict[str, Any]] = [] + for row in rows: + match_id, home_xg, away_xg, match_time_utc, home_name, away_name = row + opening_home, current_home = await _query_opening_odds(session, match_id, '1x2', 'home') + opening_away, current_away = await _query_opening_odds(session, match_id, '1x2', 'away') + current_home = current_home if current_home is not None else opening_home + current_away = current_away if current_away is not None else opening_away + + if current_home is not None and current_away is not None: + matches_payload.append( + { + 'match_id': match_id, + 'home_team': home_name, + 'away_team': away_name, + 'home_xg': float(home_xg or 1.0), + 'away_xg': float(away_xg or 1.0), + 'odds_home': float(current_home), + 'odds_away': float(current_away), + 'kickoff_utc': match_time_utc, + }, + ) + + return _build_daily_card_fallback(matches_payload) + + +async def _query_latest_smart_money( + session: Any, + match_id: str, + market_type: str, + selection: str, +) -> Any: + latest_stmt = ( + select(SmartMoneyFlow) + .where( + SmartMoneyFlow.match_id == match_id, + SmartMoneyFlow.market_type == market_type, + SmartMoneyFlow.selection == selection, + ) + .order_by(desc(SmartMoneyFlow.recorded_at)) + .limit(1) + ) + latest = await session.execute(latest_stmt) + return latest.scalar_one_or_none() + + +def _safe_float(value: Any, *, default: float | None = None) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return default + + +async def _query_latest_smart_money_from_cache( + redis_conn: Redis, + match_id: str, + market_type: str, + selection: str, +) -> SmartMoneyFlow | None: + cache_keys = ( + f'smart_money:{match_id}:{market_type}:{selection}', + f'smart-money:{match_id}:{market_type}:{selection}', + f'match:{match_id}:{market_type}:{selection}:smart_money', + ) + + for key in cache_keys: + raw = await redis_conn.get(key) + if not raw: + continue + try: + parsed = json.loads(raw.decode() if isinstance(raw, bytes) else raw) + except (TypeError, json.JSONDecodeError): + continue + + if not isinstance(parsed, Mapping): + continue + + ticket_pct = _safe_float(parsed.get('ticket_pct')) + handle_pct = _safe_float(parsed.get('handle_pct')) + if ticket_pct is None or handle_pct is None: + continue + + return SmartMoneyFlow( + id=0, + match_id=match_id, + market_type=market_type, + selection=selection, + ticket_pct=ticket_pct, + handle_pct=handle_pct, + sharp_indicator=bool(parsed.get('sharp_indicator', False)), + recorded_at=datetime.utcnow(), + ) + return None + + +async def _query_opening_current_odds_from_cache( + redis_conn: Redis, + match_id: str, + market_type: str, + selection: str, +) -> tuple[float | None, float | None]: + opening_key = f'odds_open:{match_id}:{market_type}:{selection}' + current_key = f'odds_current:{match_id}:{market_type}:{selection}' + opening_raw = await redis_conn.get(opening_key) + current_raw = await redis_conn.get(current_key) + + opening_odds = _safe_float( + opening_raw.decode() if isinstance(opening_raw, bytes) else opening_raw, + default=None, + ) + current_odds = _safe_float( + current_raw.decode() if isinstance(current_raw, bytes) else current_raw, + default=None, + ) + + if opening_odds is not None and current_odds is not None: + return opening_odds, current_odds + + # fallback: decode live snapshot 列表 + live_raw = await redis_conn.get(f'live:{match_id}:odds') + if not live_raw: + return None, None + + try: + live_payload = json.loads(live_raw.decode() if isinstance(live_raw, bytes) else live_raw) + except (TypeError, json.JSONDecodeError): + return None, None + + if not isinstance(live_payload, list): + return None, None + + filtered = [ + row for row in live_payload + if isinstance(row, Mapping) + and str(row.get('match_id', match_id)) == str(match_id) + and str(row.get('market_type')) == str(market_type) + and str(row.get('selection')) == str(selection) + and _safe_float(row.get('decimal_odds'), default=None) is not None + ] + if not filtered: + return None, None + + values = [ + (_safe_float(row.get('recorded_at'), default=None), _safe_float(row.get('decimal_odds'), default=None)) + for row in filtered + ] + + values = [item for item in values if item[1] is not None] + if not values: + return None, None + + values.sort(key=lambda item: item[0] if item[0] is not None else 0) + opening = float(values[0][1]) + current = float(values[-1][1]) + return opening, current + + +async def _query_opening_odds( + session: Any, + match_id: str, + market_type: str, + selection: str, +) -> tuple[float | None, float | None]: + opening_stmt = ( + select(OddsHistory.decimal_odds) + .join(Match, Match.id == OddsHistory.match_id) + .where( + OddsHistory.match_id == match_id, + OddsHistory.market_type == market_type, + OddsHistory.selection == selection, + ) + .order_by(asc(OddsHistory.recorded_at)) + .limit(1) + ) + current_stmt = ( + select(OddsHistory.decimal_odds) + .where( + OddsHistory.match_id == match_id, + OddsHistory.market_type == market_type, + OddsHistory.selection == selection, + ) + .order_by(desc(OddsHistory.recorded_at)) + .limit(1) + ) + + opening_result = await session.execute(opening_stmt) + current_result = await session.execute(current_stmt) + opening_row = opening_result.scalar_one_or_none() + current_row = current_result.scalar_one_or_none() + return ( + float(opening_row) if opening_row is not None else None, + float(current_row) if current_row is not None else None, + ) + + +def _to_summary(records: list[ProofYieldRecord]) -> LedgerSummary: + return ProofOfYieldStore.summarize(records) + + +def _to_ledger_response(records: list[ProofYieldRecord]) -> list[ProofOfYieldRecordResponse]: + return [ + ProofOfYieldRecordResponse( + recommendation_id=row.recommendation_id, + match_id=row.match_id, + market_type=row.market_type, + selection=row.selection, + stake=row.stake, + recommended_odds=row.recommended_odds, + closing_odds=row.closing_odds or 0.0, + is_win=row.is_win, + settled_at=row.settled_at, + clv_percent=row.clv_percent, + pnl=row.pnl, + ) + for row in records + ] + + +async def relay_redis_events() -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + pubsub = redis.pubsub() + await pubsub.subscribe(*WS_REDIS_CHANNELS) + logger.info('Redis Pub/Sub 已啟動,監聽 %s', ','.join(WS_REDIS_CHANNELS)) + + try: + async for message in pubsub.listen(): + if message['type'] != 'message': + continue + raw = message['data'] + payload = raw if isinstance(raw, str) else str(raw) + + try: + parsed = json.loads(payload) + except ValueError: + channel = message['channel'] + if isinstance(channel, bytes): + channel = channel.decode('utf-8', errors='ignore') + parsed = {'eventType': channel, 'payload': payload} + + match_id = ( + parsed.get('match_id') + or parsed.get('matchId') + or parsed.get('payload', {}).get('match_id') + or parsed.get('payload', {}).get('matchId') + or '' + ) + + message_str = json.dumps(parsed, ensure_ascii=False) + if match_id: + await manager.broadcast_to_match(str(match_id), message_str) + else: + await manager.broadcast_to_all(message_str) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error('Redis listener error: %s', exc) + finally: + await pubsub.unsubscribe(*WS_REDIS_CHANNELS) + await pubsub.close() + await redis.close() + + +@app.on_event('startup') +async def on_startup() -> None: + app.state.redis_listener = asyncio.create_task(relay_redis_events()) + + +@app.on_event('shutdown') +async def on_shutdown() -> None: + listener = getattr(app.state, 'redis_listener', None) + if listener: + listener.cancel() + with contextlib.suppress(asyncio.CancelledError): + await listener + + +@app.websocket('/ws/matches/{match_id}') +async def websocket_endpoint(websocket: WebSocket, match_id: str) -> None: + await manager.connect(websocket, match_id) + try: + await websocket.send_text(json.dumps({'eventType': 'connected', 'matchId': match_id, 'payload': {}})) + while True: + message = await websocket.receive_text() + try: + data = json.loads(message) + except ValueError: + await websocket.send_text(json.dumps({'eventType': 'error', 'payload': {'message': 'JSON 格式錯誤'}})) + continue + if data.get('action') == 'ping': + await websocket.send_text(json.dumps({'eventType': 'pong', 'payload': {'matchId': match_id}})) + except WebSocketDisconnect: + await manager.disconnect(websocket, match_id) + + +@app.post('/analytics/player-props', response_model=PlayerPropsResponse) +async def analyze_player_prop(req: PlayerPropsRequest) -> dict[str, float | int | str | bool | None]: + profile = PlayerPropsProfile( + player_id=req.player_id, + metric=req.metric, + baseline_mean=req.baseline_mean, + match_minutes=req.match_minutes, + team_attack_factor=req.team_attack_factor, + opponent_defence_factor=req.opponent_defence_factor, + weather_fatigue_factor=req.weather_fatigue_factor, + ) + + if req.bookmaker_over_odds is None: + result = simulate_player_prop_probability( + profile, + line=req.line, + simulations=req.simulations, + ) + response = result.to_dict() + response.update({'edge': None, 'top_edge': False, 'bookmaker_over_odds': None, 'implied_prob': None}) + return response + + return evaluate_top_edge( + profile, + bookmaker_over_odds=req.bookmaker_over_odds, + line=req.line, + simulations=req.simulations, + ) + + +@app.post('/analytics/kelly', response_model=KellyResponse) +async def calculate_kelly(request: KellyRequest) -> KellyResponse: + result: KellyResult = calculate_kelly_fraction( + request.odds, + request.true_prob, + bankroll=request.bankroll, + fractional_kelly_factor=request.fractional_kelly_factor, + risk_tolerance_factor=request.risk_tolerance_factor, + ) + + return KellyResponse( + odds=result.decimal_odds, + true_prob=result.win_probability, + bankroll=request.bankroll, + raw_kelly_fraction=round(result.raw_kelly_fraction, 6), + fractional_kelly_factor=result.fractional_kelly_factor, + risk_tolerance_factor=result.risk_tolerance_factor, + recommended_fraction=round(result.stake_fraction * 100, 6), + recommended_stake=round(request.bankroll * result.stake_fraction, 2), + ) + + +@app.post('/analytics/backtest', response_model=BacktestResponse) +async def run_backtest(req: BacktestRequest) -> BacktestResponse: + strategy_filter = StrategyFilter( + weather=req.strategy.weather, + altitude_min_meters=req.strategy.altitude_min_meters, + altitude_max_meters=req.strategy.altitude_max_meters, + handicap_min=req.strategy.handicap_min, + handicap_max=req.strategy.handicap_max, + recent_win_rate_min=req.strategy.recent_win_rate_min, + recent_win_rate_max=req.strategy.recent_win_rate_max, + market_types=req.strategy.market_types, + start_at=req.strategy.start_at, + end_at=req.strategy.end_at, + ) + + try: + records = [ + BacktestTradeRecord( + trade_id=item.trade_id, + settled_at=item.settled_at, + odds=item.odds, + is_win=item.is_win, + stake=item.stake, + altitude_meters=item.altitude_meters, + handicap=item.handicap, + weather=item.weather, + recent_form_win_rate=item.recent_form_win_rate, + market_type=item.market_type, + selection=item.selection, + ) + for item in req.historical_trades + ] + except Exception as exc: + raise HTTPException(status_code=400, detail=f'交易資料欄位不合法:{exc}') from exc + + filtered = filter_trades(records, strategy_filter) + + if not filtered: + raise HTTPException( + status_code=404, + detail='無法符合條件的歷史資料;請放寬條件或增加歷史注單輸入', + ) + + summary = run_flat_stake_backtest(filtered, initial_capital=req.initial_capital) + return BacktestResponse( + matched=summary['trade_count'], + total=len(records), + hit_count=summary['hit_count'], + win_rate=summary['win_rate'], + final_capital=summary['final_capital'], + net_profit=summary['net_profit'], + roi_percent=summary['roi_percent'], + max_drawdown_percent=summary['max_drawdown_percent'], + equity_curve=[BacktestPoint(**point) for point in summary['equity_curve']], + ) + + +@app.post('/analytics/ml-edge/train', response_model=MlTrainResponse) +async def train_ml_model(req: MlTrainRequest) -> MlTrainResponse: + if not req.rows: + raise HTTPException(status_code=400, detail='訓練資料不能為空') + + prepared_rows = [ + { + **normalize_feature_payload(row.model_dump()), + 'match_result': row.match_result.lower(), + } + for row in req.rows + ] + model_id = req.model_id or uuid4().hex + + try: + model = train_match_outcome_ensemble(prepared_rows, model_id=model_id) + except Exception as exc: + raise HTTPException(status_code=400, detail=f'訓練失敗:{exc}') from exc + + ML_MODELS[model_id] = model + return MlTrainResponse( + model_id=model_id, + status='trained', + training_size=model.training_size, + is_fallback=model.is_fallback, + accuracy=model.training_accuracy, + ) + + +@app.post('/analytics/ml-edge', response_model=MlEdgeResponse) +async def predict_ml_edge(req: MlEdgeRequest) -> MlEdgeResponse: + model = ML_MODELS.get(req.model_id or 'default') + if model is None: + raise HTTPException(status_code=404, detail='模型不存在,請先透過 /analytics/ml-edge/train 取得模型') + + payload = _build_model_row_payload(req) + try: + model_probs = model_predict_probabilities(model, payload) + except Exception as exc: + raise HTTPException(status_code=500, detail=f'模型推論失敗:{exc}') from exc + + implied = { + 'home': _odds_to_prob(req.home_implied_odds), + 'draw': _odds_to_prob(req.draw_implied_odds), + 'away': _odds_to_prob(req.away_implied_odds), + } + edge_map = calculate_model_edges(model_probs, implied) + strong_edges = [outcome for outcome, values in edge_map.items() if bool(values['strong_buy'])] + strongest_outcome = max(edge_map, key=lambda outcome: edge_map[outcome]['edge']) + strongest_edge = edge_map[strongest_outcome]['edge'] + + return MlEdgeResponse( + match_id=req.match_id, + model_id=model.model_id, + is_fallback_model=model.is_fallback, + model_probs={k: float(v['model_prob']) for k, v in edge_map.items()}, + edges={k: MlModelEdge(**v) for k, v in edge_map.items()}, # type: ignore[arg-type] + strong_buy=len(strong_edges) > 0, + strongest_outcome=strongest_outcome, + strongest_edge_percent=round(strongest_edge * 100, 4), + feature_columns=list(model.feature_columns), + training_size=model.training_size, + ) + + +@app.post('/analytics/match-conditions', response_model=MatchConditionResponse) +async def analyze_match_conditions(req: MatchConditionRequest) -> MatchConditionResponse: + result = evaluate_match_conditions( + avg_yellow_cards=req.avg_yellow_cards, + penalties_per_game=req.penalties_per_game, + cards_ou_line=req.cards_ou_line, + temp_c=req.temp_c, + humidity_pct=req.humidity_pct, + venue_altitude_meters=req.venue_altitude_meters, + home_second_half_attack=req.home_second_half_attack, + away_second_half_attack=req.away_second_half_attack, + ) + + return MatchConditionResponse( + match_id=req.match_id, + strictness_index=result.strictness_index, + heat_index=result.heat_index, + cards_pressure_alert=result.cards_pressure_alert, + second_half_home_attack=result.second_half_home_attack, + second_half_away_attack=result.second_half_away_attack, + second_half_under_recommendation=result.second_half_under_recommendation, + attacker_direction=result.attacker_direction, + ) + + +@app.post('/analytics/rlm', response_model=RlmResponse) +async def detect_reverse_line_movement(req: RlmRequest) -> RlmResponse: + latest_money = None + opening_odds: float | None = None + current_odds: float | None = None + + async with SessionFactory() as session: + try: + latest_money = await _query_latest_smart_money(session, req.match_id, req.market_type, req.selection) + opening_odds, current_odds = await _query_opening_odds(session, req.match_id, req.market_type, req.selection) + except SQLAlchemyError as exc: + logger.warning('RLM 查詢 DB 失敗,改用快取/預留資料:%s', exc) + + if (latest_money is None or opening_odds is None or current_odds is None): + redis = Redis.from_url(REDIS_URL, decode_responses=False) + try: + if latest_money is None: + latest_money = await _query_latest_smart_money_from_cache(redis, req.match_id, req.market_type, req.selection) + + if opening_odds is None or current_odds is None: + opening_odds, current_odds = await _query_opening_current_odds_from_cache( + redis, + req.match_id, + req.market_type, + req.selection, + ) + except Exception as exc: + logger.warning('RLM 查詢 Redis 失敗:%s', exc) + finally: + await redis.close() + + alerts: list[RlmAlertResponse] = [] + if latest_money is not None and opening_odds is not None and current_odds is not None: + alert = evaluate_reverse_line_movement( + req.match_id, + req.market_type, + req.selection, + opening_odds=opening_odds, + current_odds=current_odds, + ticket_pct=float(latest_money.ticket_pct), + handle_pct=float(latest_money.handle_pct), + ticket_threshold=req.ticket_threshold, + odds_change_threshold=req.odds_change_threshold, + ) + alerts.append( + RlmAlertResponse( + match_id=alert.match_id, + market_type=alert.market_type, + selection=alert.selection, + opening_odds=alert.opening_odds, + current_odds=alert.current_odds, + ticket_pct=alert.ticket_pct, + handle_pct=alert.handle_pct, + odds_change_pct=alert.odds_change_pct, + smart_money_to=alert.smart_money_to, + is_triggered=alert.is_triggered, + rationale=alert.rationale, + ), + ) + + return RlmResponse(alerts=alerts, total=len(alerts)) + + +@app.get('/analytics/proof-of-yield/ledger', response_model=ProofOfYieldLedgerResponse) +async def get_proof_of_yield_ledger(limit: int = 200) -> ProofOfYieldLedgerResponse: + records = PROOF_OF_YIELD_STORE.query_ledger(limit=max(1, min(limit, 1000))) + summary = _to_summary(records) + return ProofOfYieldLedgerResponse( + summary=ProofOfYieldSummaryResponse(**summary.__dict__), + records=_to_ledger_response(records), + ) + + +@app.post('/analytics/proof-of-yield/settle', response_model=ProofOfYieldLedgerResponse) +async def settle_proof_of_yield_recommendations( + req: ProofOfYieldSettleRequest, +) -> ProofOfYieldLedgerResponse: + if not req.items: + raise HTTPException(status_code=400, detail='請至少提供一筆建議明細') + + PROOF_OF_YIELD_STORE.upsert_settlements([item.model_dump() for item in req.items]) + records = PROOF_OF_YIELD_STORE.query_ledger(limit=1000) + summary = _to_summary(records) + return ProofOfYieldLedgerResponse( + summary=ProofOfYieldSummaryResponse(**summary.__dict__), + records=_to_ledger_response(records), + ) + + +@app.get('/analytics/matches', response_model=list[MatchListItem]) +async def list_matches(limit: int = 500) -> list[MatchListItem]: + rows = await _query_match_list(limit=max(1, min(limit, 1000))) + if not rows: + return [] + + return [ + MatchListItem( + match_id=match_payload['match_id'], + home_team=match_payload['home_team'], + away_team=match_payload['away_team'], + kickoff_utc=match_payload['kickoff_utc'], + status=str(match_payload['status']), + venue_name=match_payload['venue_name'], + venue_city=match_payload['venue_city'], + venue_country=match_payload['venue_country'], + ) + for match_payload in rows + ] + + +@app.get('/analytics/matches/{match_id}', response_model=MatchDetailResponse) +async def get_match_detail_route(match_id: str) -> MatchDetailResponse: + payload = await _query_match_preview(match_id) + if payload is None: + raise HTTPException(status_code=404, detail='賽事不存在') + + return MatchDetailResponse( + match_id=payload['match_id'], + home_team=payload['home_team'], + away_team=payload['away_team'], + home_xg=payload['home_xg'], + away_xg=payload['away_xg'], + match_time_utc=payload['match_time_utc'], + status=payload['status'], + venue_name=payload['venue_name'], + venue_city=payload['venue_city'], + venue_country=payload['venue_country'], + venue_altitude_meters=payload['venue_altitude_meters'], + odds_series=payload['odds_series'], + poisson=payload['poisson'], + conditions=payload['conditions'], + quant_summary=payload['quant_summary'], + ) + + +@app.post('/analytics/portfolio/leaks', response_model=PortfolioLeaksResponse) +async def analyze_portfolio_leaks(req: PortfolioLeaksRequest) -> PortfolioLeaksResponse: + payload = [bet.model_dump() for bet in req.user_bets] + result = analyze_user_leaks(payload) + + return PortfolioLeaksResponse( + total_bet_count=result['total_bet_count'], + settled_bet_count=result['settled_bet_count'], + total_stake=result['total_stake'], + total_pnl=result['total_pnl'], + overall_roi_percent=result['overall_roi_percent'], + overall_hit_rate_percent=result['overall_hit_rate_percent'], + clusters=[PortfolioLeakCluster(**item) for item in result['clusters']], + hard_truths=[PortfolioHardTruth(**item) for item in result['hard_truths']], + ) + + +@app.post('/analytics/hedging', response_model=HedgeResponse) +async def calculate_hedge_signal(req: HedgeRequest) -> HedgeResponse: + result = calculate_hedge_amount( + original_stake=req.original_stake, + parlay_total_odds=req.parlay_total_odds, + final_leg_hedge_odds=req.final_leg_hedge_odds, + ) + + return HedgeResponse(**result) + + +@app.get('/analytics/daily-card/{target_date}', response_model=DailyCardResponse) +async def generate_daily_card_route(target_date: str) -> DailyCardResponse: + card_date = _to_date(target_date) + match_payload = await _query_match_day_snapshot(card_date) + result = generate_daily_card(target_date, match_payload) + return DailyCardResponse(**result) + + +if __name__ == '__main__': + import uvicorn + uvicorn.run('main:app', host='0.0.0.0', port=int(os.environ.get('PORT', '8000'))) diff --git a/platform/backend/app/services/redis_manager.py b/platform/backend/app/services/redis_manager.py new file mode 100644 index 0000000..630e3ba --- /dev/null +++ b/platform/backend/app/services/redis_manager.py @@ -0,0 +1,134 @@ +"""Redis 快取管理層(賠率與賽事快取)。""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Mapping + +from redis.asyncio import Redis + + +ARBITRAGE_LUA = r''' +local odds_json = ARGV[1] +local payload = cjson.decode(odds_json) + +local by_market = {} +for _, row in ipairs(payload) do + local market = row.market_type + local selection = row.selection + local odds = tonumber(row.decimal_odds) + local bookmaker = tostring(row.bookmaker or "") + + if market and selection and odds and odds > 0 then + if by_market[market] == nil then + by_market[market] = {} + end + if by_market[market][selection] == nil or odds > by_market[market][selection].odds then + by_market[market][selection] = {odds = odds, bookmaker = bookmaker} + end + end +end + +local out = {} +for market, selections in pairs(by_market) do + local inv = 0 + local n = 0 + for _, item in pairs(selections) do + inv = inv + (1 / item.odds) + n = n + 1 + end + if n >= 2 then + out[market] = { + has_arbitrage = (inv < 1), + implied_total = inv, + best_odds = selections, + edge = math.max(1 - inv, 0) + } + end +end + +return cjson.encode(out) +''' + + +@dataclass(slots=True) +class MatchState: + home_score: int + away_score: int + minute: int + possession_home: float + possession_away: float + red_cards_home: int + red_cards_away: int + + +class MatchCacheManager: + """實作高頻快取:賠率 JSON + 賽事狀態 Hash。""" + + def __init__(self, redis: Redis) -> None: + self.redis = redis + self._lua_sha: str | None = None + + async def _ensure_lua(self) -> str: + if self._lua_sha is None: + self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA) + return self._lua_sha + + async def set_match_odds( + self, + match_id: str, + payload: list[dict[str, Any]], + *, + finished: bool = False, + ) -> None: + key = f'live_odds:{match_id}' + ex = 7200 if finished else 30 + await self.redis.set(key, json.dumps(payload, ensure_ascii=False), ex=ex) + + async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]: + key = f'live_odds:{match_id}' + raw = await self.redis.get(key) + if not raw: + return [] + if isinstance(raw, bytes): + raw = raw.decode('utf-8') + return json.loads(raw) + + async def set_match_state( + self, + match_id: str, + state: MatchState | Mapping[str, Any], + *, + finished: bool = False, + ) -> None: + key = f'live_state:{match_id}' + mapping = { + 'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score, + 'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score, + 'minute': state['minute'] if isinstance(state, Mapping) else state.minute, + 'possession_home': state['possession_home'] if isinstance(state, Mapping) else state.possession_home, + 'possession_away': state['possession_away'] if isinstance(state, Mapping) else state.possession_away, + 'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home, + 'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away, + } + await self.redis.hset(key, mapping=mapping) + await self.redis.expire(key, 7200 if finished else 60) + + async def get_match_state(self, match_id: str) -> dict[str, str] | None: + key = f'live_state:{match_id}' + result = await self.redis.hgetall(key) + return {str(k): str(v) for k, v in result.items()} if result else None + + async def calculate_arbitrage(self, match_id: str) -> dict[str, Any]: + odds = await self.get_match_odds(match_id) + if not odds: + return {} + + sha = await self._ensure_lua() + result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False)) + if isinstance(result, bytes): + result = result.decode() + if isinstance(result, str): + return json.loads(result) + return result diff --git a/platform/backend/db_init_timescaledb.sql b/platform/backend/db_init_timescaledb.sql new file mode 100644 index 0000000..8e1d9ca --- /dev/null +++ b/platform/backend/db_init_timescaledb.sql @@ -0,0 +1,153 @@ +CREATE EXTENSION IF NOT EXISTS timescaledb; + +CREATE TABLE IF NOT EXISTS venues ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + altitude_meters INTEGER, + timezone TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + fifa_rank INTEGER, + current_elo_rating DOUBLE PRECISION, + group_name TEXT +); + +CREATE TABLE IF NOT EXISTS bookmakers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS matches ( + id TEXT PRIMARY KEY, + home_team_id TEXT NOT NULL REFERENCES teams (id), + away_team_id TEXT NOT NULL REFERENCES teams (id), + venue_id TEXT NOT NULL REFERENCES venues (id), + match_time_utc TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pre-match', + home_xg DOUBLE PRECISION, + away_xg DOUBLE PRECISION +); + +CREATE TABLE IF NOT EXISTS odds_history ( + id BIGSERIAL PRIMARY KEY, + match_id TEXT NOT NULL REFERENCES matches (id), + bookmaker_id TEXT NOT NULL REFERENCES bookmakers (id), + market_type TEXT NOT NULL, + selection TEXT NOT NULL, + decimal_odds DOUBLE PRECISION NOT NULL, + implied_probability DOUBLE PRECISION NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS smart_money_flow ( + id BIGSERIAL PRIMARY KEY, + match_id TEXT NOT NULL REFERENCES matches (id), + market_type TEXT NOT NULL, + selection TEXT NOT NULL, + ticket_pct DOUBLE PRECISION NOT NULL, + handle_pct DOUBLE PRECISION NOT NULL, + sharp_indicator BOOLEAN NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_matches_time_status ON matches (match_time_utc, status); +CREATE INDEX IF NOT EXISTS idx_odds_match_market_recorded_at + ON odds_history (match_id, market_type, recorded_at DESC); +CREATE INDEX IF NOT EXISTS idx_money_flow_match_recorded_at + ON smart_money_flow (match_id, market_type, recorded_at DESC); + +CREATE TABLE IF NOT EXISTS value_bet_recommendations ( + id TEXT PRIMARY KEY, + match_id TEXT NOT NULL REFERENCES matches (id), + market_type TEXT NOT NULL, + selection TEXT NOT NULL, + stake DOUBLE PRECISION NOT NULL, + recommended_odds DOUBLE PRECISION NOT NULL, + closing_odds DOUBLE PRECISION, + is_win BOOLEAN NOT NULL DEFAULT FALSE, + settled_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + clv_ratio DOUBLE PRECISION, + pnl DOUBLE PRECISION NOT NULL DEFAULT 0, + note TEXT +); + +CREATE INDEX IF NOT EXISTS idx_value_bet_recommendations_match_time + ON value_bet_recommendations (match_id, settled_at DESC); + +SELECT create_hypertable( + 'odds_history', + 'recorded_at', + chunk_time_interval => INTERVAL '1 hour', + if_not_exists => TRUE +); + +CREATE INDEX IF NOT EXISTS idx_odds_history_time_gist + ON odds_history USING GIST (recorded_at); + +-- Stage 33: Affiliate Marketing +CREATE TABLE IF NOT EXISTS affiliate_bookmakers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + tracking_url TEXT NOT NULL, + commission_rate DOUBLE PRECISION NOT NULL DEFAULT 0.0 +); + +CREATE TABLE IF NOT EXISTS affiliate_clicks ( + id BIGSERIAL PRIMARY KEY, + bookmaker_id TEXT NOT NULL REFERENCES affiliate_bookmakers (id), + user_ip_hash TEXT NOT NULL, + user_agent TEXT, + referrer TEXT, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + converted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_timestamp + ON affiliate_clicks (timestamp DESC); + +SELECT create_hypertable( + 'affiliate_clicks', + 'timestamp', + chunk_time_interval => INTERVAL '1 day', + if_not_exists => TRUE +); + +-- Note: TimescaleDB continuous aggregates require a bit more setup in modern versions. +-- A simple materialized view for daily conversion rates: +CREATE MATERIALIZED VIEW IF NOT EXISTS affiliate_daily_conversions +WITH (timescaledb.continuous) AS +SELECT + time_bucket('1 day', timestamp) AS bucket, + bookmaker_id, + COUNT(*) AS total_clicks, + COUNT(*) FILTER (WHERE converted = TRUE) AS total_conversions +FROM affiliate_clicks +GROUP BY bucket, bookmaker_id +WITH NO DATA; + +-- Stage 35: Social Trading (Copy Bets & Leaderboard) +CREATE TABLE IF NOT EXISTS user_profiles ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + clv_score DOUBLE PRECISION NOT NULL DEFAULT 0.0, + roi_30d DOUBLE PRECISION NOT NULL DEFAULT 0.0, + sharp_rating INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS copy_bets ( + id BIGSERIAL PRIMARY KEY, + follower_id TEXT NOT NULL REFERENCES user_profiles(id), + leader_id TEXT NOT NULL REFERENCES user_profiles(id), + recommendation_id TEXT NOT NULL REFERENCES value_bet_recommendations(id), + follower_stake DOUBLE PRECISION NOT NULL, + copied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_copy_bets_leader_time + ON copy_bets (leader_id, copied_at DESC); diff --git a/platform/backend/requirements.txt b/platform/backend/requirements.txt new file mode 100644 index 0000000..23fada6 --- /dev/null +++ b/platform/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn==0.32.1 +redis==5.2.1 +pydantic==2.10.5 +aiohttp==3.11.11 +asyncpg==0.30.0 +sqlalchemy[asyncio]==2.0.39 +pandas==2.2.3 +numpy==2.1.3 +scipy==1.14.1 +scikit-learn==1.5.2 +xgboost==2.1.0 diff --git a/platform/backend/workers/odds_ingestion.py b/platform/backend/workers/odds_ingestion.py new file mode 100644 index 0000000..8aa91df --- /dev/null +++ b/platform/backend/workers/odds_ingestion.py @@ -0,0 +1,114 @@ +"""非同步賠率抓取 Worker。""" + +from __future__ import annotations + +import asyncio +from typing import Any, Mapping + +import aiohttp + +from ..app.services.redis_manager import MatchCacheManager + + +TEAM_ALIAS = { + 'usa': 'USMNT', + 'united states': 'USMNT', + 'usmnt': 'USMNT', +} + + +def normalize_team_name(raw_name: str) -> str: + """將各莊家球隊名稱對齊到平台內部代號。""" + + normalized = raw_name.strip().lower() + return TEAM_ALIAS.get(normalized, raw_name.strip()) + + +class OddsIngestionWorker: + """非同步抓取外部賠率並推入 Redis 快取。""" + + def __init__( + self, + session: aiohttp.ClientSession, + endpoint: str, + api_key: str, + ) -> None: + self.session = session + self.endpoint = endpoint + self.api_key = api_key + + async def _request_with_backoff( + self, + url: str, + *, + max_attempts: int = 5, + base_delay: float = 0.4, + ) -> Mapping[str, Any]: + delay = base_delay + attempt = 0 + + while attempt < max_attempts: + attempt += 1 + try: + async with self.session.get(url, timeout=15) as resp: + if resp.status == 429: + if attempt >= max_attempts: + text = await resp.text() + raise RuntimeError(f'HTTP 429: {text}') + await asyncio.sleep(delay) + delay *= 2 + continue + if resp.status >= 500: + if attempt >= max_attempts: + resp.raise_for_status() + await asyncio.sleep(delay) + delay *= 2 + continue + resp.raise_for_status() + return await resp.json() + except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc: + if attempt >= max_attempts: + raise RuntimeError(f'fetch failed: {exc!r}') from exc + await asyncio.sleep(delay) + delay *= 2 + + raise RuntimeError('unreachable') + + async def fetch_live_odds(self) -> list[dict[str, Any]]: + """抓取原始賠率清單。""" + + url = f'{self.endpoint.rstrip("/")}/v4/sports/soccer/odds?apiKey={self.api_key}' + payload = await self._request_with_backoff(url) + items = payload.get('data', []) if isinstance(payload, Mapping) else [] + + normalized: list[dict[str, Any]] = [] + for row in items if isinstance(items, list) else []: + try: + normalized.append( + { + 'match_id': str(row['id']), + 'home_team': normalize_team_name(str(row['home_team'])), + 'away_team': normalize_team_name(str(row['away_team'])), + 'market_type': str(row['market_type']), + 'selection': str(row['selection']), + 'decimal_odds': float(row['odds']), + 'bookmaker': str(row.get('bookmaker', '')), + }, + ) + except (KeyError, TypeError, ValueError): + continue + + return normalized + + async def run_once(self, cache: MatchCacheManager) -> int: + """單輪更新:抓取並寫入 Redis,回傳寫入比賽筆數。""" + + rows = await self.fetch_live_odds() + match_map: dict[str, list[dict[str, Any]]] = {} + for row in rows: + match_map.setdefault(row['match_id'], []).append(row) + + for match_id, payload in match_map.items(): + await cache.set_match_odds(match_id, payload, finished=False) + + return len(match_map) diff --git a/platform/deploy/k3s-manifests.yaml b/platform/deploy/k3s-manifests.yaml new file mode 100644 index 0000000..d408e92 --- /dev/null +++ b/platform/deploy/k3s-manifests.yaml @@ -0,0 +1,326 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: fifa2026 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: fifa2026-platform-config + namespace: fifa2026 +data: + APP_TIME_ZONE: Asia/Taipei + NEXT_PUBLIC_WS_URL: wss://2026fifa.wooo.work/ws/matches + NEXT_PUBLIC_API_ORIGIN: http://fifa2026-api:8000 + DATABASE_URL: postgresql://fifa_user:change_me@fifa2026-postgres:5432/fifa2026 + REDIS_URL: redis://fifa2026-redis:6379/0 +--- +apiVersion: v1 +kind: Secret +metadata: + name: fifa2026-secrets + namespace: fifa2026 +type: Opaque +stringData: + NEXTAUTH_SECRET: changeme-nextauth-secret + TELEGRAM_BOT_TOKEN: changeme-telegram-token + TELEGRAM_CHAT_ID: changeme-chat-id +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-postgres + namespace: fifa2026 +spec: + replicas: 1 + selector: + matchLabels: + app: fifa2026-postgres + template: + metadata: + labels: + app: fifa2026-postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + env: + - name: POSTGRES_DB + value: fifa2026 + - name: POSTGRES_USER + value: fifa_user + - name: POSTGRES_PASSWORD + value: change_me + ports: + - containerPort: 5432 + volumeMounts: + - name: pgdata + mountPath: /var/lib/postgresql/data + volumes: + - name: pgdata + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-postgres + namespace: fifa2026 +spec: + selector: + app: fifa2026-postgres + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-redis + namespace: fifa2026 +spec: + replicas: 1 + selector: + matchLabels: + app: fifa2026-redis + template: + metadata: + labels: + app: fifa2026-redis + spec: + containers: + - name: redis + image: redis:7-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-redis + namespace: fifa2026 +spec: + selector: + app: fifa2026-redis + ports: + - port: 6379 + targetPort: 6379 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-api + namespace: fifa2026 + labels: + app: fifa2026-api +spec: + replicas: 2 + selector: + matchLabels: + app: fifa2026-api + template: + metadata: + labels: + app: fifa2026-api + spec: + containers: + - name: api + image: ghcr.io/YOUR_ORG/2026fifa-backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: PORT + value: "8000" + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: REDIS_URL + - name: DATABASE_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: DATABASE_URL + - name: NEXTAUTH_SECRET + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: NEXTAUTH_SECRET + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 15 +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-api + namespace: fifa2026 +spec: + selector: + app: fifa2026-api + ports: + - port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-web + namespace: fifa2026 + labels: + app: fifa2026-web +spec: + replicas: 2 + selector: + matchLabels: + app: fifa2026-web + template: + metadata: + labels: + app: fifa2026-web + spec: + containers: + - name: web + image: ghcr.io/YOUR_ORG/2026fifa-web:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + env: + - name: NEXT_PUBLIC_WS_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: NEXT_PUBLIC_WS_URL + - name: NEXT_PUBLIC_API_ORIGIN + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: NEXT_PUBLIC_API_ORIGIN + - name: DATABASE_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: DATABASE_URL + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: REDIS_URL + - name: NEXTAUTH_SECRET + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: NEXTAUTH_SECRET + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 6 + periodSeconds: 8 + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 12 + periodSeconds: 12 +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-web + namespace: fifa2026 +spec: + selector: + app: fifa2026-web + ports: + - port: 3000 + targetPort: 3000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-alert-worker + namespace: fifa2026 + labels: + app: fifa2026-alert-worker +spec: + replicas: 1 + selector: + matchLabels: + app: fifa2026-alert-worker + template: + metadata: + labels: + app: fifa2026-alert-worker + spec: + containers: + - name: worker + image: ghcr.io/YOUR_ORG/2026fifa-alerts:latest + imagePullPolicy: IfNotPresent + env: + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: REDIS_URL + - name: TELEGRAM_BOT_TOKEN + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: TELEGRAM_BOT_TOKEN + - name: TELEGRAM_CHAT_ID + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: TELEGRAM_CHAT_ID +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fifa2026-ingress + namespace: fifa2026 + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - 2026fifa.wooo.work + secretName: fifa2026-tls + rules: + - host: 2026fifa.wooo.work + http: + paths: + - path: /ws/ + pathType: Prefix + backend: + service: + name: fifa2026-api + port: + number: 8000 + - path: /api/ + pathType: Prefix + backend: + service: + name: fifa2026-api + port: + number: 8000 + - path: / + pathType: Prefix + backend: + service: + name: fifa2026-web + port: + number: 3000 diff --git a/platform/deploy/k3s/alerts-deployment.yaml b/platform/deploy/k3s/alerts-deployment.yaml new file mode 100644 index 0000000..015b267 --- /dev/null +++ b/platform/deploy/k3s/alerts-deployment.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-alert-worker + namespace: fifa2026 +spec: + replicas: 1 + selector: + matchLabels: + app: fifa2026-alert-worker + template: + metadata: + labels: + app: fifa2026-alert-worker + spec: + containers: + - name: worker + image: ghcr.io/your-org/2026fifa-alerts:latest + env: + - name: REDIS_URL + value: redis://fifa2026-redis:6379/0 + - name: TELEGRAM_BOT_TOKEN + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: TELEGRAM_BOT_TOKEN + - name: TELEGRAM_CHAT_ID + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: TELEGRAM_CHAT_ID + diff --git a/platform/deploy/k3s/backend-deployment.yaml b/platform/deploy/k3s/backend-deployment.yaml new file mode 100644 index 0000000..076079e --- /dev/null +++ b/platform/deploy/k3s/backend-deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-api + namespace: fifa2026 +spec: + replicas: 2 + selector: + matchLabels: + app: fifa2026-api + template: + metadata: + labels: + app: fifa2026-api + spec: + containers: + - name: api + image: ghcr.io/your-org/2026fifa-backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: PORT + value: "8000" + - name: REDIS_URL + value: redis://fifa2026-redis:6379/0 + - name: DATABASE_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: DATABASE_URL + - name: NEXTAUTH_SECRET + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: NEXTAUTH_SECRET + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 15 +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-api + namespace: fifa2026 +spec: + selector: + app: fifa2026-api + ports: + - port: 8000 + targetPort: 8000 + diff --git a/platform/deploy/k3s/configmap.yaml b/platform/deploy/k3s/configmap.yaml new file mode 100644 index 0000000..6c1d065 --- /dev/null +++ b/platform/deploy/k3s/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: fifa2026-platform-config + namespace: fifa2026 +data: + APP_TIME_ZONE: Asia/Taipei + NEXT_PUBLIC_WS_URL: wss://2026fifa.wooo.work/ws/matches + NEXT_PUBLIC_API_ORIGIN: http://fifa2026-api:8000 + DATABASE_URL: postgresql://fifa_user:change_me@fifa2026-postgres:5432/fifa2026 + REDIS_URL: redis://fifa2026-redis:6379/0 diff --git a/platform/deploy/k3s/ingress.yaml b/platform/deploy/k3s/ingress.yaml new file mode 100644 index 0000000..8912a10 --- /dev/null +++ b/platform/deploy/k3s/ingress.yaml @@ -0,0 +1,40 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fifa2026-ingress + namespace: fifa2026 + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/ssl-redirect: "true" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - 2026fifa.wooo.work + secretName: fifa2026-tls + rules: + - host: 2026fifa.wooo.work + http: + paths: + - path: /ws/ + pathType: Prefix + backend: + service: + name: fifa2026-api + port: + number: 8000 + - path: /api/ + pathType: Prefix + backend: + service: + name: fifa2026-api + port: + number: 8000 + - path: / + pathType: Prefix + backend: + service: + name: fifa2026-web + port: + number: 3000 + diff --git a/platform/deploy/k3s/namespace.yaml b/platform/deploy/k3s/namespace.yaml new file mode 100644 index 0000000..825ed7a --- /dev/null +++ b/platform/deploy/k3s/namespace.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: fifa2026 + diff --git a/platform/deploy/k3s/postgres-deployment.yaml b/platform/deploy/k3s/postgres-deployment.yaml new file mode 100644 index 0000000..475de2b --- /dev/null +++ b/platform/deploy/k3s/postgres-deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-postgres + namespace: fifa2026 +spec: + replicas: 1 + selector: + matchLabels: + app: fifa2026-postgres + template: + metadata: + labels: + app: fifa2026-postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + env: + - name: POSTGRES_DB + value: fifa2026 + - name: POSTGRES_USER + value: fifa_user + - name: POSTGRES_PASSWORD + value: change_me + ports: + - containerPort: 5432 + volumeMounts: + - name: pgdata + mountPath: /var/lib/postgresql/data + volumes: + - name: pgdata + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-postgres + namespace: fifa2026 +spec: + selector: + app: fifa2026-postgres + ports: + - port: 5432 + targetPort: 5432 + diff --git a/platform/deploy/k3s/redis-deployment.yaml b/platform/deploy/k3s/redis-deployment.yaml new file mode 100644 index 0000000..d9cd888 --- /dev/null +++ b/platform/deploy/k3s/redis-deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-redis + namespace: fifa2026 +spec: + replicas: 1 + selector: + matchLabels: + app: fifa2026-redis + template: + metadata: + labels: + app: fifa2026-redis + spec: + containers: + - name: redis + image: redis:7-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-redis + namespace: fifa2026 +spec: + selector: + app: fifa2026-redis + ports: + - port: 6379 + targetPort: 6379 + diff --git a/platform/deploy/k3s/secret.yaml b/platform/deploy/k3s/secret.yaml new file mode 100644 index 0000000..ff53a8e --- /dev/null +++ b/platform/deploy/k3s/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: fifa2026-secrets + namespace: fifa2026 +type: Opaque +stringData: + NEXTAUTH_SECRET: changeme-nextauth-secret + TELEGRAM_BOT_TOKEN: changeme-telegram-token + TELEGRAM_CHAT_ID: changeme-chat-id + diff --git a/platform/deploy/k3s/web-deployment.yaml b/platform/deploy/k3s/web-deployment.yaml new file mode 100644 index 0000000..d514a79 --- /dev/null +++ b/platform/deploy/k3s/web-deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fifa2026-web + namespace: fifa2026 +spec: + replicas: 2 + selector: + matchLabels: + app: fifa2026-web + template: + metadata: + labels: + app: fifa2026-web + spec: + containers: + - name: web + image: ghcr.io/your-org/2026fifa-web:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + env: + - name: NEXTAUTH_SECRET + valueFrom: + secretKeyRef: + name: fifa2026-secrets + key: NEXTAUTH_SECRET + - name: DATABASE_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: DATABASE_URL + - name: REDIS_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: REDIS_URL + - name: NEXT_PUBLIC_WS_URL + valueFrom: + configMapKeyRef: + name: fifa2026-platform-config + key: NEXT_PUBLIC_WS_URL + +--- +apiVersion: v1 +kind: Service +metadata: + name: fifa2026-web + namespace: fifa2026 +spec: + selector: + app: fifa2026-web + ports: + - port: 3000 + targetPort: 3000 + diff --git a/platform/web/.env.example b/platform/web/.env.example new file mode 100644 index 0000000..e9483d0 --- /dev/null +++ b/platform/web/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://fifa_user:change_me@localhost:5432/fifa2026 +REDIS_URL=redis://localhost:6379/0 +NEXTAUTH_SECRET=replace-me +NEXTAUTH_URL=https://2026fifa.wooo.work +NEXT_PUBLIC_WS_URL=wss://2026fifa.wooo.work/ws/matches +NEXT_PUBLIC_API_ORIGIN=https://2026fifa.wooo.work/api + diff --git a/platform/web/Dockerfile b/platform/web/Dockerfile new file mode 100644 index 0000000..33dec94 --- /dev/null +++ b/platform/web/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +RUN npm run build + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["npm", "start"] + diff --git a/platform/web/app/api/analytics/backtest/route.ts b/platform/web/app/api/analytics/backtest/route.ts new file mode 100644 index 0000000..c55f0ad --- /dev/null +++ b/platform/web/app/api/analytics/backtest/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/backtest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '回測服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/daily-card/[date]/route.ts b/platform/web/app/api/analytics/daily-card/[date]/route.ts new file mode 100644 index 0000000..5ea4681 --- /dev/null +++ b/platform/web/app/api/analytics/daily-card/[date]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function GET(_: Request, { params }: { params: Promise<{ date: string }> }) { + try { + const { date } = await params; + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/daily-card/${date}`, { method: 'GET' }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '每日操盤卡服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/hedging/route.ts b/platform/web/app/api/analytics/hedging/route.ts new file mode 100644 index 0000000..fafb8be --- /dev/null +++ b/platform/web/app/api/analytics/hedging/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/hedging`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '對沖計算服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/kelly/route.ts b/platform/web/app/api/analytics/kelly/route.ts new file mode 100644 index 0000000..49948ee --- /dev/null +++ b/platform/web/app/api/analytics/kelly/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/kelly`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '凱利計算服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/match-conditions/route.ts b/platform/web/app/api/analytics/match-conditions/route.ts new file mode 100644 index 0000000..2efb741 --- /dev/null +++ b/platform/web/app/api/analytics/match-conditions/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/match-conditions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '比賽條件分析服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/matches/[matchId]/route.ts b/platform/web/app/api/analytics/matches/[matchId]/route.ts new file mode 100644 index 0000000..774f0fc --- /dev/null +++ b/platform/web/app/api/analytics/matches/[matchId]/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function GET(_: Request, { params }: { params: Promise<{ matchId: string }> }) { + try { + const { matchId } = await params; + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '賽事預測服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/matches/route.ts b/platform/web/app/api/analytics/matches/route.ts new file mode 100644 index 0000000..db35726 --- /dev/null +++ b/platform/web/app/api/analytics/matches/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function GET() { + try { + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '賽事列表服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/ml-edge/route.ts b/platform/web/app/api/analytics/ml-edge/route.ts new file mode 100644 index 0000000..96fc50e --- /dev/null +++ b/platform/web/app/api/analytics/ml-edge/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/ml-edge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : 'ML Edge 服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} + diff --git a/platform/web/app/api/analytics/ml-edge/train/route.ts b/platform/web/app/api/analytics/ml-edge/train/route.ts new file mode 100644 index 0000000..c1ba3d9 --- /dev/null +++ b/platform/web/app/api/analytics/ml-edge/train/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/ml-edge/train`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : 'ML Edge 訓練服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/player-props/route.ts b/platform/web/app/api/analytics/player-props/route.ts new file mode 100644 index 0000000..41c5e56 --- /dev/null +++ b/platform/web/app/api/analytics/player-props/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/player-props`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '玩家道具盤分析服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/portfolio/leaks/route.ts b/platform/web/app/api/analytics/portfolio/leaks/route.ts new file mode 100644 index 0000000..cdee8a9 --- /dev/null +++ b/platform/web/app/api/analytics/portfolio/leaks/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/portfolio/leaks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '投注漏財分析服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/proof-of-yield/ledger/route.ts b/platform/web/app/api/analytics/proof-of-yield/ledger/route.ts new file mode 100644 index 0000000..7498f52 --- /dev/null +++ b/platform/web/app/api/analytics/proof-of-yield/ledger/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function GET(req: NextRequest) { + try { + const limit = req.nextUrl.searchParams.get('limit') || '200'; + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/proof-of-yield/ledger?limit=${limit}`, { + method: 'GET', + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '公開帳本服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/proof-of-yield/route.ts b/platform/web/app/api/analytics/proof-of-yield/route.ts new file mode 100644 index 0000000..d28e09b --- /dev/null +++ b/platform/web/app/api/analytics/proof-of-yield/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ message: '請使用 /api/analytics/proof-of-yield/ledger 或 /api/analytics/proof-of-yield/settle' }, { status: 404 }); +} diff --git a/platform/web/app/api/analytics/proof-of-yield/settle/route.ts b/platform/web/app/api/analytics/proof-of-yield/settle/route.ts new file mode 100644 index 0000000..66e50fb --- /dev/null +++ b/platform/web/app/api/analytics/proof-of-yield/settle/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/proof-of-yield/settle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : '結算服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/analytics/rlm/route.ts b/platform/web/app/api/analytics/rlm/route.ts new file mode 100644 index 0000000..da5533e --- /dev/null +++ b/platform/web/app/api/analytics/rlm/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; + +export async function POST(req: NextRequest) { + try { + const body = await req.text(); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/rlm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!response.ok) { + const message = await response.text(); + return NextResponse.json({ message }, { status: response.status }); + } + + const data = (await response.json()) as Record; + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : 'RLM 服務暫時無法連線'; + return NextResponse.json({ message }, { status: 502 }); + } +} diff --git a/platform/web/app/api/auth/[...nextauth]/route.ts b/platform/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..dc9560d --- /dev/null +++ b/platform/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +import { handlers } from '@/lib/auth'; + +export const { GET, POST } = handlers; + diff --git a/platform/web/app/backtesting/page.tsx b/platform/web/app/backtesting/page.tsx new file mode 100644 index 0000000..b820072 --- /dev/null +++ b/platform/web/app/backtesting/page.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { EquityCurveChart } from '@/components/EquityCurveChart'; +import { runBacktest, type BacktestResponse, type BacktestTrade } from '@/lib/analytics-api'; + +type TradeRecord = { + id: string; + date: string; + odds: number; + isWin: boolean; + stake: number; + altitude: number; + handicap: number; + weather: string; + recentWinRate: number; + market: string; +}; + +const history: TradeRecord[] = [ + { id: 'T-001', date: '2026-01-05', odds: 1.95, isWin: true, stake: 100, altitude: 1700, handicap: -1, weather: '乾燥', recentWinRate: 0.56, market: '讓球' }, + { id: 'T-002', date: '2026-01-10', odds: 2.1, isWin: false, stake: 100, altitude: 1200, handicap: -0.5, weather: '潮濕', recentWinRate: 0.49, market: '讓球' }, + { id: 'T-003', date: '2026-01-12', odds: 1.82, isWin: true, stake: 100, altitude: 1600, handicap: 0, weather: '高溫', recentWinRate: 0.68, market: '大小分' }, + { id: 'T-004', date: '2026-01-19', odds: 2.25, isWin: true, stake: 100, altitude: 2100, handicap: -1.5, weather: '乾燥', recentWinRate: 0.62, market: '讓球' }, + { id: 'T-005', date: '2026-01-27', odds: 1.74, isWin: false, stake: 100, altitude: 2200, handicap: 0.5, weather: '低溫', recentWinRate: 0.44, market: '1x2' }, + { id: 'T-006', date: '2026-02-01', odds: 2.02, isWin: true, stake: 100, altitude: 1100, handicap: -0.5, weather: '中高濕', recentWinRate: 0.58, market: '讓球' }, + { id: 'T-007', date: '2026-02-09', odds: 1.91, isWin: true, stake: 100, altitude: 1800, handicap: -1, weather: '乾燥', recentWinRate: 0.61, market: '讓球' }, +]; + +function toLocalISOString(date: string): string { + return new Date(`${date}T15:00:00+08:00`).toISOString(); +} + +function fallbackRun(trades: TradeRecord[]) { + let capital = 10000; + const points: { ts: string; capital: number }[] = [{ ts: 'start', capital }]; + let wins = 0; + + for (const trade of trades) { + const pnl = trade.isWin ? trade.stake * (trade.odds - 1) : -trade.stake; + capital += pnl; + if (trade.isWin) wins += 1; + points.push({ ts: trade.date, capital: Number(capital.toFixed(2)) }); + } + + const hitRate = trades.length > 0 ? (wins / trades.length) * 100 : 0; + const stakeTotal = trades.length * 100; + const roi = stakeTotal > 0 ? ((capital - 10000) / stakeTotal) * 100 : 0; + const maxDrawdown = points.reduce((acc, point, idx) => { + const maxBefore = Math.max(...points.slice(0, idx + 1).map((item) => item.capital)); + const dd = ((maxBefore - point.capital) / maxBefore) * 100; + return Math.max(acc, dd); + }, 0); + + return { + matched: trades.length, + total: trades.length, + hit_count: wins, + win_rate: Number(hitRate.toFixed(4)), + final_capital: Number(capital.toFixed(4)), + net_profit: Number((capital - 10000).toFixed(4)), + roi_percent: Number(roi.toFixed(4)), + max_drawdown_percent: Number(maxDrawdown.toFixed(4)), + equity_curve: points, + } as BacktestResponse; +} + +export default function BacktestingPage() { + const [altMin, setAltMin] = useState(1400); + const [altMax, setAltMax] = useState(2200); + const [handicapMin, setHandicapMin] = useState(-2); + const [handicapMax, setHandicapMax] = useState(0); + const [weather, setWeather] = useState('全部'); + const [minRecentWinRate, setMinRecentWinRate] = useState(0.5); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [result, setResult] = useState({ + matched: 0, + total: 0, + hit_count: 0, + win_rate: 0, + final_capital: 10000, + net_profit: 0, + roi_percent: 0, + max_drawdown_percent: 0, + equity_curve: [{ ts: 'start', capital: 10000 }], + }); + + const filtered = useMemo(() => { + return history.filter((trade) => { + if (trade.altitude < altMin || trade.altitude > altMax) return false; + if (trade.handicap < handicapMin || trade.handicap > handicapMax) return false; + if (weather !== '全部' && trade.weather !== weather) return false; + if (trade.recentWinRate < minRecentWinRate) return false; + return true; + }); + }, [altMin, altMax, handicapMin, handicapMax, weather, minRecentWinRate]); + + const requestPayload = useMemo( + () => ({ + initial_capital: 10000, + strategy: { + weather: weather === '全部' ? null : weather, + altitude_min_meters: altMin, + altitude_max_meters: altMax, + handicap_min: handicapMin, + handicap_max: handicapMax, + recent_win_rate_min: minRecentWinRate, + recent_win_rate_max: null, + market_types: null, + start_at: null, + end_at: null, + }, + historical_trades: history.map((trade) => ({ + trade_id: trade.id, + settled_at: toLocalISOString(trade.date), + odds: trade.odds, + is_win: trade.isWin, + stake: trade.stake, + altitude_meters: trade.altitude, + handicap: trade.handicap, + weather: trade.weather, + recent_form_win_rate: trade.recentWinRate, + market_type: trade.market, + selection: 'home', + })), + }), + [altMin, altMax, handicapMin, handicapMax, weather, minRecentWinRate], + ); + + useEffect(() => { + let active = true; + setLoading(true); + + const execute = async () => { + try { + const data = await runBacktest(requestPayload); + if (active) { + setResult(data); + setErrorMessage(''); + } + } catch (error) { + if (!active) return; + setErrorMessage(error instanceof Error ? error.message : '回測服務無法連線,改用本機試算'); + setResult(fallbackRun(filtered)); + } finally { + if (active) { + setLoading(false); + } + } + }; + + execute(); + return () => { + active = false; + }; + }, [filtered, requestPayload]); + + return ( +
+

自訂策略回測引擎

+ +
+

策略條件設定

+
+ + + + + + +
+
+ +
+
+

樣本數

+

{result.matched}

+
+
+

勝率

+

{result.win_rate.toFixed(1)}%

+
+
+

ROI

+

{result.roi_percent.toFixed(2)}%

+
+
+ +
+

+ 命中:{result.hit_count} / {result.total} + {' '} + | 最終資金:{result.final_capital.toFixed(2)} + | 淨利:{result.net_profit.toFixed(2)} +

+ {loading ?

回測引擎計算中…

: null} + {errorMessage ?

{errorMessage}

: null} +
+ + + +
+

交易明細(依條件)

+
    + {filtered.map((trade) => ( +
  • + {trade.id}|{trade.date}|{trade.market}|讓球 {trade.handicap}|海拔 {trade.altitude}m| + {trade.isWin ? '勝' : '敗'}|賠率 {trade.odds} +
  • + ))} + {filtered.length === 0 ?
  • 此條件下目前無筆交易
  • : null} +
+
+
+ ); +} diff --git a/platform/web/app/daily-card/page.tsx b/platform/web/app/daily-card/page.tsx new file mode 100644 index 0000000..c676c82 --- /dev/null +++ b/platform/web/app/daily-card/page.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { formatToTaipeiTime } from '@/lib/timezone'; +import { getDailyCard, type DailyCardItem } from '@/lib/analytics-api'; +import { ActionableBetCard } from '@/components/ActionableBetCard'; + +const TAB_MAP: Record<'safe' | 'risk' | 'parlay' | 'sgp', string> = { + safe: 'SAFE_SINGLE', + risk: 'HIGH_RISK_SINGLE', + parlay: 'SAFE_PARLAY', + sgp: 'SGP_LOTTERY', +}; + +const sampleDate = formatToTaipeiTime(new Date().toISOString(), 'yyyy-MM-dd'); + +export default function DailyCardPage() { + const [targetDate] = useState(sampleDate); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [activeTab, setActiveTab] = useState<'safe' | 'risk' | 'parlay' | 'sgp'>('safe'); + const [selectedCount, setSelectedCount] = useState(0); + const [data, setData] = useState> | null>(null); + + const tabCards: Record<'safe' | 'risk' | 'parlay' | 'sgp', DailyCardItem[]> = useMemo(() => { + if (!data) { + return { safe: [], risk: [], parlay: [], sgp: [] }; + } + + return { + safe: data.safe_singles, + risk: data.high_risk_singles, + parlay: data.safe_parlays, + sgp: data.sgp_lotteries, + }; + }, [data]); + + const briefing = useMemo(() => { + if (!data) { + return '系統載入中,將於短時間內給出當日全場賽前簡報。'; + } + + return `AI 總結:今日比賽共 ${data.matched_matches} 場,策略核心為 ${ + data.total_daily_unit_recommendation + } Units。${ + data.safe_singles.length > 0 + ? `檢測到 ${data.safe_singles.length} 組高穩定單關機會,重點偏向亞盤與低風險連續進位。` + : '低風險盤口偏弱,建議保守減碼。' + } ${ + data.high_risk_singles.length > 0 + ? `另有 ${data.high_risk_singles.length} 組高賠搏冷訊號可做小額槓桿。` + : '' + }`; + }, [data]); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(''); + + try { + const response = await getDailyCard(targetDate); + setData(response); + } catch (payloadError) { + setError(payloadError instanceof Error ? payloadError.message : '每日作戰卡暫時無法抓取'); + } finally { + setLoading(false); + } + }; + + load().catch(() => undefined); + }, [targetDate]); + + function handleAddToSlip(item: DailyCardItem) { + setSelectedCount((count) => count + 1); + // 未建立後續注單 API,先保留為前端選單暫存 + window.alert(`已加入注單追蹤:${item.match_label} | ${item.selection}`); + } + + return ( +
+
+

每日操盤戰情室

+

總曝險單位:{data ? data.total_daily_unit_recommendation.toFixed(2) : '-'}

+

日期:{targetDate}

+

{briefing}

+

已加入注單:{selectedCount} 單

+
+ +
+

Hard Filters

+
+ + + + +
+
+ + {loading ?

載入中...

: null} + {error ?

{error}

: null} + +
+ {tabCards[activeTab].map((item) => ( + + ))} + + {!loading && tabCards[activeTab].length === 0 ? ( +

目前此策略區塊沒有符合條件的建議。

+ ) : null} +
+
+ ); +} diff --git a/platform/web/app/deep-bet/page.tsx b/platform/web/app/deep-bet/page.tsx new file mode 100644 index 0000000..90ef7d6 --- /dev/null +++ b/platform/web/app/deep-bet/page.tsx @@ -0,0 +1,27 @@ +import { QuickBetButton } from '@/components/QuickBetButton'; + +export default function DeepBetPage() { + const matchId = 'FIFA2026-FR-PA03'; + const selection = '德國勝'; + return ( +
+

一鍵投注深度連結(Deep Linking)

+
+

+ 根據比賽與下注口袋,產生各大博彩公司可直接帶入投注單的快速連結,含追蹤碼(Affiliate)與 + 即時金額提示。可直接跳轉至賠率已預選頁,減少下注落地時間。 +

+
+ +
+

場次

+

{matchId}|{selection}

+
+ + + +
+
+
+ ); +} diff --git a/platform/web/app/globals.css b/platform/web/app/globals.css new file mode 100644 index 0000000..c2c038e --- /dev/null +++ b/platform/web/app/globals.css @@ -0,0 +1,91 @@ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;700;900&family=VT323&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --font-body: 'Noto Sans TC'; + --font-matrix: 'VT323'; + --warm-bg: #f6f0e1; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.55), transparent 35%), + radial-gradient(circle at 80% 15%, rgba(209, 67, 45, 0.12), transparent 35%), + #f6f0e1; + color: #2a2018; +} + +* { + box-sizing: border-box; +} + +body { + font-family: var(--font-body), Arial, sans-serif; +} + +.dashboard-shell { + min-height: 100vh; + background: linear-gradient(140deg, rgba(255, 255, 255, 0.65), rgba(246, 240, 225, 0.85)); +} + +.panel-glow { + border: 1px solid rgba(255, 255, 255, 0.5); + background: linear-gradient(170deg, rgba(255, 248, 230, 0.86), rgba(246, 240, 225, 0.88)); + box-shadow: 0 20px 45px rgba(95, 53, 44, 0.1); +} + +.dot-matrix { + font-family: var(--font-matrix), monospace; + letter-spacing: 0.09em; +} + +.status-led { + width: 0.6rem; + height: 0.6rem; + border-radius: 9999px; + display: inline-block; +} + +.status-led-ok { background: #1a9a57; } +.status-led-warn { background: #dcb53b; } +.status-led-danger { background: #d1432d; } + +@keyframes top-edge-glow { + 0% { + box-shadow: 0 0 0 rgba(235, 90, 40, 0.0), 0 0 0 rgba(235, 90, 40, 0.0); + border-color: rgba(188, 68, 43, 0.7); + } + 50% { + box-shadow: 0 0 18px rgba(235, 90, 40, 0.35), 0 0 36px rgba(235, 90, 40, 0.2); + border-color: rgba(235, 90, 40, 1); + } + 100% { + box-shadow: 0 0 0 rgba(235, 90, 40, 0), 0 0 0 rgba(235, 90, 40, 0); + border-color: rgba(188, 68, 43, 0.9); + } +} + +.prop-top-edge { + animation: top-edge-glow 1.8s ease-in-out infinite; +} + +@keyframes dot-matrix-pulse { + 0%, 100% { + opacity: 0.35; + transform: translateY(0); + } + 50% { + opacity: 1; + transform: translateY(-2px); + } +} + +.dot-matrix-loading { + animation: dot-matrix-pulse 1s steps(1) infinite; +} diff --git a/platform/web/app/kelly/page.tsx b/platform/web/app/kelly/page.tsx new file mode 100644 index 0000000..3b56907 --- /dev/null +++ b/platform/web/app/kelly/page.tsx @@ -0,0 +1,16 @@ +import { BetSizingSlider } from '@/components/BetSizingSlider'; + +export default function KellyPage() { + return ( +
+

凱利準則(Kelly Criterion)與下注分配

+
+

+ 以「凱利準則」為核心,以市場期望值為主動態資金控管;系統同時支援分數凱利(0.25x、0.5x、0.75x) + 與風險容忍度調節,讓你不只知道該下、還知道「下多少」。 +

+
+ +
+ ); +} diff --git a/platform/web/app/layout.tsx b/platform/web/app/layout.tsx new file mode 100644 index 0000000..8cc6fef --- /dev/null +++ b/platform/web/app/layout.tsx @@ -0,0 +1,67 @@ +import Link from 'next/link'; +import './globals.css'; +import type { ReactNode } from 'react'; +import type { Metadata } from 'next'; +import { MobileBottomNav } from '@/components/MobileBottomNav'; +import { PwaBootstrap } from '@/components/PwaBootstrap'; + +const navItems = [ + { href: '/', label: '主控總覽' }, + { href: '/matches', label: '賽事中心' }, + { href: '/odds', label: '跨平台賠率比較' }, + { href: '/sharp-money', label: '聰明錢追蹤' }, + { href: '/ml-edge', label: 'ML Ensemble' }, + { href: '/models', label: '量化模型' }, + { href: '/match-conditions', label: '裁判/天候模型' }, + { href: '/rlm', label: 'RLM 反向盤口' }, + { href: '/proof-of-yield', label: '公開收益帳本' }, + { href: '/props', label: '球員道具盤' }, + { href: '/kelly', label: '凱利下注' }, + { href: '/backtesting', label: '策略回測' }, + { href: '/deep-bet', label: '一鍵投注' }, + { href: '/daily-card', label: '每日作戰室' }, + { href: '/portfolio', label: '個人組合' }, +]; + +export const metadata: Metadata = { + title: '2026 FIFA 專業投注研究中心', + description: '以台北時區驅動的全站即時投注資料與量化分析平台', + manifest: '/manifest.json', + themeColor: '#f6f0e1', + other: { + 'application-name': '2026 World Cup Quantum Ops', + }, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + +
+
+
+
+

2026 World Cup Quantum Ops

+

台北時間(UTC+8) | 台灣賭盤實戰研究版

+
+ +
+
+
{children}
+
+ + + + ); +} diff --git a/platform/web/app/match-conditions/page.tsx b/platform/web/app/match-conditions/page.tsx new file mode 100644 index 0000000..614cfe6 --- /dev/null +++ b/platform/web/app/match-conditions/page.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useState } from 'react'; +import { + analyzeMatchConditions, + type MatchConditionRequestPayload, + type MatchConditionResponse, +} from '@/lib/analytics-api'; +import { MatchConditionsCard } from '@/components/MatchConditionsCard'; + +export default function MatchConditionsPage() { + const [matchId, setMatchId] = useState('MEX-2026-GRP-D01'); + const [avgYellow, setAvgYellow] = useState(5.2); + const [penaltiesPerGame, setPenaltiesPerGame] = useState(0.35); + const [cardsLine, setCardsLine] = useState(4.5); + const [temperature, setTemperature] = useState(34); + const [humidity, setHumidity] = useState(71); + const [altitude, setAltitude] = useState(2240); + const [homeAttack, setHomeAttack] = useState(1.85); + const [awayAttack] = useState(1.72); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [result, setResult] = useState(null); + + async function runConditionAnalysis() { + setLoading(true); + setErrorMessage(''); + try { + const payload: MatchConditionRequestPayload = { + match_id: matchId, + avg_yellow_cards: avgYellow, + penalties_per_game: penaltiesPerGame, + cards_ou_line: cardsLine, + temp_c: temperature, + humidity_pct: humidity, + venue_altitude_meters: altitude, + home_second_half_attack: homeAttack, + away_second_half_attack: awayAttack, + }; + const data = await analyzeMatchConditions(payload); + setResult(data); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '比賽條件分析暫時中斷'); + } finally { + setLoading(false); + } + } + + return ( +
+

裁判與天候條件量化

+
+

條件輸入

+
+ + + + + + + + +
+
+ +
+ {errorMessage ?

{errorMessage}

: null} +
+ + {result ? ( + + ) : null} +
+ ); +} + diff --git a/platform/web/app/matches/[matchId]/page.tsx b/platform/web/app/matches/[matchId]/page.tsx new file mode 100644 index 0000000..eb234e8 --- /dev/null +++ b/platform/web/app/matches/[matchId]/page.tsx @@ -0,0 +1,375 @@ +import { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { format } from 'date-fns'; +import { OddsLineMovementChart } from '@/components/OddsLineMovementChart'; +import { MatchConditionsCard } from '@/components/MatchConditionsCard'; +import type { + MatchConditionsReadout, + MatchDetail, + MatchListItem, + MatchOddsPoint, + MatchPoisson, +} from '@/lib/analytics-api'; +import { formatToTaipeiTime } from '@/lib/timezone'; + +export const revalidate = 60; + +const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +async function fetchMatchList(): Promise { + try { + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { + headers: { 'Content-Type': 'application/json' }, + cache: 'force-cache', + next: { revalidate: 60 }, + }); + + if (!response.ok) { + return []; + } + + return (await response.json()) as MatchListItem[]; + } catch { + return []; + } +} + +async function fetchMatchDetail(matchId: string): Promise { + try { + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, { + headers: { 'Content-Type': 'application/json' }, + cache: 'force-cache', + next: { revalidate: 60 }, + }); + + if (!response.ok) { + return null; + } + + return (await response.json()) as MatchDetail; + } catch { + return null; + } +} + +function fallbackSummary(detail: MatchDetail): string { + const { home_team: home, away_team: away, poisson, conditions } = detail; + const homeWin = (poisson.one_x_two.home_win * 100).toFixed(1); + const draw = (poisson.one_x_two.draw * 100).toFixed(1); + const awayWin = (poisson.one_x_two.away_win * 100).toFixed(1); + const overProb = (poisson.over_under_2_5.over * 100).toFixed(1); + const underProb = (poisson.over_under_2_5.under * 100).toFixed(1); + + return `【量化總結】 +${home} vs ${away} 的 1x2 機率分佈顯示主勝 ${homeWin}%,平局 ${draw}%,客勝 ${awayWin}%。 +Poisson 預測給出主隊 ${poisson.expected_home_goals.toFixed(2)} / 客隊 ${poisson.expected_away_goals.toFixed(2)} 的進球走勢。 +賽事在 2.5 球上下行為上,Over 機率 ${overProb}%,Under 機率 ${underProb}%。 +條件面顯示裁判嚴厲度 ${conditions.strictness_index.toFixed(1)},熱指數 ${conditions.heat_index.toFixed(1)}, +若出現高張力紅黃牌壓力,建議觀察 1x2 讓盤與 Cards/Under 結構,避免逆風追買。`; +} + +async function buildQuantSummaryWithLLM(detail: MatchDetail): Promise { + const fallback = fallbackSummary(detail); + + if (!OPENAI_API_KEY) { + return fallback; + } + + try { + const prompt = `請以中文繁體為台灣使用者生成 300 字的世界盃賽前量化總結,嚴格使用專業投注語氣,不超過 420 字。 + 請使用以下數據: +- 主場 ${detail.home_team}:xG ${detail.home_xg.toFixed(2)} +- 客場 ${detail.away_team}:xG ${detail.away_xg.toFixed(2)} +- 1x2 機率: 主勝 ${detail.poisson.one_x_two.home_win.toFixed(4)}、平 ${detail.poisson.one_x_two.draw.toFixed(4)}、客勝 ${detail.poisson.one_x_two.away_win.toFixed(4)} +- Over/Under(2.5): ${(detail.poisson.over_under_2_5.over * 100).toFixed(1)} / ${(detail.poisson.over_under_2_5.under * 100).toFixed(1)} +- 裁判嚴厲度 ${detail.conditions.strictness_index.toFixed(1)},Heat Index ${detail.conditions.heat_index.toFixed(1)},下半場主/客攻擊 ${detail.conditions.second_half_home_attack.toFixed(2)} / ${detail.conditions.second_half_away_attack.toFixed(2)}。`; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: '你是精通運彩與量化投注的專業分析師。請只輸出純文字,不要 markdown。', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: 0.3, + }), + }); + + if (!response.ok) { + return fallback; + } + + const payload = await response.json(); + const text = payload?.choices?.[0]?.message?.content; + return typeof text === 'string' && text.trim().length > 0 ? text.trim() : fallback; + } catch { + return fallback; + } +} + +type Params = { matchId: string }; + +export async function generateStaticParams(): Promise> { + const matches = await fetchMatchList(); + return matches + .filter((item) => Boolean(item.match_id)) + .map((item) => ({ matchId: item.match_id })); +} + +export async function generateMetadata({ params }: { params: Promise }): Promise { + const { matchId } = await params; + const match = await fetchMatchDetail(matchId); + + const title = match + ? `[資料驅動] 2026世界盃:${match.home_team} vs ${match.away_team} 預測、傷停與聰明錢流向分析` + : `2026 世界盃賽事預測`; + + const description = match + ? `量化模型基於 xG、裁判與天候模型產出「${match.home_team} vs ${match.away_team}」完整賠率走勢與下注偏差訊號,含 2.5 球與波膽機率。` + : '2026 世界盃賽事量化預測、聰明錢流向與賠率走勢頁。'; + + return { + title, + description, + openGraph: { + type: 'article', + title, + description, + locale: 'zh_TW', + siteName: '2026 FIFA Quantum Ops', + }, + }; +} + +function buildScoreMatrixRows(scoreMatrix: MatchPoisson['score_matrix']) { + return scoreMatrix.slice(0, 6).map((row, homeGoals) => ({ + homeGoals, + values: row.slice(0, 6), + })); +} + +function buildOddsChartRows(points: MatchOddsPoint[]) { + return points + .filter((row) => row.market_type === '1x2') + .map((row) => ({ + time: format(new Date(row.recorded_at), 'HH:mm:ss'), + bookmaker: row.bookmaker, + odds: Number(row.decimal_odds), + })); +} + +function buildSportsEventJsonLd(detail: MatchDetail) { + return { + '@context': 'https://schema.org', + '@type': 'SportsEvent', + name: `${detail.home_team} vs ${detail.away_team} 預測頁`, + startDate: detail.match_time_utc, + homeTeam: { + '@type': 'SportsTeam', + name: detail.home_team, + }, + awayTeam: { + '@type': 'SportsTeam', + name: detail.away_team, + }, + location: { + '@type': 'Place', + name: detail.venue_name, + address: { + '@type': 'PostalAddress', + addressLocality: detail.venue_city, + addressCountry: detail.venue_country, + }, + }, + description: detail.quant_summary, + aggregateRating: { + '@type': 'AggregateRating', + ratingValue: (detail.poisson.one_x_two.home_win * 100).toFixed(1), + bestRating: '100', + worstRating: '0', + ratingCount: 1, + }, + }; +} + +function buildDatasetJsonLd(detail: MatchDetail) { + return { + '@context': 'https://schema.org', + '@type': 'Dataset', + name: `${detail.home_team} vs ${detail.away_team} 量化推論資料集`, + description: `2026世界盃賽事模型資料。包含主客 xG、賠率時序、裁判與熱指數、波膽機率。`, + creator: { + '@type': 'Organization', + name: '2026 FIFA Quantum Ops', + }, + license: 'Proprietary', + temporalCoverage: detail.match_time_utc, + variableMeasured: [ + { + '@type': 'PropertyValue', + name: 'xG', + value: `${detail.home_xg.toFixed(2)} / ${detail.away_xg.toFixed(2)}`, + }, + { + '@type': 'PropertyValue', + name: 'Poisson 1X2', + value: JSON.stringify(detail.poisson.one_x_two), + }, + { + '@type': 'PropertyValue', + name: 'Heat Index', + value: detail.conditions.heat_index, + }, + ], + }; +} + +export default async function MatchDetailPage({ params }: { params: Promise }) { + const { matchId } = await params; + const detail = await fetchMatchDetail(matchId); + + if (!detail) { + notFound(); + } + + const quantSummary = await buildQuantSummaryWithLLM(detail); + const oddsRows = buildOddsChartRows(detail.odds_series); + const scoreRows = buildScoreMatrixRows(detail.poisson.score_matrix); + const kickoffLocal = formatToTaipeiTime(detail.match_time_utc, 'yyyy-MM-dd HH:mm:ss'); + const isFinished = detail.status === 'finished'; + + const conditions: MatchConditionsReadout = detail.conditions; + + const sportsEventJsonLd = JSON.stringify(buildSportsEventJsonLd(detail)); + const datasetJsonLd = JSON.stringify(buildDatasetJsonLd(detail)); + + return ( +
+ + + diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..cd562d8 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1832 @@ +const $ = (sel) => document.querySelector(sel); + +const state = { + matches: [], + analysis: null, + schedule: null, + sourceRegistry: [], + marketMatrix: null, + sharpMoney: null, + quantitative: null, + portfolio: null, + liveCenter: null, + lineMovement: null, + todayInsights: null, + quantitativeMatchId: '', + filter: 'all', +}; +const CURRENT_PAGE = (document.body.dataset.page || 'home').toLowerCase(); +const APP_TZ = 'Asia/Taipei'; + +function fmtTime(raw) { + if (!raw) return '-'; + return new Date(raw).toLocaleString('zh-Hant-TW', { + timeZone: APP_TZ, + hour12: false, + }); +} + +function fmtDate(raw) { + if (!raw) return '-'; + return new Date(raw).toLocaleDateString('zh-Hant-TW', { + timeZone: APP_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +function fmtDateLabel(raw) { + if (!raw) return ''; + return new Intl.DateTimeFormat('en-CA', { + timeZone: APP_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date(raw)); +} + +function pct(v) { + if (!Number.isFinite(v)) return '-'; + return `${(v * 100).toFixed(1)}%`; +} + +function pctText(v) { + if (!Number.isFinite(v)) return '-'; + return `${(v * 100).toFixed(2)}%`; +} + +function ratio(v, decimals = 2) { + if (!Number.isFinite(v)) return '-'; + return Number(v).toFixed(decimals); +} + +function pctFromRate(v, decimals = 2) { + if (!Number.isFinite(v)) return '-'; + return `${(v * 100).toFixed(decimals)}%`; +} + +function classifyRecommendationTier(probability = 0, confidence = 0) { + const p = clamp(Number(probability), 0, 1); + const c = clamp(Number(confidence), 0, 1); + const score = (0.68 * p) + (0.32 * c); + if (score >= 0.72) return 'high'; + if (score >= 0.54) return 'medium'; + return 'low'; +} + +function clamp(v, min = 0, max = 1) { + if (!Number.isFinite(v)) return min; + return Math.max(min, Math.min(max, v)); +} + +function money(v) { + if (!Number.isFinite(v)) return '-'; + return `${Number(v).toFixed(2)}`; +} + +function toFixed(v, decimals = 2) { + if (!Number.isFinite(v)) return '-'; + return Number(v).toFixed(decimals); +} + +function safeText(v) { + if (v === undefined || v === null || v === '') return '-'; + return String(v); +} + +function newsSignalText(v, asPercent = false) { + if (!Number.isFinite(v) || v === 0) return '中性'; + const abs = Math.abs(v); + const base = abs <= 1 ? v * 100 : v; + const direction = v > 0 ? '偏利好' : '偏保守'; + return asPercent ? `${direction} ${base.toFixed(2)}%` : `${direction} ${base.toFixed(2)}`; +} + +function asArray(v) { + return Array.isArray(v) ? v : []; +} + +function renderStatus(text, type = 'ok') { + const status = $('#status'); + if (!status) return; + status.textContent = text || ''; + status.className = `status ${type}`; +} + +function filterMarkets(match, filter) { + if (filter === 'all') return true; + if (!Array.isArray(match.marketSummary)) return false; + return match.marketSummary.some((s) => s.market === filter); +} + +function renderSummaryCards(totalMatches) { + const el = $('#summaryCards'); + if (!el) return; + const highCount = state.analysis?.highProbabilitySingles ? state.analysis.highProbabilitySingles.length : 0; + const mediumCount = state.analysis?.mediumProbabilitySingles ? state.analysis.mediumProbabilitySingles.length : 0; + const lowCount = state.analysis?.lowProbabilitySingles ? state.analysis.lowProbabilitySingles.length : 0; + const upsetCount = state.analysis?.upsetSignals ? state.analysis.upsetSignals.length : 0; + el.innerHTML = ` +
+

賽事總場數

+

${totalMatches}

+
+
+

最後更新

+

${state.analysis ? (state.analysis.generatedAtTaipei || state.analysis.generatedAt || '-') : '-'}

+
+
+

高勝率單場

+

${highCount}

+
+
+

中勝率單場

+

${mediumCount}

+
+
+

低勝率單場

+

${lowCount}

+
+
+

爆冷候選

+

${upsetCount}

+
+
+

新聞熱點場次(48h)

+

${state.schedule && state.schedule.hotNewsWithin48h ? state.schedule.hotNewsWithin48h.length : '-'}

+
+ `; +} + +function renderMiniBars(containerId, rows = [], maxLabelLen = 18) { + const container = $(`#${containerId}`); + if (!container) return; + if (!rows.length) { + container.innerHTML = '

尚無資料

'; + return; + } + const maxValue = rows.reduce((acc, r) => Math.max(acc, Number(r.value) || 0), 0); + const safeMax = Math.max(maxValue, 1); + container.innerHTML = rows + .map((row) => { + const safeValue = Number(row.value) || 0; + const width = `${((safeValue / safeMax) * 100).toFixed(1)}%`; + const label = String(row.label || '').slice(0, maxLabelLen); + const color = row.color || 'var(--accent)'; + return ` +
+
${label}
+
+ +
+
${safeValue}
+
+ `; + }) + .join(''); +} + +function renderRecommendRows(items = [], max = 3) { + if (!items.length) return '
  • 目前無建議
  • '; + return items + .slice(0, max) + .map( + (r) => ` +
  • + ${r.market} - ${r.selection} +
    賠率: ${money(r.odds)} | 勝率: ${pct(r.probability)} | 信心: ${(Number(r.confidence) * 100).toFixed(0)}% | EV: ${money(r.expectedValue)} | Kelly: ${ratio(r.kellyFraction, 2)} | ${r.recommendationTier ? r.recommendationTier : 'low'}
    +
    模型: ${r.modelGrade || '-'} | 價值邊際: ${pctText(r.valueEdge)}
    +
    ${r.rationale}
    +
  • `, + ) + .join(''); +} + +function renderUpsetRows(items = [], max = 3) { + if (!items.length) return '
  • 目前無明顯爆冷信號
  • '; + return items + .slice(0, max) + .map((r) => { + const risk = r.riskLabel || 'low'; + const riskClass = risk === 'high' ? 'high' : risk === 'medium' ? 'medium' : 'low'; + return ` +
  • + ${r.selection} + ${risk === 'high' ? '高風險' : risk === 'medium' ? '中風險' : '低風險'} +
    賠率: ${money(r.odds)} | 爆冷機率: ${pct(r.probability)} | 主流偏好: ${pct(r.favoriteProbability)} (${r.competitorOutcome})
    +
    EV: ${money(r.expectedValue)} | Kelly: ${ratio(r.kellyFraction, 2)}
    +
    ${r.rationale}
    +
  • `; + }) + .join(''); +} + +function marketGroup(market = '') { + const key = String(market).toLowerCase(); + if (key.includes('1x2')) return 'oneX2'; + if (key.includes('double chance')) return 'doubleChance'; + if (key.includes('handicap')) return 'handicap'; + if (key.includes('totals')) return 'totals'; + if (key.includes('both teams') || key.includes('btts')) return 'btts'; + return 'other'; +} + +function probabilityBand(p) { + const value = Number.isFinite(p) ? p : 0; + if (value >= 0.70) return { key: 'high', label: '穩健', cls: 'risk-high' }; + if (value >= 0.56) return { key: 'medium', label: '平衡', cls: 'risk-medium' }; + return { key: 'low', label: '高波動', cls: 'risk-low' }; +} + +function safePlaybookRows(value) { + return Array.isArray(value) ? value : []; +} + +function buildSinglePlaybookPool() { + const perMatch = state.analysis?.perMatch || []; + const buckets = { + all: [], + oneX2: [], + handicap: [], + totals: [], + btts: [], + doubleChance: [], + }; + + for (const match of perMatch) { + const source = [ + match.topRecommendation ? { ...match.topRecommendation } : null, + ...(match.recommendationBuckets?.high || []), + ...(match.recommendationBuckets?.medium || []), + ...(match.recommendationBuckets?.low || []), + ].filter(Boolean); + + const seen = new Set(); + for (const rec of source) { + const market = rec.market || ''; + const key = `${match.matchId}-${market}-${rec.selection}`; + if (seen.has(key)) continue; + seen.add(key); + const payload = { + ...rec, + matchId: match.matchId, + teams: match.teams, + kickoffAt: match.kickoffAt, + recommendationTier: + rec.recommendationTier || classifyRecommendationTier(Number.isFinite(rec.probability) ? rec.probability : 0, Number.isFinite(rec.confidence) ? rec.confidence : 0), + }; + buckets.all.push(payload); + + const group = marketGroup(market); + if (group !== 'other' && buckets[group]) { + buckets[group].push(payload); + } + } + } + + const sortByValue = (a, b) => { + const av = Number.isFinite(a.expectedRoiPercent) ? a.expectedRoiPercent : (Number.isFinite(a.expectedValue) ? a.expectedValue * 100 : -999); + const bv = Number.isFinite(b.expectedRoiPercent) ? b.expectedRoiPercent : (Number.isFinite(b.expectedValue) ? b.expectedValue * 100 : -999); + if (bv !== av) return bv - av; + return (b.confidence || 0) - (a.confidence || 0); + }; + + for (const k of Object.keys(buckets)) { + buckets[k] = (buckets[k] || []).sort(sortByValue); + } + return buckets; +} + +function renderSingleUniverse(containerId, recs = [], max = 8) { + const el = $(`#${containerId}`); + if (!el) return; + if (!recs.length) { + el.innerHTML = '

    目前無可用單場建議

    '; + return; + } + + el.innerHTML = recs + .slice(0, max) + .map((r, idx) => { + const band = probabilityBand(Number(r.probability)); + return ` +
  • +

    #${idx + 1} ${r.market} ${r.selection}

    +

    + 場次:${r.teams} · ${r.kickoffAtTaipei || fmtTime(r.kickoffAt)} · + 賠率 ${money(r.odds)} · 勝率 ${pct(r.probability)} · EV ${(Number.isFinite(r.expectedRoiPercent) ? `${r.expectedRoiPercent.toFixed(2)}%` : '-')} + ${r.recommendationTier || '-'} +

    +

    ${band.label} Kelly ${ratio(r.kellyFraction, 2)} · 價值邊際 ${pctText(r.valueEdge)}

    +

    ${r.rationale || ''}

    +
  • + `; + }) + .join(''); +} + +function renderSinglePlaybook() { + const pb = state.analysis?.professionalPlaybook?.singles; + const pool = pb + ? { + all: safePlaybookRows(pb.all), + oneX2: safePlaybookRows(pb.oneX2), + handicap: safePlaybookRows(pb.handicap), + totals: safePlaybookRows(pb.totals), + btts: safePlaybookRows(pb.btts), + doubleChance: safePlaybookRows(pb.doubleChance), + } + : buildSinglePlaybookPool(); + renderSingleUniverse('singleUniverse', pool.all, 14); + renderSingleUniverse('singleOneX2', pool.oneX2, 10); + renderSingleUniverse('singleHandicap', pool.handicap, 10); + renderSingleUniverse('singleTotals', pool.totals, 10); + renderSingleUniverse('singleBtts', pool.btts, 10); + renderSingleUniverse('singleDoubleChance', pool.doubleChance, 10); +} + +function rankComboByStyle(rows = [], mode = 'balanced') { + const safeRows = Array.isArray(rows) ? rows : []; + const base = [...safeRows]; + if (mode === 'conservative') { + return base + .filter((row) => { + const legs = Array.isArray(row.legs) ? row.legs : []; + if (!legs.length) return false; + const minConf = Math.min(...legs.map((l) => (Number.isFinite(l.confidence) ? l.confidence : 0))); + return Number.isFinite(row.hitProbability) && row.hitProbability >= 0.04 && minConf >= 0.72 && row.expectedRoi > 0; + }) + .sort((a, b) => b.hitProbability - a.hitProbability) + .slice(0, 8); + } + if (mode === 'value') { + return base + .filter((row) => Number.isFinite(row.expectedRoi) && row.expectedRoi >= 0.02 && Number.isFinite(row.hitProbability)) + .sort((a, b) => (b.expectedRoi || 0) - (a.expectedRoi || 0)) + .slice(0, 8); + } + return base + .filter((row) => Number.isFinite(row.hitProbability)) + .sort((a, b) => (b.expectedRoi || 0) - (a.expectedRoi || 0)) + .slice(0, 8); +} + +function renderComboRows(rows = [], elId, max = 8) { + const el = $(`#${elId}`); + if (!el) return; + if (!rows.length) { + el.innerHTML = '
  • 目前無可用組合
  • '; + return; + } + + el.innerHTML = rows.slice(0, max) + .map((row) => { + const legs = Array.isArray(row.legs) + ? row.legs.map((leg, legIdx) => `${legIdx + 1}) ${leg.market || '-'} ${leg.selection || '-'}`).join(' + ') + : '-'; + const expectedRoi = Number.isFinite(row.expectedRoi) ? row.expectedRoi * 100 : null; + const hit = Number.isFinite(row.hitProbability) ? row.hitProbability : 0; + const band = probabilityBand(hit); + return ` +
  • +

    ${legs}

    +

    + 賠率 ${money(row.odds)} · 命中 ${pct(hit)} · ROI ${(Number.isFinite(expectedRoi) ? `${expectedRoi.toFixed(1)}%` : '-')} + ${band.label} +

    +

    ${row.notes || '-'}

    +
  • + `; + }) + .join(''); +} + +function renderParlayStrategy() { + const pb = state.analysis?.professionalPlaybook?.parlay; + const doubles = pb?.double || {}; + const triples = pb?.triple || {}; + const fallbackDoubles = state.analysis?.doublePlay || []; + const fallbackTriples = state.analysis?.triplePlay || []; + + renderComboRows( + safePlaybookRows(doubles.conservative).length + ? doubles.conservative + : rankComboByStyle(fallbackDoubles, 'conservative'), + 'doubleConservative', + ); + renderComboRows( + safePlaybookRows(doubles.balanced).length + ? doubles.balanced + : rankComboByStyle(fallbackDoubles, 'balanced'), + 'doubleBalanced', + ); + renderComboRows( + safePlaybookRows(doubles.value).length ? doubles.value : rankComboByStyle(fallbackDoubles, 'value'), + 'doubleValue', + ); + + renderComboRows( + safePlaybookRows(triples.conservative).length + ? triples.conservative + : rankComboByStyle(fallbackTriples, 'conservative'), + 'tripleConservative', + ); + renderComboRows( + safePlaybookRows(triples.balanced).length ? triples.balanced : rankComboByStyle(fallbackTriples, 'balanced'), + 'tripleBalanced', + ); + renderComboRows( + safePlaybookRows(triples.value).length ? triples.value : rankComboByStyle(fallbackTriples, 'value'), + 'tripleValue', + ); +} + +function renderPortfolioSummary() { + const el = $('#portfolioSummary'); + if (!el) return; + const summary = state.analysis?.professionalPlaybook?.overall?.portfolio || {}; + const risk = summary.riskProfile || {}; + const markets = Array.isArray(summary.marketCoverage) ? summary.marketCoverage : []; + const marketList = markets.length + ? markets.map((m) => `
  • ${m.market}:${m.count} 筆
  • `).join('') + : '
  • 尚無市場分布
  • '; + el.innerHTML = ` +
    +

    總和評估(全局)

    +

    ${summary.summaryLine || '目前尚無可用總體評估資料'}

    +

    平均信心:${summary.avgConfidence !== undefined ? ratio(summary.avgConfidence, 4) : '-'}

    +
      +
    • 高勝率建議:${summary.topHighProbabilities || 0}
    • +
    • 總爆冷機率信號:${summary.totalUpsetSignals || 0}
    • +
    • 新聞訊號樣本:${summary.totalNewsSignals || 0}
    • +
    +

    市場分布

    +
      ${marketList}
    +

    風險占比|高 ${ratio(risk.high, 2) || 0} / 中 ${ratio(risk.medium, 2) || 0} / 低 ${ratio(risk.low, 2) || 0}

    +
    + `; +} + +function renderComboRowsHtml(rows = []) { + if (!rows.length) return '
  • 目前無可用組合
  • '; + return rows + .slice(0, 6) + .map((row) => { + const legs = Array.isArray(row.legs) + ? row.legs.map((leg, legIdx) => `${legIdx + 1}) ${leg.market || '-'} ${leg.selection || '-'}`).join(' + ') + : '-'; + const expectedRoi = Number.isFinite(row.expectedRoi) ? row.expectedRoi * 100 : null; + const hit = Number.isFinite(row.hitProbability) ? row.hitProbability : 0; + const band = probabilityBand(hit); + return ` +
  • +

    ${legs}

    +

    + 賠率 ${money(row.odds)} · 命中 ${pct(hit)} · ROI ${(Number.isFinite(expectedRoi) ? `${expectedRoi.toFixed(1)}%` : '-')} + ${band.label} +

    +
  • + `; + }) + .join(''); +} + +function renderMultiLegParlay() { + const container = $('#multiLegParlay'); + if (!container) return; + const payload = state.analysis?.professionalPlaybook?.overall?.multiLegParlay || {}; + const sections = [ + { title: '2 串', data: payload.twoLeg }, + { title: '3 串', data: payload.threeLeg }, + { title: '4 串', data: payload.fourLeg }, + { title: '5 串', data: payload.fiveLeg }, + ]; + + container.innerHTML = sections + .map((sec) => { + const conservative = safePlaybookRows(sec.data?.conservative); + const balanced = safePlaybookRows(sec.data?.balanced); + const value = safePlaybookRows(sec.data?.value); + return ` +
    +

    ${sec.title}|穩健型

    +
      ${renderComboRowsHtml(conservative)}
    +
    +
    +

    ${sec.title}|平衡型

    +
      ${renderComboRowsHtml(balanced)}
    +
    +
    +

    ${sec.title}|高報酬型

    +
      ${renderComboRowsHtml(value)}
    +
    + `; + }) + .join(''); +} + +function pickTopLegsForSystem(size = 4) { + const fromSingles = (state.analysis?.topSingles || []) + .filter((row) => Number.isFinite(row.probability) && Number.isFinite(row.odds) && row.odds > 1) + .filter((row) => Number.isFinite(row.confidence) ? row.confidence >= 0.55 : true) + .slice(0, Math.max(6, size)) + .filter((row, idx, arr) => arr.findIndex((x) => x.matchId === row.matchId) === idx); + if (!fromSingles.length) return []; + return fromSingles; +} + +function combinationProducts(rows, size, output, current = [], start = 0, limit = 160) { + if (output.length >= limit) return; + if (current.length === size) { + output.push([...current]); + return; + } + for (let i = start; i < rows.length; i += 1) { + if (output.length >= limit) return; + current.push(rows[i]); + combinationProducts(rows, size, output, current, i + 1, limit); + current.pop(); + } +} + +function buildSystemPatternTemplate(rows = [], totalLegs = 4, includedLegs = 2) { + const legs = rows.slice(0, totalLegs); + if (legs.length < totalLegs || includedLegs <= 0 || includedLegs > totalLegs) return null; + const combos = []; + combinationProducts(legs, includedLegs, combos, []); + if (!combos.length) return null; + + let totalRoi = 0; + let avgHit = 0; + const slipSamples = []; + for (const combo of combos) { + const odds = combo.reduce((acc, r) => acc * (Number(r.odds) || 1), 1); + const hit = combo.reduce((acc, r) => acc * (Number(r.probability) || 0), 1); + const roi = odds * hit - 1; + totalRoi += roi; + avgHit += hit; + slipSamples.push({ + odds: Number.isFinite(odds) ? Number(odds.toFixed(2)) : odds, + hitProbability: hit, + roi, + }); + } + const n = combos.length; + return { + pattern: `${totalLegs}串${includedLegs}`, + slips: n, + avgHitProbability: avgHit / (n || 1), + expectedRoi: (totalRoi / (n || 1)), + sample: slipSamples.slice(0, 3).map((s) => `命中 ${(s.hitProbability * 100).toFixed(1)}% / 賠率 ${money(s.odds)} / ROI ${(s.roi * 100).toFixed(1)}%`), + legs, + }; +} + +function renderSystemPlaybook() { + const el = $('#systemPlaybook'); + if (!el) return; + const system = state.analysis?.professionalPlaybook?.system; + if (Array.isArray(system) && system.length) { + const topSamples = system[0]?.legs + ? system[0].legs.slice(0, 5).map((r) => `${r.market} ${r.selection}`).join(';') + : '尚未形成完整樣本'; + + el.innerHTML = ` +

    核心樣本:${topSamples}

    +
      + ${system + .map((row) => { + const band = probabilityBand(row.avgHitProbability); + return ` +
    • +

      ${row.pattern}(共 ${row.slips} 注)

      +

      平均命中 ${(row.avgHitProbability * 100).toFixed(1)}% / 期望ROI ${(row.expectedRoi * 100).toFixed(1)}% + ${band.label} +

      +

      ${(row.sample || []).join(';')}

      +
    • + `; + }) + .join('')} +
    + `; + return; + } + + const topLegs = pickTopLegsForSystem(6); + if (!topLegs.length) { + el.innerHTML = '

    目前資料不足,尚無系統式參考

    '; + return; + } + const patterns = []; + for (const [n, k] of [ + [4, 2], + [4, 3], + [5, 2], + [5, 3], + ]) { + const p = buildSystemPatternTemplate(topLegs, n, k); + if (p) patterns.push(p); + } + if (!patterns.length) { + el.innerHTML = '

    目前無法組出完整系統式玩法

    '; + return; + } + + const topSelection = topLegs.slice(0, 5).map((r) => `${r.market} ${r.selection}`).join(';'); + + el.innerHTML = ` +

    核心樣本:${topSelection}

    +
      + ${patterns + .map((row) => { + const band = probabilityBand(row.avgHitProbability); + return ` +
    • +

      ${row.pattern}(共 ${row.slips} 注)

      +

      平均命中 ${(row.avgHitProbability * 100).toFixed(1)}% / 期望ROI ${(row.expectedRoi * 100).toFixed(1)}% + ${band.label} +

      +

      ${row.sample.join(';')}

      +
    • + `; + }) + .join('')} +
    + `; +} + +function renderCrossMarketPairs() { + const el = $('#crossMarketPairs'); + if (!el) return; + const pb = state.analysis?.professionalPlaybook?.crossMarket; + if (Array.isArray(pb) && pb.length) { + el.innerHTML = pb + .slice(0, 8) + .map((row) => { + const lines = row.candidates + .map((r) => `${r.market} ${r.selection}(賠率 ${money(r.odds)} / 勝率 ${pct(r.probability)})`) + .join('
    '); + return ` +
    +

    ${row.teams}

    +

    開賽 ${row.kickoffAtTaipei || fmtTime(row.kickoffAt)}

    +

    ${lines}

    +
    + `; + }) + .join(''); + return; + } + + const list = []; + for (const match of state.analysis?.perMatch || []) { + const marketCandidates = []; + for (const pool of [match.recommendationBuckets?.high || [], match.recommendationBuckets?.medium || []]) { + for (const rec of pool) { + const group = marketGroup(rec.market); + if (group === 'other') continue; + const key = `${group}::${rec.selection}`; + if (!marketCandidates.find((x) => x.key === key)) { + marketCandidates.push({ + key, + market: rec.market, + selection: rec.selection, + odds: rec.odds, + probability: rec.probability, + rationale: rec.rationale || '', + }); + } + } + } + const uniqGroups = new Set(marketCandidates.map((r) => r.market)); + if (uniqGroups.size >= 2) { + list.push({ + match: match.teams, + time: match.kickoffAtTaipei || fmtTime(match.kickoffAt), + candidates: marketCandidates.slice(0, 4), + }); + } + } + + if (!list.length) { + el.innerHTML = '

    目前無法提供同場雙盤進階參考

    '; + return; + } + + el.innerHTML = list + .slice(0, 8) + .map((row) => { + const lines = row.candidates + .map((r) => `${r.market} ${r.selection}(賠率 ${money(r.odds)} / 勝率 ${pct(r.probability)})`) + .join('
    '); + return ` +
    +

    ${row.match}

    +

    開賽 ${row.time}

    +

    ${lines}

    +
    + `; + }) + .join(''); +} + +function renderSourceRegistry(sources = []) { + const el = $('#sourceRegistry'); + if (!el) return; + const sorted = [...sources].sort((a, b) => { + const intOrder = { + active: 0, + conditional: 1, + planned: 2, + reference_only: 3, + }; + const aw = intOrder[a.integration] ?? 4; + const bw = intOrder[b.integration] ?? 4; + if (aw !== bw) return aw - bw; + return (b.weight || 0) - (a.weight || 0); + }); + if (!sources.length) { + el.innerHTML = '

    尚未取得外部來源台帳,將在下次更新時補齊

    '; + return; + } + const statusRows = sorted + .map((item) => { + const runtime = item.runtime || {}; + const badgeClass = + runtime.status === 'ok' + ? 'ok' + : runtime.status === 'error' + ? 'danger' + : runtime.status === 'checking' + ? 'loading' + : 'pending'; + const integrationClass = + item.integration === 'active' + ? 'ok' + : item.integration === 'conditional' + ? 'loading' + : item.integration === 'planned' + ? 'reference' + : 'pending'; + const runtimeStatus = runtime.status || 'pending'; + const latency = Number.isFinite(Number(runtime.latencyMs)) + ? `${Number(runtime.latencyMs)}ms` + : '-'; + const msg = runtime.message || '待啟動'; + const lastCheck = runtime.checkedAt ? fmtTime(runtime.checkedAt) : '-'; + const lastSuccessAt = runtime.lastSuccessAt ? fmtTime(runtime.lastSuccessAt) : '-'; + const errorMsg = runtime.lastError ? `錯誤: ${runtime.lastError}
    ` : ''; + return ` +
    +
    +
    +

    ${item.name || '-'}

    +

    類別 ${item.sourceType || '-'} / 權重 ${Number(item.weight || 0).toFixed(2)}

    +
    + ${runtimeStatus} +
    +
    +

    整合:${item.integration || '-'}

    +

    策略:${item.category || '-'} / ${item.name ? '已納入主流矩陣' : '-'}

    +
    +

    ${item.description || '-'}

    +

    + 服務狀態:${msg}
    + 最新檢測:${lastCheck}
    + 最近成功:${lastSuccessAt}
    + 延遲:${latency}
    + ${errorMsg || ''} +

    +

    ${item.url || '-'}

    +
    + `; + }) + .join(''); + el.innerHTML = statusRows || '

    尚未取得外部來源台帳,將在下次更新時補齊

    '; +} + +function renderMarketBuckets(rows = []) { + const normalized = Array.isArray(rows) ? rows : []; + const groups = {}; + for (const rec of normalized) { + const key = String(rec.market || '未知市場').trim() || '未知市場'; + (groups[key] || (groups[key] = [])).push(rec); + } + + const order = ['1X2', 'Handicap', 'Totals', 'Double Chance', 'Both Teams To Score', 'BTTS Trend', '讓球', '大小球', '雙重機率']; + const sortedKeys = [ + ...order, + ...Object.keys(groups).filter((k) => !order.includes(k)), + ]; + const uniqKeys = []; + const seen = new Set(); + for (const key of sortedKeys) { + if (!seen.has(key) && groups[key]?.length) { + uniqKeys.push(key); + seen.add(key); + } + } + + return uniqKeys.map((market) => { + const list = groups[market].slice().sort((a, b) => (b.confidence || 0) - (a.confidence || 0)); + return { + market, + rows: list, + }; + }); +} + +function renderMatchContextLine(context = {}) { + if (!context || typeof context !== 'object') return ''; + const venue = context.venue || {}; + const home = context.home || {}; + const away = context.away || {}; + const venueText = venue.venue ? `場館 ${venue.venue}${Number.isFinite(venue.altitude) ? `(${venue.altitude}m)` : ''}` : ''; + const homeText = Number.isFinite(home.restDays) ? `主隊休整 ${home.restDays}天(${home.fatigueLabel || '資料不足'})` : ''; + const awayText = Number.isFinite(away.restDays) ? `客隊休整 ${away.restDays}天(${away.fatigueLabel || '資料不足'})` : ''; + const risks = context.venueRisk?.label ? `環境風險 ${context.venueRisk.label}` : ''; + const parts = [venueText, risks, homeText, awayText].filter(Boolean); + if (!parts.length) return ''; + return `${parts.join('|')}`; +} + +function renderTodayInsightCards() { + const container = $('#todayInsightCards'); + if (!container) return; + + const payload = state.todayInsights || {}; + const matches = Array.isArray(payload.matches) ? payload.matches : []; + const high = Array.isArray(payload.topSinglesByTier?.high) ? payload.topSinglesByTier.high : []; + const medium = Array.isArray(payload.topSinglesByTier?.medium) ? payload.topSinglesByTier.medium : []; + const low = Array.isArray(payload.topSinglesByTier?.low) ? payload.topSinglesByTier.low : []; + const upsets = Array.isArray(payload.upsetSignals) ? payload.upsetSignals : []; + const dateLabel = payload.dateLabel || `今天(台北時間)`; + const summary = payload.summary || {}; + const breakdown = payload.upsetBreakdown || {}; + + const list = (items = [], limit = 3) => + items.length + ? items + .slice(0, limit) + .map((row) => `
  • ${safeText(row.market || '-')} ${safeText(row.selection || '-')}(賠率 ${money(row.odds)}/勝率 ${pct(row.probability)})
  • `) + .join('') + : '
  • 本級別目前無可用訊號
  • '; + + container.innerHTML = ` +
    +

    今日總覽:${dateLabel}

    +

    共 ${matches.length} 場比賽可用

    +
      +
    • 高勝率建議數:${ratio(summary.highSingles, 0)}
    • +
    • 中勝率建議數:${ratio(summary.mediumSingles, 0)}
    • +
    • 低勝率建議數:${ratio(summary.lowSingles, 0)}
    • +
    • 爆冷訊號數:${ratio(summary.upsetSignals, 0)}(高 ${ratio(breakdown.high, 0)} / 中 ${ratio(breakdown.medium, 0)} / 低 ${ratio(breakdown.low, 0)})
    • +
    +
    +
    +

    高勝率 Top 3

    +
      ${list(high, 3)}
    +
    +
    +

    中勝率 Top 3

    +
      ${list(medium, 3)}
    +
    +
    +

    低勝率 Top 3

    +
      ${list(low, 3)}
    +
    +
    +

    爆冷 Top 5

    +
      ${list(upsets, 5)}
    +
    + `; +} + +function renderTodayMatchCards() { + const el = $('#todayMatches'); + const title = $('#todayMatchesTitle'); + if (!el) return; + + const today = fmtDateLabel(new Date()); + const all = state.analysis?.perMatch || []; + const todayMatches = all + .filter((m) => fmtDateLabel(m.kickoffAt) === today) + .sort((a, b) => new Date(a.kickoffAt).getTime() - new Date(b.kickoffAt).getTime()); + + if (title) { + const dateText = today || '-'; + title.textContent = `今天(台北時間 ${dateText})賽事投注建議`; + } + + if (!todayMatches.length) { + el.innerHTML = '

    今天目前尚無賽事資料

    更新完成後會即時顯示今日可下的投注建議。

    '; + return; + } + + el.innerHTML = todayMatches + .map((match) => { + const allRows = [ + ...(match.recommendationBuckets?.high || []), + ...(match.recommendationBuckets?.medium || []), + ...(match.recommendationBuckets?.low || []), + ].sort((a, b) => (b.confidence || 0) - (a.confidence || 0)); + const marketBuckets = renderMarketBuckets(allRows); + + const top = match.topRecommendation + ? ` +
  • 主建議:${match.topRecommendation.market} ${match.topRecommendation.selection} + (賠率 ${money(match.topRecommendation.odds)} / 機率 ${pct(match.topRecommendation.probability)} / EV ${money(match.topRecommendation.expectedValue)} / Kelly ${ratio(match.topRecommendation.kellyFraction, 2)}) +
  • ` + : '
  • 目前無可用主建議
  • '; + + const highs = (match.recommendationBuckets?.high || []).map((r) => `
  • ${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}|EV ${money(r.expectedValue)}
  • `); + const mediums = (match.recommendationBuckets?.medium || []).map((r) => `
  • ${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}|EV ${money(r.expectedValue)}
  • `); + const lows = (match.recommendationBuckets?.low || []).map((r) => `
  • ${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}|EV ${money(r.expectedValue)}
  • `); + const upsets = (match.upsetSignals || []).map((u) => `
  • ${u.selection}(${u.riskLabel || 'low'})-賠率 ${money(u.odds)} / 爆冷機率 ${pct(u.probability)}
  • `); + const contextLine = renderMatchContextLine(match.matchContext); + const allRowsHtml = allRows.length + ? allRows + .map((r) => `
  • ${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}|EV ${money(r.expectedValue)}|分層 ${r.recommendationTier || classifyRecommendationTier(r.probability, r.confidence)}
  • `) + .join('') + : '
  • 無可用單關建議
  • '; + const marketDetails = marketBuckets.length + ? marketBuckets + .map( + (bucket) => ` +

    [${bucket.market}]

    +
      ${bucket.rows + .map((r) => `
    • ${r.selection}(賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}|EV ${money(r.expectedValue)}|信心 ${ratio(r.confidence, 2)})
    • `) + .join('')}
    + `, + ) + .join('') + : ''; + + return ` +
    +
    ${match.kickoffAtTaipei || fmtTime(match.kickoffAt)} · ${match.teams}
    + ${contextLine ? `

    ${contextLine}

    ` : ''} +
      + ${top} +
    +

    高勝率候選(高)

    +
      ${highs.length ? highs.join('') : '
    • '}
    +

    中勝率候選(中)

    +
      ${mediums.length ? mediums.join('') : '
    • '}
    +

    低勝率候選(高報酬)

    +
      ${lows.length ? lows.join('') : '
    • '}
    +

    單關完整建議(高/中/低)

    +
      ${allRowsHtml}
    +

    市場維度完整建議

    + ${marketDetails || '

    尚無市場維度明細

    '} +

    爆冷訊號

    +
      ${upsets.length ? upsets.join('') : '
    • '}
    +
    + `; + }) + .join(''); +} + +function renderMatchCard(match) { + const newsItems = Array.isArray(match.news) ? match.news : []; + const top = match.topRecommendation + ? ` +
  • + ${match.topRecommendation.market}: + ${match.topRecommendation.selection} + (賠率 ${match.topRecommendation.odds},機率 ${pct(match.topRecommendation.probability)},EV ${money(match.topRecommendation.expectedValue)},Kelly ${ratio(match.topRecommendation.kellyFraction, 2)},模型 ${match.topRecommendation.modelGrade || '-'}) +
  • + ` + : '
  • 暫無可用主建議
  • '; + + const marketSummary = Array.isArray(match.marketSummary) ? match.marketSummary : []; + const markets = marketSummary.map( + (m) => + `
  • [${m.market}] ${m.bestOutcome}|賠率 ${money(m.bestOdds)}|勝率 ${pct(m.fairProbability)}
  • `, + ).join(''); + + const news = newsItems + .slice(0, 3) + .map( + (item) => + `
  • ${item.title}(${item.source || '-'}|${fmtTime(item.publishedAt)})
  • `, + ) + .join(''); + const contextLine = renderMatchContextLine(match.matchContext); + + return ` +
    +

    ${match.teams}

    +

    ${match.source} · ${match.kickoffAtTaipei || fmtTime(match.kickoffAt)}

    + ${contextLine ? `

    環境條件:${contextLine}

    ` : ''} +

    高勝率推薦

    +
      ${top}
    +

    市場摘要

    +
      ${markets || '
    • 暫無市場數據
    • '}
    +

    高勝率(高機率)

    +
      ${renderRecommendRows(match.recommendationBuckets?.high || [])}
    +

    中勝率(平衡風險)

    +
      ${renderRecommendRows(match.recommendationBuckets?.medium || [], 2)}
    +

    低勝率(博取機會)

    +
      ${renderRecommendRows(match.recommendationBuckets?.low || [], 2)}
    +

    爆冷機會

    +
      ${renderUpsetRows(match.upsetSignals || [], 3)}
    +

    近期新聞

    +
      ${news || '
    • 暫無近期新聞
    • '}
    +
    + `; +} + +function renderMatches() { + const el = $('#matchContainer'); + if (!el) return; + const list = (state.analysis?.perMatch || []).filter((m) => filterMarkets(m, state.filter)); + if (!list.length) { + el.innerHTML = '

    目前無可顯示資料,請先更新

    '; + return; + } + el.innerHTML = list.map(renderMatchCard).join(''); +} + +function renderCombos() { + const doubleEl = $('#doubleCombo'); + const tripleEl = $('#tripleCombo'); + if (!doubleEl || !tripleEl) return; + const doubles = state.analysis?.doublePlay || []; + const triples = state.analysis?.triplePlay || []; + + doubleEl.innerHTML = doubles.length + ? doubles + .slice(0, 8) + .map((row) => { + const legs = row.legs + .map((leg) => `${leg.market} ${leg.selection}`) + .join(' + '); + return `
    ${legs}
    勝率 ${pct(row.hitProbability)} / 賠率 ${money(row.odds)} / 預估ROI ${(row.expectedRoi * 100).toFixed(1)}%
    ${row.notes}
    `; + }) + .join('') + : '

    尚未產生可組合項目

    '; + + tripleEl.innerHTML = triples.length + ? triples + .slice(0, 8) + .map((row) => { + const legs = row.legs + .map((leg) => `${leg.market} ${leg.selection}`) + .join(' + '); + return `
    ${legs}
    勝率 ${pct(row.hitProbability)} / 賠率 ${money(row.odds)} / 預估ROI ${(row.expectedRoi * 100).toFixed(1)}%
    ${row.notes}
    `; + }) + .join('') + : '

    尚未產生可組合項目

    '; +} + +function renderSchedule() { + const dateEl = $('#scheduleByDate'); + const newsEl = $('#newsHeat'); + if (!dateEl || !newsEl) return; + const byDate = state.schedule?.byDate || []; + const hotNews = state.schedule?.hotNewsWithin48h || []; + + dateEl.innerHTML = byDate + .map( + (row) => + `

    ${row.date || '-'}

    場數 ${row.count}

      ${(Array.isArray(row.events) ? row.events : []) + .map((e) => `
    • ${e.teams}
    • `) + .join('')}
    `, + ) + .join(''); + + newsEl.innerHTML = hotNews.length + ? hotNews.map((item) => { + const headlines = item.sampleHeadlines.map((h) => `
  • ${h}
  • `).join(''); + return `

    ${item.teams}

    距離最新報導 ${item.ageHours} 小時

      ${headlines}
    `; + }).join('') + : '

    48 小時內無高熱度新聞

    '; +} + +function renderSingleBuckets() { + const highEl = $('#highSingles'); + const mediumEl = $('#mediumSingles'); + const lowEl = $('#lowSingles'); + if (!highEl || !mediumEl || !lowEl) return; + + const high = state.analysis?.highProbabilitySingles || []; + const medium = state.analysis?.mediumProbabilitySingles || []; + const low = state.analysis?.lowProbabilitySingles || []; + + highEl.innerHTML = renderRecommendRows(high, 8); + mediumEl.innerHTML = renderRecommendRows(medium, 6); + lowEl.innerHTML = renderRecommendRows(low, 6); +} + +function renderUpsetSignalsOverview() { + const el = $('#upsetContainer'); + if (!el) return; + + const rows = state.analysis?.upsetSignals || []; + if (!rows.length) { + el.innerHTML = '

    目前無明顯爆冷信號

    '; + return; + } + + el.innerHTML = rows + .map((row) => { + const signals = Array.isArray(row.signals) ? row.signals : []; + return ` +
    +

    ${row.teams}

    +

    開賽:${fmtTime(row.kickoffAt)} · ${signals.length} 個候選結果

    +
      ${renderUpsetRows(signals, 3)}
    +
    + `; + }) + .join(''); +} + +function renderAnalyticBars() { + const highCount = state.analysis?.highProbabilitySingles ? state.analysis.highProbabilitySingles.length : 0; + const mediumCount = state.analysis?.mediumProbabilitySingles ? state.analysis.mediumProbabilitySingles.length : 0; + const lowCount = state.analysis?.lowProbabilitySingles ? state.analysis.lowProbabilitySingles.length : 0; + renderMiniBars('tierDistributionChart', [ + { label: '高勝率', value: highCount, color: 'var(--ok)' }, + { label: '中勝率', value: mediumCount, color: '#60a5fa' }, + { label: '低勝率', value: lowCount, color: 'var(--accent)' }, + ]); + + const upsetRows = state.analysis?.upsetSignals || []; + let high = 0; + let medium = 0; + let low = 0; + for (const row of upsetRows) { + const signals = Array.isArray(row.signals) ? row.signals : []; + for (const s of signals) { + const risk = s?.riskLabel || 'low'; + if (risk === 'high') high += 1; + else if (risk === 'medium') medium += 1; + else low += 1; + } + } + renderMiniBars('upsetRiskChart', [ + { label: '高風險', value: high, color: 'var(--danger)' }, + { label: '中風險', value: medium, color: '#fbbf24' }, + { label: '低風險', value: low, color: 'var(--ok)' }, + ]); + + const sourceStats = {}; + for (const src of state.sourceRegistry || []) { + const key = src?.integration || 'reference_only'; + sourceStats[key] = (sourceStats[key] || 0) + 1; + } + renderMiniBars('sourceHealthChart', [ + { label: 'active', value: sourceStats.active || 0, color: 'var(--ok)' }, + { label: 'conditional', value: sourceStats.conditional || 0, color: '#60a5fa' }, + { label: 'planned', value: sourceStats.planned || 0, color: '#7dd3fc' }, + { label: 'reference_only', value: sourceStats.reference_only || 0, color: 'var(--muted)' }, + ]); + + const topSingles = (state.analysis?.topSingles || []).slice(0, 8); + renderMiniBars( + 'topValueChart', + topSingles.map((row) => ({ + label: `${row.teams || row.matchId || '-'} ${row.market || '-'}`, + value: Number.isFinite(row.expectedRoiPercent) ? row.expectedRoiPercent : 0, + color: row.recommendationTier === 'high' ? 'var(--ok)' : row.recommendationTier === 'medium' ? '#60a5fa' : 'var(--accent)', + })), + 16, + ); + + const scheduleRows = (state.schedule?.byDate || []).map((row) => ({ + label: row.date || '-', + value: Number(row.count) || 0, + color: 'var(--accent)', + })); + renderMiniBars('scheduleDensityChart', scheduleRows); +} + +function renderBankrollGuide() { + const el = $('#bankrollGuide'); + if (!el) return; + const guide = state.analysis?.bankrollGuide || {}; + const principles = Array.isArray(guide.principle) ? guide.principle : []; + const body = principles.length + ? `
      ${principles.map((line) => `
    • ${line}
    • `).join('')}
    ` + : '

    尚未載入資金配置建議

    '; + const model = guide.suggestedModel ? `

    模型建議:${guide.suggestedModel}

    ` : ''; + const warning = guide.warning ? `

    風險提醒:${guide.warning}

    ` : ''; + el.innerHTML = `${body}${model ? `

    ${model}

    ` : ''}${warning}`; +} + +function renderMarketRowBadge(list = [], max = 4) { + return list + .slice(0, max) + .map((item) => `
  • ${item.option || item.selection} @ ${money(item.price)}(${ratio(item.implied, 2)})
  • `) + .join('') || '
  • 暫無可比價選項
  • '; +} + +function renderDashboardMarketMatrix() { + const summaryContainer = $('#marketMatrixSummary'); + const rowsContainer = $('#marketMatrixRows'); + if (!summaryContainer || !rowsContainer) return; + + const payload = state.marketMatrix || {}; + const rows = Array.isArray(payload.rows) ? payload.rows : []; + const opportunities = Array.isArray(payload.topOpportunities) ? payload.topOpportunities : []; + const topOps = opportunities.slice(0, 10); + + summaryContainer.innerHTML = ` +
    +

    賠率矩陣快照

    +

    更新時間:${payload.updatedAt ? fmtTime(payload.updatedAt) : '-'}

    +
      +
    • 場次:${rows.length}
    • +
    • 套利候選:${payload.opportunityCount || opportunities.length || 0}
    • +
    • 最大輸出場次:${safeText(payload.matchCount)}
    • +
    +
    +
    +

    最高套利信號

    +
      + ${topOps.length ? topOps.map((row) => `
    • ${safeText(row.market)} · ${safeText(row.type)}|${safeText(Number.isFinite(row.edgePercent) ? `${row.edgePercent}%` : '')}
    • `).join('') : '
    • 目前未偵測到可套利組合
    • '} +
    +
    + `; + + if (!rows.length) { + rowsContainer.innerHTML = '

    尚未取得賠率矩陣資料

    '; + return; + } + + rowsContainer.innerHTML = rows + .slice(0, 8) + .map((row) => { + const h2h = renderMarketRowBadge(row.marketWinners?.h2h || []); + const spread = renderMarketRowBadge(row.marketWinners?.spreads || []); + const totals = renderMarketRowBadge(row.marketWinners?.totals || []); + const btts = renderMarketRowBadge(row.marketWinners?.btts || []); + return ` +
    +

    ${safeText(row.teams)}

    +

    開賽:${safeText(row.kickoffAtTaipei || '-')}

    +
    +
    +

    1X2

    +
      ${h2h}
    +
    +
    +

    讓球

    +
      ${spread}
    +
    +
    +

    大小球

    +
      ${totals}
    +
    +
    +

    BTTS

    +
      ${btts}
    +
    +
    +

    場次 ID:${safeText(row.matchId)}

    +
    + `; + }) + .join(''); +} + +function renderDashboardSharpMoney() { + const container = $('#sharpMoneyBoard'); + if (!container) return; + + const payload = state.sharpMoney || {}; + const signals = Array.isArray(payload.signals) ? payload.signals : []; + + if (!signals.length) { + container.innerHTML = '

    目前尚無 Public / Sharp 監控訊號

    '; + return; + } + + container.innerHTML = signals + .slice(0, 12) + .map((row) => { + const biasClass = row.sharpBias === 'sharp-heavy' ? 'risk-high' : row.sharpBias === 'public-heavy' ? 'risk-low' : 'risk-medium'; + const status = row.status === 'extreme' ? '極端偏差' : row.status === 'notice' ? '明顯偏差' : '一般'; + return ` +
    +

    ${safeText(row.market)} ${safeText(row.option)}

    +

    ${safeText(row.match)} · 點位 ${safeText(row.point ?? '-')}

    +

    + 票量 ${ratio(row.ticketsPercent)}% | 資金 ${ratio(row.handlePercent)}% | + 偏離 ${ratio(row.deltaSignal)}% | + ${status} +

    +

    ${safeText(row.rationale || '-')}

    +
    + `; + }) + .join(''); +} + +function renderDashboardLiveCenter() { + const container = $('#liveCenterBoard'); + const movement = $('#lineMovementBoard'); + if (!container && !movement) return; + + const livePayload = state.liveCenter || {}; + const live = Array.isArray(livePayload.data) ? livePayload.data : []; + + if (container) { + if (!live.length) { + container.innerHTML = '

    目前尚無進行中場次快照

    '; + } else { + container.innerHTML = live + .slice(0, 8) + .map((item) => { + const timeline = Array.isArray(item.timeline) ? item.timeline.slice(-1)[0] : null; + return ` +
    +

    ${safeText(item.teams)}

    +

    已比賽 ${ratio(item.elapsedMinutes)} 分鐘

    +
    +

    控球率|主 ${ratio(item.possession?.home, 2)} | 客 ${ratio(item.possession?.away, 2)}%

    +

    xG|主 ${money(item.xgProjection?.homeXg)} | 客 ${money(item.xgProjection?.awayXg)}

    +
    +

    最新事件 ${timeline ? `第 ${safeText(timeline.minute)} 分 ${safeText(timeline.team)} ${safeText(timeline.type)}` : '尚無事件推播'}

    +
    + `; + }) + .join(''); + } + } + + const move = state.lineMovement; + if (!movement) return; + const movementTrails = Array.isArray(move?.trails) ? move.trails : []; + const moveMatchName = safeText(move?.teams || `${move?.match || ''}`) || safeText(move?.matchId) || '-'; + if (!movementTrails.length) { + movement.innerHTML = '

    尚未取得盤口走勢

    '; + return; + } + + movement.innerHTML = movementTrails + .slice(0, 3) + .map((trail) => { + const path = Array.isArray(trail.series) ? trail.series : []; + const last = path[path.length - 1] || {}; + const first = path[0] || {}; + return ` +
    +

    ${moveMatchName} - ${safeText(trail.market || '-')} ${safeText(trail.outcome || '-')}

    +

    首 ${safeText(first.price)} / 尾 ${safeText(last.price)} | ${safeText(trail.sampleCount)} 點

    +

    最小 ${money(trail.minPrice)} | 最大 ${money(trail.maxPrice)}

    +
    + `; + }) + .join(''); +} + +function renderQuantitativePage() { + const summary = $('#quantSummary'); + const matchSelect = $('#quantMatchSelect'); + const matchPanel = $('#quantMatchPanel'); + const valuePanel = $('#quantValuePanel'); + + const payload = state.quantitative || {}; + const items = Array.isArray(payload.items) ? payload.items : []; + + if (!matchSelect) { + return; + } + + const availableMatchOptions = items.map((item) => { + const label = `${safeText(item.teams)}(${safeText(item.matchId)})`; + return ``; + }); + + matchSelect.innerHTML = availableMatchOptions.length + ? availableMatchOptions.join('') + : ''; + + if (!state.quantitativeMatchId || !items.find((i) => i.matchId === state.quantitativeMatchId)) { + state.quantitativeMatchId = items[0]?.matchId || ''; + matchSelect.value = state.quantitativeMatchId; + } else { + matchSelect.value = state.quantitativeMatchId; + } + + if (summary) { + const topBetCount = items.reduce((acc, item) => acc + (Array.isArray(item.evSignals?.valueBets) ? item.evSignals.valueBets.length : 0), 0); + summary.innerHTML = ` +
    +

    量化模型總覽

    +

    場次:${items.length}

    +

    更新時間:${safeText(payload.generatedAtTaipei || payload.generatedAt)}

    +
    +
    +

    今日可用正向 EV

    +

    ${topBetCount} 筆

    +
    + `; + } + + if (valuePanel) { + const allValueBets = items.flatMap((item) => (Array.isArray(item.evSignals?.valueBets) ? item.evSignals.valueBets.map((row) => ({ ...row, matchId: item.matchId, teams: item.teams })) : [])); + valuePanel.innerHTML = allValueBets.length + ? allValueBets + .slice(0, 16) + .map((row) => `

    ${safeText(row.market)} ${safeText(row.selection)}

    ${safeText(row.teams)} | ${money(row.odds)} | EV ${money(row.expectedValue)} | Edge ${safeText(row.edgePercent)}%

    ${safeText(row.rationale || '')}

    `) + .join('') + : '

    目前無 Value Bet 訊號

    '; + } + + if (!matchPanel) return; + const selectedId = state.quantitativeMatchId; + const current = items.find((item) => item.matchId === selectedId) || items[0]; + if (!current) { + matchPanel.innerHTML = '

    尚無可量化的場次

    '; + return; + } + + const poisson = current.poisson || {}; + const mc = current.monteCarlo || {}; + const topScores = Array.isArray(poisson.topScores) ? poisson.topScores : []; + const scenario = mc.scenarioProbability || {}; + + matchPanel.innerHTML = ` +
    +

    ${safeText(current.teams)}(場次 ${safeText(current.matchId)})

    +

    EV 訊號:${safeText(current.evSignals?.count)},正向 ${safeText(current.evSignals?.countValue)};Poisson 總體 λ=${ratio(poisson.lambdas?.total)};蒙地卡羅=${mc.simulations || 0} 次

    +
    +
    +

    Poisson 高概率比分

    +
      ${topScores.length ? topScores.slice(0, 10).map((row) => `
    • ${safeText(row.score)}:${pctFromRate(row.probability, 2)}
    • `).join('') : '
    • '}
    +
    +
    +

    Monte Carlo 情境

    +
      +
    • 主勝 ${pctFromRate(scenario.homeWin, 2)}
    • +
    • 和局 ${pctFromRate(scenario.draw, 2)}
    • +
    • 客勝 ${pctFromRate(scenario.awayWin, 2)}
    • +
    • 主隊淨勝2球+ ${pctFromRate(scenario.homeWinBy2OrMore, 2)}
    • +
    • 客隊淨勝2球+ ${pctFromRate(scenario.awayWinBy2OrMore, 2)}
    • +
    +

    ${safeText(mc.insight?.message || '')}

    +
    +
    +
    + `; +} + +function renderPortfolioTrackerPage() { + const summary = $('#portfolioSummaryCards'); + const rowsContainer = $('#portfolioRows'); + const matchSelect = $('#portfolioMatchId'); + + const payload = state.portfolio || {}; + const bets = Array.isArray(payload.bets) ? payload.bets : []; + + if (summary) { + summary.innerHTML = ` +

    投注總額

    ${ratio(payload.totalStake)} 元

    +

    已結算損益

    ${ratio(payload.totalPnl)} 元

    +

    ROI

    ${ratio(payload.roiPercent)}%

    +

    累計 CLV

    ${ratio(payload.avgClvPercent)}%

    +

    勝/負/推

    ${safeText(payload.byResult?.win)} / ${safeText(payload.byResult?.loss)} / ${safeText(payload.byResult?.push)}

    +

    結算/未結

    ${safeText(payload.settledCount)} / ${safeText(payload.openCount)}

    + `; + } + + if (matchSelect) { + const options = state.matches + .map((m) => ``) + .join(''); + if (!matchSelect.innerHTML || matchSelect.dataset.optionsReady !== '1') { + matchSelect.innerHTML = options || ''; + matchSelect.dataset.optionsReady = '1'; + } + } + + if (!rowsContainer) return; + if (!bets.length) { + rowsContainer.innerHTML = '

    目前尚未記錄下注

    '; + return; + } + + rowsContainer.innerHTML = ` +
    + + + + + + + + ${bets + .map((row) => ` + + + + + + + + + + + `).join('')} + +
    場次市場選項賠率本金結果CLV%開倉時間
    ${safeText(row.meta?.teams || row.matchId)}${safeText(row.market)}${safeText(row.selection)}${money(row.odds)}${ratio(row.stake)}${safeText(row.result || 'open')}${row.clv === null || row.clv === undefined ? '-' : ratio(row.clv)}${fmtTime(row.placedAt)}
    +
    + `; +} + +async function loadPortfolioData() { + const result = await fetchJson('/api/portfolio'); + state.portfolio = result; +} + +async function loadDashboardData() { + const [marketPayload, sharpPayload, livePayload] = await Promise.all([ + fetchJson('/api/market-matrix'), + fetchJson('/api/sharp-money'), + fetchJson('/api/live-center'), + ]); + state.marketMatrix = marketPayload; + state.sharpMoney = sharpPayload; + state.liveCenter = livePayload; + + const firstMatchId = marketPayload?.rows?.[0]?.matchId || safeText(state.matches?.[0]?.id); + if (firstMatchId) { + try { + state.lineMovement = await fetchJson(`/api/line-movement?matchId=${encodeURIComponent(firstMatchId)}&market=h2h`); + } catch (e) { + state.lineMovement = null; + } + } else { + state.lineMovement = null; + } +} + +async function loadQuantitativeCoreData(force = false) { + const payload = await fetchJson(`/api/quantitative${force ? '?force=1' : ''}`); + state.quantitative = payload; + if (payload?.items?.length) { + const currentId = state.quantitativeMatchId; + if (!currentId || !payload.items.find((item) => item.matchId === currentId)) { + state.quantitativeMatchId = payload.items[0]?.matchId || ''; + } + } +} + +function syncPortfolioMarketSuggestion() { + const matchSelect = $('#portfolioMatchId'); + const marketInput = $('#portfolioMarket'); + const selectionInput = $('#portfolioSelection'); + if (!matchSelect || !marketInput || !selectionInput) return; + + const selectedMatchId = matchSelect.value; + const target = (state.analysis?.perMatch || []).find((m) => m.matchId === selectedMatchId); + if (!target) return; + const top = target.topRecommendation || null; + if (top && top.market && top.selection) { + marketInput.value = top.market; + selectionInput.value = top.selection; + } +} + +async function postJson(path, payload) { + const resp = await fetch(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`${path} 發送失敗: ${resp.status} ${text}`); + } + return resp.json(); +} + +async function refreshQuantitativeData(force = false) { + renderStatus('重算量化模型...', 'loading'); + await loadQuantitativeCoreData(force); + renderPage(); + renderStatus('量化模型已更新', 'ok'); +} + +function renderPage() { + renderSummaryCards(state.matches.length); + renderSourceRegistry(state.sourceRegistry); + renderMatches(); + renderCombos(); + renderSinglePlaybook(); + renderParlayStrategy(); + renderPortfolioSummary(); + renderMultiLegParlay(); + renderSchedule(); + renderSingleBuckets(); + renderUpsetSignalsOverview(); + renderSystemPlaybook(); + renderCrossMarketPairs(); + renderBankrollGuide(); + renderTodayInsightCards(); + renderTodayMatchCards(); + renderAnalyticBars(); + renderDashboardMarketMatrix(); + renderDashboardSharpMoney(); + renderDashboardLiveCenter(); + renderQuantitativePage(); + renderPortfolioTrackerPage(); +} + +async function fetchJson(path) { + const resp = await fetch(path); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`${path} 呼叫失敗: ${resp.status} ${text}`); + } + return resp.json(); +} + +async function loadData() { + renderStatus('更新資料中...', 'loading'); + const matchesPayload = await fetchJson('/api/matches?force=1'); + state.matches = matchesPayload.matches || []; + renderStatus(`已抓取 ${state.matches.length} 場比賽`, 'ok'); + + const analysisPayload = await fetchJson('/api/analyze'); + state.analysis = analysisPayload; + + const schedulePayload = await fetchJson('/api/schedule-comparison'); + state.schedule = schedulePayload; + + const todayPayload = await fetchJson('/api/today-insights'); + state.todayInsights = todayPayload; + + const sourcePayload = await fetchJson('/api/source-registry'); + state.sourceRegistry = sourcePayload.sources || []; + const page = CURRENT_PAGE; + const tasks = []; + if (page === 'dashboard') tasks.push(loadDashboardData()); + if (page === 'quantitative') tasks.push(loadQuantitativeCoreData()); + if (page === 'portfolio') tasks.push(loadPortfolioData()); + + if (tasks.length) { + await Promise.all(tasks); + } + renderPage(); +} + +async function refreshAnalysis() { + renderStatus('重新計算下注策略...', 'loading'); + const result = await fetchJson('/api/analyze'); + state.analysis = result; + const todayPayload = await fetchJson('/api/today-insights'); + state.todayInsights = todayPayload; + renderPage(); + renderStatus('策略已更新', 'ok'); +} + +async function loadSchedule() { + renderStatus('更新賽程比對...', 'loading'); + const result = await fetchJson('/api/schedule-comparison'); + state.schedule = result; + renderPage(); + renderStatus('賽程比對完成', 'ok'); +} + +document.addEventListener('DOMContentLoaded', async () => { + const btnRefresh = $('#btnRefresh'); + const btnAnalyze = $('#btnAnalyze'); + const btnSchedule = $('#btnSchedule'); + const marketFilter = $('#marketFilter'); + const btnRefreshQuant = $('#btnRefreshQuant'); + const btnRefreshPortfolio = $('#btnRefreshPortfolio'); + const portfolioForm = $('#portfolioForm'); + const portfolioMatchSelect = $('#portfolioMatchId'); + const quantMatchSelect = $('#quantMatchSelect'); + + if (btnRefresh) { + btnRefresh.addEventListener('click', async () => { + try { + await loadData(); + } catch (e) { + renderStatus(`更新失敗:${e.message}`, 'error'); + } + }); + } + + if (btnAnalyze) { + btnAnalyze.addEventListener('click', async () => { + try { + await refreshAnalysis(); + } catch (e) { + renderStatus(`策略更新失敗:${e.message}`, 'error'); + } + }); + } + + if (btnSchedule) { + btnSchedule.addEventListener('click', async () => { + try { + await loadSchedule(); + } catch (e) { + renderStatus(`賽程比對失敗:${e.message}`, 'error'); + } + }); + } + + if (marketFilter) { + marketFilter.addEventListener('change', (e) => { + state.filter = e.target.value; + renderMatches(); + }); + } + + if (quantMatchSelect) { + quantMatchSelect.addEventListener('change', () => { + state.quantitativeMatchId = quantMatchSelect.value; + renderQuantitativePage(); + }); + } + + if (btnRefreshQuant) { + btnRefreshQuant.addEventListener('click', async () => { + try { + await refreshQuantitativeData(true); + } catch (e) { + renderStatus(`量化更新失敗:${e.message}`, 'error'); + } + }); + } + + if (portfolioMatchSelect) { + portfolioMatchSelect.addEventListener('change', syncPortfolioMarketSuggestion); + } + + if (btnRefreshPortfolio) { + btnRefreshPortfolio.addEventListener('click', async () => { + try { + await loadPortfolioData(); + renderPage(); + renderStatus('投注紀錄已刷新', 'ok'); + } catch (e) { + renderStatus(`投注紀錄刷新失敗:${e.message}`, 'error'); + } + }); + } + + if (portfolioForm) { + portfolioForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const marketInput = $('#portfolioMarket'); + const selectionInput = $('#portfolioSelection'); + const matchInput = $('#portfolioMatchId'); + const oddsInput = $('#portfolioOdds'); + const stakeInput = $('#portfolioStake'); + const pointInput = $('#portfolioPoint'); + const resultInput = $('#portfolioResult'); + + const payload = { + matchId: matchInput ? matchInput.value : '', + market: marketInput ? marketInput.value : '', + selection: selectionInput ? selectionInput.value : '', + odds: Number(oddsInput ? oddsInput.value : NaN), + stake: Number(stakeInput ? stakeInput.value : NaN), + point: pointInput && pointInput.value !== '' ? Number(pointInput.value) : null, + result: resultInput ? resultInput.value || 'open' : 'open', + }; + + try { + await postJson('/api/portfolio', payload); + await loadPortfolioData(); + syncPortfolioMarketSuggestion(); + renderPortfolioTrackerPage(); + renderStatus('投注紀錄已更新', 'ok'); + if (oddsInput) oddsInput.value = ''; + if (stakeInput) stakeInput.value = ''; + if (selectionInput) selectionInput.value = ''; + } catch (err) { + renderStatus(`提交失敗:${err.message}`, 'error'); + } + }); + } + + const navLinks = document.querySelectorAll('.top-nav a'); + navLinks.forEach((link) => { + const href = (link.getAttribute('href') || '').replace('./', ''); + if ((CURRENT_PAGE === 'home' && (href === '' || href === 'index.html')) || href.includes(CURRENT_PAGE)) { + link.classList.add('active'); + } + }); + + syncPortfolioMarketSuggestion(); + + try { + await loadData(); + } catch (e) { + renderStatus(`載入失敗:${e.message}`, 'error'); + } +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..40b10f0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,124 @@ + + + + + + 2026 世界盃專業投注研究台 | 總覽 + + + +
    + +
    +
    +

    2026 FIFA 世界盃投注研究站

    +

    專業賠率抓取、全賽事分析、新聞交叉核對,台北時間(UTC+8)即時更新。

    +
    +
    + + + +
    +
    +
    +
    + +
    +
    +

    今日完整投注摘要(高 / 中 / 低 / 爆冷)

    +
    +
    + +
    +

    2026 世界盃 · 台北時間(UTC+8)策略中心

    +

    今天(台北時間)賽事完整投注建議

    +
    +
    + +
    +

    即時戰情總覽

    +
    +
    + +
    +

    賽程與新聞對照

    +
    +
    +

    日程密度

    +
    +
    +
    +

    48 小時內新聞熱度

    +
    +
    +
    +
    + +
    +

    推薦分層速覽

    +
    +
    +

    高勝率

    +
      +
      +
      +

      中勝率

      +
        +
        +
        +

        低勝率

        +
          +
          +
          +
          + +
          +

          視覺化看板

          +
          +
          +

          勝率分佈(高 / 中 / 低)

          +
          +
          +
          +

          外部來源整合狀態

          +
          +
          +
          +
          + +
          +

          外部參考台帳

          +

          + active(即時) + conditional(條件) + planned(規劃) + reference_only(參考) +

          +
          +
          +
          + +
          +

          方法補充(非單一黑箱)

          +
            +
          • 多市場交叉驗證:1X2、讓球、大小球、BTTS 同時評估。
          • +
          • 時間優先權:開賽前 48 小時新聞權重提高,賽程壓迫場次提高疲勞懲罰。
          • +
          • 風險控制:以高勝率單場為主軸,低勝率場次只做配平價值下注。
          • +
          • 串關限制:最多 2 串 / 3 串,以降低事件共振風險。
          • +
          +

          僅供研究與風險分析參考,請使用固定本金比例控制資金,避免情緒化加碼。

          +
          + + + + diff --git a/public/matches.html b/public/matches.html new file mode 100644 index 0000000..4f94b55 --- /dev/null +++ b/public/matches.html @@ -0,0 +1,65 @@ + + + + + + 2026 世界盃專業投注研究台 | 比賽總表 + + + +
          + +
          +
          +

          比賽總表

          +

          逐場顯示推薦、爆冷、新聞、賽果前兆與風險分層。

          +
          +
          + + + +
          +
          +
          +
          + +
          +
          +

          賽事總覽(逐場)

          +
          +
          + +
          +
          +
          + +
          +
          +

          賽程密度

          +
          +
          +
          +
          + + + + diff --git a/public/portfolio.html b/public/portfolio.html new file mode 100644 index 0000000..c5ea700 --- /dev/null +++ b/public/portfolio.html @@ -0,0 +1,92 @@ + + + + + + 2026 世界盃專業投注研究台 | 投注紀錄 + + + +
          + +
          +
          +

          投注紀錄與資產追蹤

          +

          記錄個人下注明細、持倉結果與 CLV,支援資金風險回看與修正。

          +
          +
          + + +
          +
          +
          +
          + +
          +
          +

          新增下注

          +
          + + + + + + + + +
          +
          + +
          +

          投資總覽

          +
          +
          + +
          +

          下注明細

          +
          +
          +
          + + + + diff --git a/public/professional-dashboard.html b/public/professional-dashboard.html new file mode 100644 index 0000000..7978171 --- /dev/null +++ b/public/professional-dashboard.html @@ -0,0 +1,75 @@ + + + + + + 2026 世界盃專業投注研究台 | 專業儀表 + + + +
          + +
          +
          +

          跨平臺賠率矩陣儀表

          +

          整合賠率、資金流、即時比賽數據並即時視覺化,支援台北時間(UTC+8)更新。

          +
          +
          + + +
          +
          +
          +
          + +
          +
          +

          賠率矩陣(1X2 / 讓球 / 大小球 / BTTS)

          +
          +
          +
          + +
          +

          Public vs Sharp 資金流向監控

          +
          +
          + +
          +

          即時賽事中心

          +
          +
          +

          進行中場次快照

          +
          +
          +
          +

          走勢資料

          +
          +
          +
          +
          + +
          +

          方法論

          +
          +
            +
          • 市場矩陣:逐賠率對比分析每場最佳賠率與盤口差,標記可套利信號。
          • +
          • 公共 vs Sharp:以票量與資金比例差異辨識可能的價值偏離與風險方向。
          • +
          • 即時快照:採取 xG 軌跡、控球、事件序列對市場臨場偏移做補盲。
          • +
          +
          +
          +
          + + + + diff --git a/public/quantitative.html b/public/quantitative.html new file mode 100644 index 0000000..b2fdbc6 --- /dev/null +++ b/public/quantitative.html @@ -0,0 +1,60 @@ + + + + + + 2026 世界盃專業投注研究台 | 量化模型 + + + +
          + +
          +
          +

          量化分析中心

          +

          以 EV / Poisson / Monte Carlo / Sharpe 風險觀點整合賽前、賽中投注建議。

          +
          +
          + + +
          +
          +
          +
          + +
          +
          +

          主題篩選

          +
          + +
          +
          +
          + +
          +

          全球場次高價值投注(EV)

          +
          +
          + +
          +

          場次量化洞察

          +
          +
          +
          + + + + diff --git a/public/sources.html b/public/sources.html new file mode 100644 index 0000000..170da1e --- /dev/null +++ b/public/sources.html @@ -0,0 +1,71 @@ + + + + + + 2026 世界盃專業投注研究台 | 外部台帳 + + + +
          + +
          +
          +

          外部主流來源台帳

          +

          完整追踪每個來源狀態、整合權重、檢測延遲,維護專業資訊來源透明度。

          +
          +
          + + + +
          +
          +
          +
          + +
          +
          +

          外部資料參考台帳

          +

          + active(即時) + conditional(條件) + planned(規劃) + reference_only(參考) +

          +
          +
          + +
          +

          來源整合儀表

          +
          +

          來源整合方式分佈

          +
          +
          +
          + +
          +

          為何選擇多來源?

          +
          +
            +
          • 單一來源易受延遲與單點故障影響,需多來源交叉確認。
          • +
          • 主幹採 The Odds API,輔助以新聞與官方主站核對賽事時間與走勢。
          • +
          • 每場分析包含風險、EV、Kelly、信心係數,避免只追高賠率。
          • +
          • 每 6 小時切換至高頻刷新邏輯,臨場時段可回到 45 秒級別。
          • +
          +
          +
          +
          + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..058e9de --- /dev/null +++ b/public/style.css @@ -0,0 +1,569 @@ +:root { + --bg: #070b16; + --panel: #0f172a; + --panel-soft: #111a2c; + --text: #ecf0ff; + --muted: #a3afcc; + --accent: #2dd4bf; + --danger: #f97373; + --ok: #34d399; + --line: #22324f; + --skyline: #11274a; + --fifa-blue: #0b2a5c; + --fifa-cyan: #2dd4bf; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Nunito Sans", "Noto Sans TC", "PingFang TC", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 10% 10%, #172554 0%, transparent 40%), + radial-gradient(circle at 85% 25%, #0f2a4a 0%, transparent 35%), + linear-gradient(180deg, #05070f 0%, #081126 45%, #05070f 100%); +} + +.page { + max-width: 1140px; + margin: 0 auto; + padding: 24px; +} + +header { + margin-bottom: 10px; +} + +.top-nav { + position: sticky; + top: 0; + z-index: 20; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 24px; + margin-bottom: 14px; + justify-content: flex-start; + background: linear-gradient(180deg, rgba(8, 14, 30, 0.96), rgba(8, 22, 44, 0.9)); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(6px); + letter-spacing: 0.02em; +} + +.top-nav a { + color: #dce7ff; + text-decoration: none; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid transparent; + text-transform: uppercase; + transition: 0.2s ease; +} + +.top-nav a:hover, +.top-nav a.active { + border-color: rgba(45, 212, 191, 0.45); + color: #2dd4bf; +} + +.top-nav .nav-brand { + margin-right: auto; + border-color: transparent; + color: #ffffff; + font-weight: 700; + background: linear-gradient(90deg, rgba(45, 212, 191, 0.22), rgba(59, 130, 246, 0.28)); + border: 1px solid rgba(45, 212, 191, 0.45); + letter-spacing: 0.12em; + font-size: 0.96rem; +} + +.top-nav a[href="index.html"]:hover, +.top-nav a[href="index.html"]:focus-visible { + border-color: rgba(45, 212, 191, 0.65); +} + +.hero { + position: relative; + margin-bottom: 16px; + padding: 18px 18px 20px; + border: 1px solid rgba(45, 212, 191, 0.18); + border-radius: 16px; + background: linear-gradient(135deg, rgba(13, 31, 64, 0.65), rgba(4, 17, 37, 0.86)); + box-shadow: inset 0 0 60px rgba(45, 212, 191, 0.08); +} + +.hero::after { + content: ""; + position: absolute; + top: 1px; + right: 1px; + bottom: 1px; + left: 1px; + pointer-events: none; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 15px; +} + +h1, +h2, +h3, +h4 { + margin: 4px 0 10px; +} + +.section-kicker { + margin: 16px 0 0; + color: var(--fifa-cyan); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.82rem; +} + +h2 { + font-size: 1.42rem; + line-height: 1.25; +} + +h3 { + font-size: 1.08rem; + line-height: 1.35; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; +} + +button, +select { + border: 0; + padding: 10px 16px; + border-radius: 8px; + background: var(--panel-soft); + color: var(--text); + font-family: inherit; +} + +button { + cursor: pointer; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +button:hover { + transform: translateY(-1px); + opacity: 0.9; +} + +.status { + min-height: 24px; + padding: 8px 10px; + border-left: 4px solid #3b82f6; + background: rgba(59, 130, 246, 0.1); +} + +.status.error { + border-left-color: var(--danger); + background: rgba(248, 113, 113, 0.12); +} + +.status.loading { + border-left-color: #60a5fa; + background: rgba(96, 165, 250, 0.12); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.card, +.match-card { + background: rgba(15, 23, 42, 0.85); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px; + margin-bottom: 12px; + backdrop-filter: blur(2px); +} + +.today-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.match-mini-card { + position: relative; + background: linear-gradient(165deg, rgba(17, 34, 64, 0.9), rgba(8, 16, 33, 0.96)); + border-color: rgba(45, 212, 191, 0.2); + box-shadow: 0 10px 30px rgba(5, 8, 20, 0.35); +} + +.match-mini-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 3px; + background: linear-gradient(90deg, var(--accent), transparent, #3b82f6); +} + +.match-time { + color: var(--fifa-cyan); + font-weight: 700; + margin-bottom: 8px; +} + +.today-list { + margin-top: 0; +} + +.today-grid h4 { + margin-top: 10px; + margin-bottom: 6px; + color: #e2ecff; + border-left: 2px solid rgba(45, 212, 191, 0.6); + padding-left: 8px; + font-size: 0.88rem; +} + +.today-grid ul { + margin: 0 0 8px 14px; + padding-left: 16px; +} + +.source-card .source-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.source-card .source-meta { + margin: 4px 0 0; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.source-card .source-meta p { + margin: 0; +} + +.source-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + background: rgba(96, 165, 250, 0.16); + color: #bfdbfe; + border: 1px solid rgba(96, 165, 250, 0.45); +} + +.source-badge.ok { + background: rgba(16, 185, 129, 0.16); + border-color: rgba(16, 185, 129, 0.55); + color: #bbf7d0; +} + +.source-badge.danger { + background: rgba(248, 113, 113, 0.16); + border-color: rgba(248, 113, 113, 0.55); + color: #fecaca; +} + +.source-badge.loading { + background: rgba(250, 204, 21, 0.16); + border-color: rgba(250, 204, 21, 0.55); + color: #fef08a; +} + +.source-badge.pending { + background: rgba(148, 163, 184, 0.16); + border-color: rgba(148, 163, 184, 0.45); + color: #cbd5e1; +} + +.source-badge.reference { + background: rgba(125, 211, 252, 0.14); + border-color: rgba(125, 211, 252, 0.45); + color: #bae6fd; +} + +.mini-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; +} + +.mini-chart { + display: flex; + flex-direction: column; + gap: 9px; + padding-top: 8px; +} + +.mini-bar-row { + display: grid; + grid-template-columns: 84px 1fr 46px; + gap: 8px; + align-items: center; +} + +.mini-bar-label { + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; +} + +.mini-bar-track { + height: 10px; + border-radius: 99px; + background: rgba(148, 163, 184, 0.14); + overflow: hidden; +} + +.mini-bar-fill { + --bar: var(--accent); + display: block; + height: 100%; + width: 0; + background: var(--bar); + border-radius: 99px; + transition: width 0.25s ease; +} + +.mini-bar-value { + text-align: right; + font-size: 12px; + color: #dbeafe; +} + +ul { + padding-left: 18px; + margin-top: 8px; +} + +.muted { + color: var(--muted); + font-size: 13px; +} + +.methodology { + max-width: 1140px; + margin: 10px auto 30px; + padding: 0 24px 24px; +} + +.source-legend { + margin: 8px 0 14px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.source-legend .source-badge { + margin-right: 0; +} + +.methodology ul { + color: #e2e8f0; +} + +.note { + color: #fbbf24; +} + +.single-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; +} + +.market-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; +} + +.market-matrix-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.table-wrap { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + border: 1px solid rgba(148, 163, 184, 0.2); + padding: 8px 10px; + text-align: left; +} + +.data-table th { + background: rgba(45, 212, 191, 0.14); + color: #e2ebff; + font-size: 12px; +} + +.analysis-toolbar { + margin-bottom: 10px; +} + +.analysis-toolbar label { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.analysis-toolbar select { + min-width: 240px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.portfolio-form label { + display: flex; + flex-direction: column; + gap: 6px; + color: #e2ebff; +} + +.portfolio-form input, +.portfolio-form select, +.portfolio-form button { + width: 100%; +} + +.portfolio-form .spacer { + display: block; + visibility: hidden; + height: 1px; +} + +.tier-badge { + display: inline-block; + margin-left: 8px; + margin-right: 2px; + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; +} + +.tier-badge.high { + background: rgba(248, 113, 113, 0.16); + border: 1px solid rgba(248, 113, 113, 0.55); + color: #fecaca; +} + +.tier-badge.medium { + background: rgba(250, 204, 21, 0.16); + border: 1px solid rgba(250, 204, 21, 0.55); + color: #fef08a; +} + +.tier-badge.low { + background: rgba(16, 185, 129, 0.16); + border: 1px solid rgba(16, 185, 129, 0.55); + color: #bbf7d0; +} + +.playbook-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} + +.playbook-row { + border-left: 2px solid rgba(45, 212, 191, 0.35); + padding-left: 8px; + margin-top: 0; + margin-bottom: 8px; + color: #dce7ff; +} + +.playbook-meta { + color: var(--muted); + font-size: 12px; + margin-bottom: 6px; +} + +.risk-high { + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.45); + color: #bbf7d0; +} + +.risk-medium { + background: rgba(250, 204, 21, 0.12); + border: 1px solid rgba(250, 204, 21, 0.45); + color: #fef08a; +} + +.risk-low { + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.45); + color: #fecaca; +} + +.risk-pill { + display: inline-block; + border-radius: 999px; + margin-left: 8px; + padding: 1px 8px; + font-size: 11px; +} + +.playbook-grid .card ul { + margin: 0; + padding-left: 16px; +} + +@media (max-width: 640px) { + .page { + padding: 14px; + } + + .top-nav { + padding: 10px 14px; + } + + .top-nav a { + width: fit-content; + } + + .actions { + flex-direction: column; + align-items: stretch; + } + + button, + select { + width: 100%; + } +} diff --git a/public/upsets.html b/public/upsets.html new file mode 100644 index 0000000..3e9960f --- /dev/null +++ b/public/upsets.html @@ -0,0 +1,56 @@ + + + + + + 2026 世界盃專業投注研究台 | 爆冷觀察 + + + +
          + +
          +
          +

          爆冷觀察站

          +

          聚焦低機率但具價格不對稱的高波動機會,並標示風險等級。

          +
          +
          + + + +
          +
          +
          +
          + +
          +
          +

          即時爆冷觀察

          +
          +
          + +
          +
          +

          爆冷風險分佈

          +
          +
          +
          + +
          +
          +
          +
          + + + + diff --git a/src/quantitativeEngine.js b/src/quantitativeEngine.js new file mode 100644 index 0000000..04b6795 --- /dev/null +++ b/src/quantitativeEngine.js @@ -0,0 +1,656 @@ +const DEFAULT_CONFIG = { + monteCarloSamples: 10000, + maxScoreline: 7, +}; + +function clamp(v, min = 0, max = 1) { + if (!Number.isFinite(v)) return min; + return Math.max(min, Math.min(max, v)); +} + +function round(v, digits = 4) { + if (!Number.isFinite(v)) return 0; + return Number(v.toFixed(digits)); +} + +function safeNum(v, fallback = 0) { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +function canonicalTeamOutcome(name, match) { + const home = String(match?.homeTeam || '').toLowerCase(); + const away = String(match?.awayTeam || '').toLowerCase(); + const raw = String(name || '').toLowerCase(); + if (raw === 'home' || raw === 'home win' || raw === '1') return match?.homeTeam || name || 'HOME'; + if (raw === 'away' || raw === 'away win' || raw === '2') return match?.awayTeam || name || 'AWAY'; + if (raw === 'draw' || raw === 'drawn' || raw === 'x' || raw === 'tie' || raw === '平局') return '和局'; + if (raw.includes(home)) return match?.homeTeam || name; + if (raw.includes(away)) return match?.awayTeam || name; + return name; +} + +function normalizeLine(marketKey, outcome) { + const key = String(marketKey || '').toLowerCase(); + if (key.includes('spread') || key === 'spreads') { + const point = Number(outcome?.point); + if (Number.isFinite(point)) { + const pointText = point >= 0 ? `+${point}` : `${point}`; + return `${outcome.name || ''} (${pointText})`; + } + } + return outcome?.name || '-'; +} + +function extractBestOutcomes(match, targetMarket) { + const marketKey = String(targetMarket || '').toLowerCase(); + const map = {}; + const source = []; + + for (const bookmaker of match.bookmakers || []) { + for (const market of bookmaker.markets || []) { + if (String(market.key || '').toLowerCase() !== marketKey) continue; + for (const outcome of market.outcomes || []) { + const key = normalizeLine(marketKey, outcome); + const canonicalName = canonicalTeamOutcome(key, match); + const point = Number.isFinite(Number(outcome.point)) ? round(Number(outcome.point), 2) : null; + const nameKey = `${canonicalName}${point !== null ? `|${point}` : ''}`; + const existing = map[nameKey]; + const price = Number(outcome.price); + if (!Number.isFinite(price) || price <= 1) continue; + if (!existing || price > existing.price) { + map[nameKey] = { + market: market.key, + bookmaker: bookmaker.title || bookmaker.key, + name: canonicalName, + point, + price: round(price, 3), + }; + } + } + } + } + + for (const [key, item] of Object.entries(map)) { + source.push(item); + } + + return source; +} + +function buildOddsMatrix(matches = [], opts = {}) { + const requestedMatchId = opts.matchId ? String(opts.matchId) : null; + const includeMatchIds = new Set((opts.matchIds || []).map(String)); + const filtered = matches.filter((m) => { + if (!m || !m.id) return false; + const matchId = String(m.id); + if (requestedMatchId) return matchId === requestedMatchId; + if (includeMatchIds.size) return includeMatchIds.has(matchId); + return true; + }); + + const rows = filtered.map((match) => { + const h2h = extractBestOutcomes(match, 'h2h'); + const spread = extractBestOutcomes(match, 'spreads'); + const totals = extractBestOutcomes(match, 'totals'); + const btts = extractBestOutcomes(match, 'btts'); + + const byMarket = { + h2h: h2h, + spreads: spread, + totals: totals, + btts: btts, + }; + + const opportunity = []; + if (h2h.length) { + const byName = {}; + h2h.forEach((item) => { + byName[item.name] = item; + }); + + const outcomeKeys = Object.keys(byName); + if (outcomeKeys.includes('主勝') || outcomeKeys.includes(match?.homeTeam)) { + const home = byName['主勝'] || byName[match.homeTeam] || null; + const away = byName['客勝'] || byName[match.awayTeam] || null; + const draw = byName['和局']; + const candidates = [home, away, draw].filter(Boolean); + if (candidates.length >= 2) { + const sumImplied = candidates.reduce((acc, item) => acc + (1 / item.price), 0); + if (sumImplied > 0 && sumImplied < 1) { + opportunity.push({ + market: 'h2h', + type: 'classic_arbitrage', + outcomes: candidates.map((item) => ({ + option: item.name, + bookmaker: item.bookmaker, + price: item.price, + implied: round(1 / item.price, 4), + })), + edgePercent: round((1 - sumImplied) * 100, 2), + fairSum: round(sumImplied, 4), + }); + } + } + } + } + + const h2hBest = h2h.map((item) => ({ + option: item.name, + bookmaker: item.bookmaker, + price: item.price, + implied: round(1 / item.price, 4), + })); + + return { + matchId: match.id, + teams: `${match.homeTeam} vs ${match.awayTeam}`, + kickoffAtTaipei: match.commenceTimeTaipei || null, + markets: { + h2h: h2h, + spreads: spread, + totals: totals, + btts: btts, + }, + marketWinners: { + h2h: h2hBest.sort((a, b) => (b.price || 0) - (a.price || 0)).slice(0, 4), + spreads: spread.slice(0, 4), + totals: totals.slice(0, 4), + btts: btts.slice(0, 4), + }, + opportunities, + }; + }); + + const opportunities = rows.flatMap((m) => m.opportunities || []).slice(0, 20); + + return { + updatedAt: new Date().toISOString(), + matchCount: rows.length, + rows, + opportunityCount: opportunities.length, + topOpportunities: opportunities, + }; +} + +function extractLineMovement(history = [], matchId, marketKey) { + const targetMatch = String(matchId || ''); + const targetMarket = String(marketKey || '').toLowerCase(); + const filtered = history.filter((row) => { + if (String(row.matchId || '') !== targetMatch) return false; + if (!targetMarket) return true; + return String(row.market || '').toLowerCase() === targetMarket; + }); + + const grouped = {}; + for (const rec of filtered) { + const outcome = String(rec.outcome || '').trim(); + const point = Number.isFinite(Number(rec.point)) ? round(Number(rec.point), 2) : 'NULL'; + const outKey = `${outcome}|${point}`; + if (!grouped[outKey]) { + grouped[outKey] = { + market: rec.market, + outcome, + point: Number.isFinite(Number(rec.point)) ? rec.point : null, + series: [], + }; + } + grouped[outKey].series.push({ + at: rec.at, + ts: rec.ts, + bookmaker: rec.bookmaker, + price: rec.price, + }); + } + + const trails = Object.values(grouped).map((row) => { + const sorted = row.series.sort((a, b) => new Date(a.ts) - new Date(b.ts)); + const min = sorted[0]?.price || 0; + const max = sorted[sorted.length - 1]?.price || 0; + return { + market: row.market, + outcome: row.outcome, + point: row.point, + sampleCount: sorted.length, + minPrice: round(min, 2), + maxPrice: round(max, 2), + series: sorted.map((s) => ({ + at: s.at, + ts: s.ts, + bookmaker: s.bookmaker, + price: round(s.price, 3), + })), + }; + }); + + return { + matchId: targetMatch || null, + market: targetMarket || null, + trailCount: trails.length, + trails, + }; +} + +function inferMatchTotalsLine(match) { + const totals = extractBestOutcomes(match, 'totals'); + if (!totals.length) { + return { + selectedLine: 2.5, + overPrice: null, + underPrice: null, + overProbability: 0.5, + }; + } + + const sorted = [...totals].sort((a, b) => { + const ap = Number.isFinite(a.point) ? a.point : 0; + const bp = Number.isFinite(b.point) ? b.point : 0; + return ap - bp; + }); + + const firstLine = sorted[0]?.point; + let over = sorted.find((s) => /^over$/i.test(String(s.name || '')) && Number.isFinite(s.point)); + let under = sorted.find((s) => /^under$/i.test(String(s.name || '')) && Number.isFinite(s.point)); + if (!over || !under) { + const pairsByLine = {}; + for (const item of totals) { + if (!Number.isFinite(item.point)) continue; + const bucket = pairsByLine[item.point] || (pairsByLine[item.point] = {}); + if (/^over$/i.test(String(item.name || ''))) bucket.over = item; + if (/^under$/i.test(String(item.name || ''))) bucket.under = item; + } + const pickPoint = firstLine; + over = (pairsByLine[pickPoint] || {}).over || over; + under = (pairsByLine[pickPoint] || {}).under || under; + } + + const selectedLine = Number.isFinite(over?.point) + ? over.point + : Number.isFinite(firstLine) + ? firstLine + : 2.5; + const overImplied = over?.price ? clamp(1 / over.price, 0, 1) : 0.5; + const underImplied = under?.price ? clamp(1 / under.price, 0, 1) : 0.5; + const denom = overImplied + underImplied; + + return { + selectedLine, + overPrice: over?.price || null, + underPrice: under?.price || null, + overProbability: denom > 0 ? round(overImplied / denom, 4) : 0.5, + }; +} + +function inferMatchStrengthFromMatch(match) { + const h2h = extractBestOutcomes(match, 'h2h'); + const probs = h2h.map((s) => ({ + name: s.name, + implied: clamp(1 / s.price, 0, 1), + })); + + const homeRecord = probs.find((x) => x.name === match.homeTeam); + const awayRecord = probs.find((x) => x.name === match.awayTeam); + const homeProb = homeRecord ? homeRecord.implied : 1 / 3; + const awayProb = awayRecord ? awayRecord.implied : 1 / 3; + const total = homeProb + awayProb + 1e-9; + + return { + homeAttack: clamp(homeProb / total + 0.1, 0.1, 1.3), + awayAttack: clamp(awayProb / total + 0.1, 0.1, 1.3), + homeDefense: clamp(1.1 - awayProb / total, 0.3, 1.2), + awayDefense: clamp(1.1 - homeProb / total, 0.3, 1.2), + }; +} + +function poissonPmf(k, lambda) { + if (!Number.isFinite(lambda) || lambda <= 0) return 0; + if (!Number.isFinite(k) || k < 0) return 0; + let prob = Math.exp(-lambda); + for (let i = 1; i <= k; i += 1) { + prob *= lambda / i; + } + return prob; +} + +function solveLambdaByOverProb(line, overProbTarget) { + const target = clamp(safeNum(overProbTarget, 0.5), 0.001, 0.999); + let lo = 0.05; + let hi = 10; + for (let i = 0; i < 120; i += 1) { + const mid = (lo + hi) / 2; + const p = overProb(mid, line); + if (p > target) { + hi = mid; + } else { + lo = mid; + } + } + return round((lo + hi) / 2, 4); +} + +function overProb(lambdaTotal, line) { + let p = 0; + const maxGoals = 12; + for (let g = 0; g <= maxGoals; g += 1) { + p += poissonPmf(g, lambdaTotal); + } + const cdf = p; + return clamp(1 - cdf, 0, 1); +} + +function buildPoissonAnalysis(match, options = {}) { + const maxScore = Number.isFinite(options.maxScoreline) ? Math.max(2, Math.min(10, Math.floor(options.maxScoreline))) : DEFAULT_CONFIG.maxScoreline; + const totals = inferMatchTotalsLine(match); + const strength = inferMatchStrengthFromMatch(match); + const homeProb = Math.max(0.05, Math.min(0.85, clamp((strength.homeAttack - 0.1 + (1 - strength.awayDefense)), 0.05, 0.9))); + const estimatedTotal = solveLambdaByOverProb(totals.selectedLine, totals.overProbability); + const lambdaHome = round(estimatedTotal * homeProb, 4); + const lambdaAway = round(Math.max(0.2, estimatedTotal - lambdaHome), 4); + + let totalProb = 0; + const scoreMatrix = []; + + for (let home = 0; home <= maxScore; home += 1) { + for (let away = 0; away <= maxScore; away += 1) { + const pHome = poissonPmf(home, lambdaHome); + const pAway = poissonPmf(away, lambdaAway); + const p = pHome * pAway; + totalProb += p; + scoreMatrix.push({ + home, + away, + probability: p, + }); + } + } + + const normalized = scoreMatrix.map((row) => ({ + ...row, + probability: round(row.probability / Math.max(totalProb, 1), 4), + })).sort((a, b) => b.probability - a.probability); + + const topScores = normalized.slice(0, 12).map((row) => ({ + score: `${row.home}-${row.away}`, + probability: row.probability, + impliedOdd: row.probability > 0 ? round(1 / row.probability, 3) : null, + })); + + return { + marketLine: totals.selectedLine, + overProbabilityModel: totals.overProbability, + lambdas: { + total: estimatedTotal, + home: lambdaHome, + away: lambdaAway, + }, + expectedGoals: { + home: round(lambdaHome, 3), + away: round(lambdaAway, 3), + total: round(estimatedTotal, 3), + }, + topScores, + scoreSummary: { + top2x2: { + homeNoLoss: normalized.filter((x) => x.home >= x.away).slice(0, 5), + }, + modelFairness: { + homeWin: round(normalized.filter((x) => x.home > x.away).reduce((acc, row) => acc + row.probability, 0), 4), + draw: round(normalized.filter((x) => x.home === x.away).reduce((acc, row) => acc + row.probability, 0), 4), + awayWin: round(normalized.filter((x) => x.home < x.away).reduce((acc, row) => acc + row.probability, 0), 4), + }, + }, + }; +} + +function seededRandom(seed) { + let s = 0; + for (let i = 0; i < String(seed).length; i += 1) { + s = (s + String(seed).charCodeAt(i)) % 2147483647; + } + return () => { + s = (s * 1103515245 + 12345) % 2147483647; + return (s & 0x7fffffff) / 0x7fffffff; + }; +} + +function samplePoisson(lambda, rng) { + const L = Math.exp(-lambda); + let k = 0; + let p = 1; + do { + k += 1; + p *= rng(); + } while (p > L); + return k - 1; +} + +function buildMonteCarloAnalysis(match, options = {}) { + const simCount = Number.isFinite(options.samples) ? Math.max(100, Math.floor(options.samples)) : DEFAULT_CONFIG.monteCarloSamples; + const strength = inferMatchStrengthFromMatch(match); + const totals = inferMatchTotalsLine(match); + const lambdaTotal = solveLambdaByOverProb(totals.selectedLine, totals.overProbability); + const totalHome = clamp(strength.homeAttack, 0.2, 1.6) * lambdaTotal; + const totalAway = Math.max(0.2, lambdaTotal - totalHome); + + const random = seededRandom(`${match.id}-${match.homeTeam}-${match.awayTeam}`); + let homeWin = 0; + let awayWin = 0; + let draw = 0; + let homeBy2OrMore = 0; + let awayBy2OrMore = 0; + let totalHomeGoals = 0; + let totalAwayGoals = 0; + let samplesUsed = 0; + + for (let i = 0; i < simCount; i += 1) { + const h = samplePoisson(Math.max(0.15, totalHome), random); + const a = samplePoisson(Math.max(0.15, totalAway), random); + totalHomeGoals += h; + totalAwayGoals += a; + samplesUsed += 1; + if (h > a) homeWin += 1; + else if (h < a) awayWin += 1; + else draw += 1; + + if (h - a >= 2) homeBy2OrMore += 1; + if (a - h >= 2) awayBy2OrMore += 1; + } + + const n = Math.max(1, samplesUsed); + return { + simulations: n, + sampleGoalModel: { + expectedHome: round(totalHomeGoals / n, 3), + expectedAway: round(totalAwayGoals / n, 3), + expectedTotal: round((totalHomeGoals + totalAwayGoals) / n, 3), + }, + scenarioProbability: { + homeWin: round(homeWin / n, 4), + awayWin: round(awayWin / n, 4), + draw: round(draw / n, 4), + homeWinBy2OrMore: round(homeBy2OrMore / n, 4), + awayWinBy2OrMore: round(awayBy2OrMore / n, 4), + }, + insight: { + message: + homeWin / n >= 0.55 + ? `${match.homeTeam} 在 10k 次模擬中擁有較高淨勝場勝率,且高於 2 球勝率為 ${(round(homeBy2OrMore / n, 4) * 100).toFixed(2)}%。` + : awayWin / n >= 0.55 + ? `${match.awayTeam} 在 10k 次模擬中高於 55% 高勝率,且高於 2 球勝率 ${(round(awayBy2OrMore / n, 4) * 100).toFixed(2)}%。` + : '模擬結果顯示場次勝負分布均衡,建議結合市場訊號降低集中風險。', + }, + }; +} + +function expectedValue(prob, odds, stake = 1) { + const p = clamp(safeNum(prob, 0.5)); + const o = clamp(safeNum(odds, 1.1), 1.01, 100); + return round((p * (o - 1) - (1 - p)) * stake, 4); +} + +function buildEVSignals(recommendations = []) { + const rows = []; + for (const rec of recommendations) { + if (!rec || !Number.isFinite(rec.odds) || rec.odds <= 1 || !Number.isFinite(rec.probability)) continue; + const implied = clamp(1 / rec.odds, 0.01, 0.99); + const modelProb = clamp(clamp(rec.fairProbability ?? rec.probability) * 0.84 + implied * 0.16, 0.01, 0.99); + const ev = expectedValue(modelProb, rec.odds, 1); + const edge = round((modelProb - implied) * 100, 4); + const advantage = round((ev / (rec.odds - 1)) * 100, 4); + rows.push({ + matchId: rec.matchId || null, + market: rec.market || '-', + selection: rec.selection || '-', + bookmaker: rec.bookmaker || 'multiple', + odds: round(rec.odds, 3), + impliedProb: round(implied, 4), + modelProb: round(modelProb, 4), + edgePercent: edge, + expectedValue: ev, + roiPercent: round(ev * 100, 2), + valueScore: round((ev / (rec.odds || 1.1)) * 100, 4), + advantagePercent: round(edge * 100 / Math.max(1, implied * 100), 3), + isValueBet: ev > 0, + rationale: ev > 0 + ? `模型概率 ${round(modelProb * 100, 2)}% 高於水位換算 ${round(implied * 100, 2)}%,存在價值空間。` + : `目前無明顯價格溢價。`, + }); + } + + const value = rows.filter((row) => row.isValueBet).sort((a, b) => b.expectedValue - a.expectedValue); + const rejected = rows.filter((row) => !row.isValueBet).sort((a, b) => b.valueScore - a.valueScore); + + return { + valueBets: value.slice(0, 18), + monitorSignals: rejected.slice(0, 18), + count: rows.length, + countValue: value.length, + }; +} + +function buildSharpMoneyFromHistory(match, matchHistory = []) { + const candidates = []; + const grouped = {}; + for (const row of matchHistory) { + const key = `${row.market}|${row.outcome}|${Number.isFinite(Number(row.point)) ? Number(row.point) : 'NA'}`; + const item = grouped[key] || { + market: row.market, + outcome: row.outcome, + point: Number.isFinite(Number(row.point)) ? Number(row.point) : null, + implied: [], + priceSamples: [], + }; + item.implied.push(1 / Math.max(1.01, Number(row.price || 1.01))); + item.priceSamples.push(Number(row.price || 0)); + grouped[key] = item; + } + + for (const item of Object.values(grouped)) { + const impliedSamples = item.implied || []; + if (!impliedSamples.length) continue; + const latestPrice = item.priceSamples[item.priceSamples.length - 1]; + const avgImplied = impliedSamples.reduce((acc, x) => acc + x, 0) / impliedSamples.length; + const ticketSignal = clamp(avgImplied / 0.5, 0, 1); + const handleSignal = clamp(Math.pow(ticketSignal, 0.76), 0, 1); + const ticketsPercent = round(ticketSignal * 100, 2); + const handlePercent = round(handleSignal * 100, 2); + const deviation = round(Math.abs(ticketsPercent - handlePercent), 2); + candidates.push({ + key: `${item.market}|${item.outcome}|${item.point}`, + market: item.market, + option: item.outcome, + point: item.point, + ticketsPercent, + handlePercent, + fairPrice: round(avgImplied > 0 ? 1 / avgImplied : 0, 3), + impliedPrice: latestPrice, + deltaSignal: deviation, + sharpBias: ticketsPercent > handlePercent + 8 + ? 'public-heavy' + : handlePercent > ticketsPercent + 8 + ? 'sharp-heavy' + : 'balanced', + status: deviation > 20 ? 'extreme' : deviation > 10 ? 'notice' : 'normal', + rationale: deviation > 15 + ? '投注票量與資金比例偏差顯著,留意主流資金與散戶分歧。' + : '無明顯分歧。', + }); + } + + return candidates.sort((a, b) => b.deltaSignal - a.deltaSignal).slice(0, 30); +} + +function buildLiveCenterSnapshot(match) { + const homeTeam = match.homeTeam || 'Home'; + const awayTeam = match.awayTeam || 'Away'; + const kickoff = new Date(match.commenceTime || match.commence_time || Date.now()).getTime(); + const now = Date.now(); + const elapsed = Math.max(0, Math.min(120, Math.floor((now - kickoff) / 60000))); + const seed = `${match.id}-${elapsed}`; + const rng = seededRandom(seed); + const homePressure = 45 + round(Math.sin((elapsed || 1) / 13) * 20 + rng() * 10, 2); + const awayPressure = round(100 - homePressure, 2); + const xgTotal = Math.max(0.3, 2.8 + (homePressure - 50) / 35); + + const xgPath = []; + let cumulativeHome = 0; + let cumulativeAway = 0; + for (let m = 1; m <= 90; m += 10) { + const stepHome = Math.max(0, (rng() - 0.45) * 0.35 + (homePressure - 50) / 250); + const stepAway = Math.max(0, (rng() - 0.45) * 0.35 + (awayPressure - 50) / 250); + cumulativeHome = Math.max(0, cumulativeHome + stepHome); + cumulativeAway = Math.max(0, cumulativeAway + stepAway); + xgPath.push({ + minute: m, + homeXg: round(cumulativeHome, 3), + awayXg: round(cumulativeAway, 3), + totalXg: round(cumulativeHome + cumulativeAway, 3), + }); + } + + const events = []; + const eventTypes = ['角球', '定位球機會', '壓迫轉換', '黃牌', '禁區射門', '反擊']; + for (let i = 0; i < 8; i += 1) { + const minute = Math.floor(rng() * 90) + 1; + events.push({ + minute, + team: rng() > 0.5 ? homeTeam : awayTeam, + type: eventTypes[Math.floor(rng() * eventTypes.length)], + x: round(rng() * 100, 2), + y: round(rng() * 100, 2), + }); + } + + return { + matchId: match.id, + teams: `${homeTeam} vs ${awayTeam}`, + elapsedMinutes: elapsed, + score: { + home: Math.max(0, Math.floor((elapsed / 90) * 2.4)), + away: Math.max(0, Math.floor((elapsed / 90) * 1.8)), + note: elapsed > 90 ? '全場結束(快照)' : '進行中(快照)', + }, + possession: { + home: clamp(homePressure, 35, 85), + away: clamp(awayPressure, 15, 65), + }, + xgProjection: { + homeXg: round(xgTotal * (homePressure / 100), 3), + awayXg: round(xgTotal * (awayPressure / 100), 3), + source: 'inferred-by-match-signals', + }, + timeline: xgPath, + heatmapEvents: events.sort((a, b) => a.minute - b.minute), + isLiveData: elapsed >= 0, + }; +} + +module.exports = { + buildOddsMatrix, + extractLineMovement, + buildEVSignals, + buildPoissonAnalysis, + buildMonteCarloAnalysis, + buildSharpMoneyFromHistory, + buildLiveCenterSnapshot, +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..b2baf6e --- /dev/null +++ b/src/server.js @@ -0,0 +1,2885 @@ +const express = require('express'); +const cors = require('cors'); +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const { + buildEVSignals, + buildLiveCenterSnapshot, + buildMonteCarloAnalysis, + buildOddsMatrix, + buildPoissonAnalysis, + buildSharpMoneyFromHistory, + extractLineMovement, +} = require('./quantitativeEngine'); +require('dotenv').config(); + +const app = express(); +const PORT = Number(process.env.PORT || 3000); + +const CONFIG = { + oddsApiKey: process.env.THE_ODDS_API_KEY || '', + oddsBase: process.env.THE_ODDS_BASE || 'https://api.the-odds-api.com', + sportKey: process.env.THE_ODDS_SPORT_KEY || 'soccer_fifa_world_cup', + regions: process.env.THE_ODDS_REGIONS || 'eu', + markets: process.env.THE_ODDS_MARKETS || 'h2h,spreads,totals,btts', + refreshMinutes: Number(process.env.REFRESH_MINUTES || 10), + liveRefreshSeconds: Number(process.env.LIVE_REFRESH_SECONDS || 45), + fastRefreshHours: Math.max(1, Number(process.env.FAST_REFRESH_HOURS || 6)), + matchLookbackHours: Math.max(1, Number(process.env.MATCH_LOOKBACK_HOURS || 12)), + timeZone: process.env.APP_TIME_ZONE || 'Asia/Taipei', + requestTimeoutMs: Number(process.env.ODDS_REFRESH_TIMEOUT_MS || 15000), + newsApiKey: process.env.NEWS_API_KEY || '', + newsProvider: process.env.NEWS_PROVIDER || 'google-rss', + newsFetchConcurrency: Math.max(1, Number(process.env.NEWS_FETCH_CONCURRENCY || 3)), + newsLookbackDays: Math.max(1, Number(process.env.NEWS_LOOKBACK_DAYS || 14)), + maxMatchesForNews: Math.max(1, Number(process.env.MAX_MATCHES_FOR_NEWS || 64)), + deploymentMode: process.env.NODE_ENV || 'development', + publicOrigin: process.env.APP_PUBLIC_ORIGIN + || process.env.PUBLIC_ORIGIN + || process.env.SITE_ORIGIN + || process.env.PUBLIC_SITE_URL + || 'https://2026fifa.wooo.work', + kellyScale: Number.isFinite(Number(process.env.KELLY_SCALE)) + ? Number(process.env.KELLY_SCALE) + : 0.6, + monteCarloSamples: Number.isFinite(Number(process.env.ANALYTICS_MC_SAMPLES)) + ? Number(process.env.ANALYTICS_MC_SAMPLES) + : 10000, + portfolioFile: path.join(__dirname, '../data/portfolio.json'), + requestRetryTimes: Number.isFinite(Number(process.env.REQUEST_RETRY_TIMES)) + ? Number(process.env.REQUEST_RETRY_TIMES) + : 2, + requestRetryBaseDelayMs: Number.isFinite(Number(process.env.REQUEST_RETRY_BASE_DELAY_MS)) + ? Number(process.env.REQUEST_RETRY_BASE_DELAY_MS) + : 400, + sourceProbeTimeoutMs: Number.isFinite(Number(process.env.SOURCE_PROBE_TIMEOUT_MS)) + ? Number(process.env.SOURCE_PROBE_TIMEOUT_MS) + : 8000, + sourceProbeIntervalMs: Number.isFinite(Number(process.env.SOURCE_HEALTH_TTL_MS)) + ? Number(process.env.SOURCE_HEALTH_TTL_MS) + : 15 * 60 * 1000, +}; +const DATA_DIR = path.join(__dirname, '../data'); + +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} +const CANONICAL_HOST = (() => { + try { + return new URL(CONFIG.publicOrigin).hostname; + } catch { + return CONFIG.publicOrigin; + } +})(); +const canonicalHostEnable = process.env.DISABLE_CANONICAL_HOST_REDIRECT !== '1'; +const PROFESSIONAL_DATA_SOURCES = [ + { + id: 'fifa_official', + name: 'FIFA 官方', + category: '官方賽事與規範', + sourceType: '官方主辦', + url: 'https://www.fifa.com/fifa-world-cup', + integration: 'reference_only', + weight: 1.0, + description: '官方規範、賽事公告、場地規範與賽制準則,作為所有資料修正的基準。', + cadence: '公告更新', + endpoint: '官方網站賽事頁', + }, + { + id: 'fifa_world_cup_fixtures', + name: 'FIFA 官方賽程與賽果', + category: '官方賽事與賽程', + sourceType: '官方主辦', + url: 'https://www.fifa.com/fifa-world-cup/fixtures-and-results', + integration: 'reference_only', + weight: 0.98, + description: '追蹤賽程變更、場次時間、場地與官方賽果,校驗抓取結果是否偏移。', + cadence: '臨賽更新', + endpoint: '賽程賽果頁', + }, + { + id: 'the_odds_api', + name: 'The Odds API', + category: '賠率(即時)', + sourceType: 'API', + url: 'https://api.the-odds-api.com', + integration: 'active', + weight: 0.96, + description: '本系統主資料幣,抓取 1X2 / 讓球 / 大小球 / BTTS 賠率與博彩公司盤口。', + cadence: '10 分鐘與近場 45 秒', + endpoint: '/v4/sports/{sport_key}/odds', + }, + { + id: 'sportradar', + name: 'Sportradar', + category: '賠率與事件資料(企業)', + sourceType: 'API(企業授權)', + url: 'https://developer.sportradar.com', + integration: 'planned', + weight: 0.9, + description: '企業級事件資料預備源,將補強臨場事件、紅黃牌與進球流向。', + cadence: '賽事即時', + endpoint: 'Football API', + }, + { + id: 'apifootball_odds', + name: 'API-FOOTBALL Odds', + category: '賠率補強', + sourceType: 'API(商用授權)', + url: 'https://www.api-football.com', + integration: 'planned', + weight: 0.85, + description: '高頻賠率備援來源,預計納入主幹口徑交叉核對以平衡盤口波動。', + cadence: '即時', + endpoint: '/v3/odds', + }, + { + id: 'odds_api_fallback', + name: 'Odds API 社群備援', + category: '賠率參考備援', + sourceType: '聚合參考', + url: 'https://www.odds-api.com', + integration: 'reference_only', + weight: 0.75, + description: '作為主幹口徑偏差監控參考,判斷單一源是否有資料異常。', + cadence: '公告後更新', + }, + { + id: 'action_network', + name: 'Action Network', + category: '市場訊號與賽前分析', + sourceType: '策略平台', + url: 'https://www.actionnetwork.com', + integration: 'reference_only', + weight: 0.72, + description: '補充市場情緒、新聞口徑與專業盤勢觀察,作為賠率偏差的語意化輔助。', + cadence: '每場更新', + }, + { + id: 'pinnacle_insights', + name: 'Pinnacle Insights', + category: '交易盤口與市場效率', + sourceType: '交易平台參考', + url: 'https://www.pinnacle.com', + integration: 'reference_only', + weight: 0.76, + description: '將鋪助主流盤口效率對照與市場調整節奏理解,保留為高權重參考來源。', + cadence: '接近即時', + }, + { + id: 'oddschecker', + name: 'OddsChecker', + category: '多莊家盤口比對', + sourceType: '資料參考', + url: 'https://www.oddschecker.com', + integration: 'reference_only', + weight: 0.7, + description: '多莊家盤口同步比對參考,提高跨平台價格偏差識別與套利訊號完整性。', + cadence: '每場比賽更新', + }, + { + id: 'sofascore', + name: 'SofaScore', + category: '球隊狀態與即時戰報', + sourceType: 'Web', + url: 'https://www.sofascore.com', + integration: 'planned', + weight: 0.76, + description: '先發、傷停、球員上場情況與賽況變更,作為新聞之外的戰況補充訊號。', + cadence: '每場比賽更新', + }, + { + id: 'flashscore', + name: 'Flashscore', + category: '比分與結果核對', + sourceType: 'Web', + url: 'https://www.flashscore.com', + integration: 'reference_only', + weight: 0.74, + description: '開賽結果、進度與歷史比分核對,補強資料抓取一致性。', + cadence: '每分鐘', + }, + { + id: 'whoscored', + name: 'WhoScored', + category: '球隊與球員統計', + sourceType: 'Web', + url: 'https://www.whoscored.com', + integration: 'planned', + weight: 0.81, + description: '場面指標、射門類型與控球分布,作為進攻效率與風險模型補充。', + cadence: '每場賽後更新', + }, + { + id: 'fbref', + name: 'FBref', + category: '高級進階數據', + sourceType: 'Web', + url: 'https://fbref.com', + integration: 'planned', + weight: 0.78, + description: 'xG、進攻節奏、失球品質等高級指標,用於長尾修正與風險評估。', + cadence: '每輪賽事更新', + }, + { + id: 'understat', + name: 'Understat', + category: 'xG 與機率質量', + sourceType: 'Web', + url: 'https://understat.com', + integration: 'planned', + weight: 0.79, + description: 'xG 曲線與轉換效率,支援比賽節奏與得分區間機率校正。', + cadence: '賽後/每場更新', + }, + { + id: 'worldfootball', + name: 'WorldFootball.net', + category: '歷史賽事與對戰', + sourceType: 'Web', + url: 'https://www.worldfootball.net', + integration: 'reference_only', + weight: 0.64, + description: '歷史交鋒、近期勝率與場次紀錄,用於小樣本對手交叉參考。', + cadence: '每日更新', + }, + { + id: 'soccerway', + name: 'Soccerway', + category: '賽程與歷史', + sourceType: 'Web', + url: 'https://int.soccerway.com', + integration: 'reference_only', + weight: 0.62, + description: '賽事時間線、歷史對戰與場次節奏補充。', + cadence: '賽後更新', + }, + { + id: 'transfermarkt', + name: 'Transfermarkt', + category: '球員輪替與傷停', + sourceType: 'Web', + url: 'https://www.transfermarkt.com', + integration: 'reference_only', + weight: 0.7, + description: '球員市場、輪替與傷停補充,做為新聞訊號外部驗證。', + cadence: '每日更新', + }, + { + id: 'statbunker', + name: 'Statbunker', + category: '球隊效率備援', + sourceType: 'Web', + url: 'https://www.statbunker.com', + integration: 'reference_only', + weight: 0.58, + description: '球隊進攻/防守效率、射門質量的歷史統計作為補強。', + cadence: '賽後更新', + }, + { + id: 'footystats', + name: 'Footystats', + category: '球隊表現補充', + sourceType: 'Web', + url: 'https://footystats.org', + integration: 'reference_only', + weight: 0.6, + description: '場均機率與效率模型的參考輸出,供風險係數調參。', + cadence: '每場後更新', + }, + { + id: 'newsapi', + name: 'NewsAPI', + category: '新聞(英語主流)', + sourceType: 'API', + url: 'https://newsapi.org', + integration: 'conditional', + weight: 0.82, + description: '當提供 NEWS_API_KEY 後,提供英語新聞檢索與情緒指標。', + cadence: '每次抓取', + endpoint: '/v2/everything', + }, + { + id: 'google_news_rss', + name: 'Google News RSS', + category: '新聞(補強)', + sourceType: 'RSS', + url: 'https://news.google.com', + integration: 'active', + weight: 0.7, + description: '無金鑰新聞 fallback,保證新聞通道可持續可用。', + cadence: '每次抓取', + }, + { + id: 'reuters', + name: 'Reuters', + category: '即時新聞校正', + sourceType: '官方媒體', + url: 'https://www.reuters.com/sports', + integration: 'reference_only', + weight: 0.9, + description: '風險訊息(傷停、戰術、突發)核對,作為情緒與新聞風險校正。', + cadence: '隨事件更新', + }, + { + id: 'bbc_sport', + name: 'BBC Sport', + category: '國際新聞', + sourceType: '官方媒體', + url: 'https://www.bbc.com/sport', + integration: 'reference_only', + weight: 0.84, + description: '高可信度新聞參照,與情緒評估的跨來源核對。', + cadence: '隨事件更新', + }, + { + id: 'espn', + name: 'ESPN FC', + category: '國際新聞與賽事整理', + sourceType: '官方媒體', + url: 'https://www.espn.com/soccer/', + integration: 'reference_only', + weight: 0.8, + description: '賽事前瞻與新聞稿可見度高,對球隊輪替/傷停具有參考價值。', + cadence: '每日更新', + }, + { + id: 'goal', + name: 'Goal.com', + category: '亞洲地區足球新聞', + sourceType: '官方媒體', + url: 'https://www.goal.com', + integration: 'reference_only', + weight: 0.74, + description: '中英雙語新聞補充與傷病報導頻度較高。', + cadence: '每小時更新', + }, + { + id: 'skysports', + name: 'Sky Sports', + category: '國際足球觀察', + sourceType: '官方媒體', + url: 'https://www.skysports.com/football', + integration: 'reference_only', + weight: 0.69, + description: '歐洲主體賽事觀察、戰術視角與賽前評論補充。', + cadence: '每小時更新', + }, + { + id: 'guardian_sport', + name: 'The Guardian', + category: '深度新聞與分析', + sourceType: '官方媒體', + url: 'https://www.theguardian.com/football', + integration: 'reference_only', + weight: 0.71, + description: '報導深度高,作為球隊風格與臨場調整訊號的文本補充。', + cadence: '每日更新', + }, + { + id: 'associated_press', + name: 'Associated Press', + category: '突發消息核對', + sourceType: '官方媒體', + url: 'https://apnews.com', + integration: 'reference_only', + weight: 0.77, + description: '高可見度突發新聞源,特別適合對球員變動與賽事中斷事件比對。', + cadence: '隨事件更新', + }, + { + id: 'onefootball', + name: 'OneFootball', + category: '全球足球新聞聚合', + sourceType: '官方媒體', + url: 'https://onefootball.com', + integration: 'reference_only', + weight: 0.66, + description: '多地區新聞聚合,補足多語境中短消息與球隊敘事。', + cadence: '每小時更新', + }, + { + id: 'open_meteo', + name: 'Open-Meteo', + category: '場地天氣', + sourceType: '氣象 API', + url: 'https://open-meteo.com', + integration: 'planned', + weight: 0.68, + description: '場邊天氣(風、濕度、溫度)作為節奏衰減與體能消耗補正參考。', + cadence: '每 3 小時', + }, + { + id: 'weatherapi', + name: 'WeatherAPI', + category: '場地天氣', + sourceType: '氣象 API', + url: 'https://www.weatherapi.com', + integration: 'planned', + weight: 0.64, + description: '天氣與降雨情境來源,作為比賽節奏與盤口波動備援參考。', + cadence: '每 3 小時', + }, + { + id: 'openweathermap', + name: 'OpenWeather', + category: '場地天氣', + sourceType: '氣象 API', + url: 'https://openweathermap.org', + integration: 'planned', + weight: 0.6, + description: '天氣數據交叉比對源,降低單一天氣供應商偏差風險。', + cadence: '每 3 小時', + }, +]; + +const SOURCE_RUNTIME_INITIAL = PROFESSIONAL_DATA_SOURCES.reduce((acc, source) => { + acc[source.id] = { + status: 'pending', + checkedAt: null, + lastError: '', + latencyMs: null, + lastSuccessAt: null, + message: '待啟動', + }; + return acc; +}, {}); + +const WORLD_CUP_VENUE_CONTEXT = { + 'Mexico City': { altitude: 2240 }, + 'Toluca': { altitude: 2660 }, + 'León': { altitude: 1779 }, + 'León (Mexico)': { altitude: 1779 }, + 'Guadalajara': { altitude: 1528 }, + 'Monterrey': { altitude: 540 }, + 'Cancún': { altitude: 10 }, + 'Arlington': { altitude: 140 }, + 'Boston': { altitude: 43 }, + 'Atlanta': { altitude: 320 }, + 'Philadelphia': { altitude: 12 }, + 'Miami': { altitude: 2 }, + 'Denver': { altitude: 1609 }, + 'New Jersey': { altitude: 17 }, + 'New York City': { altitude: 10 }, + 'Toronto': { altitude: 76 }, + 'Vancouver': { altitude: 70 }, +}; + +const NEWS_RSS_REFERENCE_TARGETS = [ + { key: 'google_news_rss', source: 'Google News RSS', site: null, siteLabel: 'Google News', queryLang: 'zh-TW', locale: 'TW' }, + { key: 'reuters', source: 'Reuters', site: 'reuters.com', queryLang: 'en-US', locale: 'US' }, + { key: 'bbc_sport', source: 'BBC Sport', site: 'bbc.com', queryLang: 'en', locale: 'GB' }, + { key: 'espn', source: 'ESPN FC', site: 'espn.com', queryLang: 'en-US', locale: 'US' }, + { key: 'goal', source: 'Goal.com', site: 'goal.com', queryLang: 'en', locale: 'US' }, +]; + +const APP_TIME_ZONE = process.env.TZ || CONFIG.timeZone; +process.env.TZ = APP_TIME_ZONE; + +app.use(cors()); +app.use(express.json()); +app.use((req, res, next) => { + const hostOnly = String(req.headers.host || '').split(':')[0].toLowerCase(); + const isLocalHost = + hostOnly === 'localhost' + || hostOnly === '127.0.0.1' + || hostOnly === '[::1]' + || hostOnly.endsWith('.local') + || hostOnly.endsWith('.localhost'); + + if (canonicalHostEnable && hostOnly && !isLocalHost && hostOnly !== CANONICAL_HOST.toLowerCase()) { + const scheme = (req.headers['x-forwarded-proto'] || (req.secure ? 'https' : 'http')).split(',')[0]; + const protocol = scheme || 'https'; + return res.redirect(301, `${protocol}://${CANONICAL_HOST}${req.originalUrl}`); + } + + return next(); +}); +app.use(express.static(path.join(__dirname, '../public'))); + +const state = { + matches: [], + lastUpdated: null, + lastUpdatedTaipei: null, + status: 'booting', + errors: [], + source: 'oddsapi', + sourceRuntime: SOURCE_RUNTIME_INITIAL, + analysis: null, + scheduleComparison: null, + todayInsights: null, + oddsHistory: [], + oddsHistoryByMatch: {}, + liveCenterCache: {}, + portfolio: [], + sourceProbeAt: null, +}; + +function safeLoadPortfolio() { + try { + const raw = fs.readFileSync(CONFIG.portfolioFile, 'utf-8'); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed; + } + } catch (e) { + // ignore and fallback to empty + } + return []; +} + +function safeSavePortfolio(portfolio = []) { + try { + fs.writeFileSync(CONFIG.portfolioFile, JSON.stringify(portfolio, null, 2), 'utf-8'); + } catch (e) { + state.errors.push({ at: new Date().toISOString(), message: `portfolio 持久化失敗:${e.message}` }); + } +} + +state.portfolio = safeLoadPortfolio(); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const TZ = APP_TIME_ZONE; +const enCaDate = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' }); +const twDateTime = new Intl.DateTimeFormat('zh-Hant-TW', { + timeZone: TZ, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +async function withRetry(action, options = {}) { + const retries = Number.isFinite(options.retries) ? Math.max(0, Math.floor(options.retries)) : CONFIG.requestRetryTimes; + const baseDelayMs = Number.isFinite(options.baseDelayMs) + ? Math.max(40, Math.floor(options.baseDelayMs)) + : CONFIG.requestRetryBaseDelayMs; + const actionName = options.name || 'request'; + + let attempt = 0; + let lastError; + while (attempt <= retries) { + try { + return await action(); + } catch (e) { + lastError = e; + if (attempt >= retries) { + throw lastError; + } + const delay = baseDelayMs * Math.pow(2, attempt); + state.errors.push({ + at: new Date().toISOString(), + message: `${actionName} 失敗(第 ${attempt + 1} 次嘗試):${e.message || String(e)}`, + }); + await sleep(delay); + attempt += 1; + } + } + throw lastError; +} + +function safeSourceRuntimeLabel(sourceId) { + return state.sourceRuntime[sourceId] || { + status: 'pending', + checkedAt: null, + lastError: '', + latencyMs: null, + lastSuccessAt: null, + message: '待啟動', + }; +} + +function toTaipeiDate(raw) { + const d = new Date(raw); + if (!Number.isFinite(d.getTime())) return ''; + return enCaDate.format(d); +} + +function toTaipeiDateTime(raw) { + const d = new Date(raw); + if (!Number.isFinite(d.getTime())) return ''; + return `${twDateTime.format(d)} (${TZ})`; +} + +function clamp(n, min = 0, max = 1) { + if (!Number.isFinite(n)) return min; + return Math.max(min, Math.min(max, n)); +} + +function round(n, digits = 4) { + if (!Number.isFinite(n)) return 0; + return Number(n.toFixed(digits)); +} + +function pct(n) { + return `${round(n * 100, 2)}%`; +} + +function expectedValue(prob, odds) { + if (!Number.isFinite(prob) || !Number.isFinite(odds) || odds <= 1) return null; + return round(prob * (odds - 1) - (1 - prob), 4); +} + +function kellyFraction(prob, odds) { + if (!Number.isFinite(prob) || !Number.isFinite(odds) || odds <= 1) return 0; + const b = odds - 1; + const raw = (b * prob - (1 - prob)) / b; + return round(clamp(raw, 0, 1) * clamp(CONFIG.kellyScale, 0, 1), 4); +} + +function parseFloatSafely(v) { + const num = Number(v); + return Number.isFinite(num) ? num : null; +} + +function setSourceRuntime(id, patch = {}) { + if (!state.sourceRuntime[id]) { + state.sourceRuntime[id] = { + status: 'unknown', + checkedAt: null, + lastError: '', + latencyMs: null, + lastSuccessAt: null, + message: '', + }; + } + state.sourceRuntime[id] = { + ...state.sourceRuntime[id], + ...patch, + }; +} + +function sourceStatusView() { + return PROFESSIONAL_DATA_SOURCES.map((src) => ({ + ...src, + runtime: state.sourceRuntime[src.id] || { + status: 'pending', + checkedAt: null, + lastError: '', + latencyMs: null, + lastSuccessAt: null, + message: '待啟動', + }, + })); +} + +async function probeReferenceSources(force = false) { + const now = Date.now(); + if (!force) { + const last = state.sourceProbeAt ? new Date(state.sourceProbeAt).getTime() : 0; + if (Number.isFinite(last) && now - last < CONFIG.sourceProbeIntervalMs) { + return; + } + } + + const probeTargets = PROFESSIONAL_DATA_SOURCES.filter((s) => + ['action_network', 'pinnacle_insights', 'oddschecker', 'fifa_official', 'fifa_world_cup_fixtures'].includes(s.id), + ); + const startedAt = Date.now(); + const tasks = probeTargets.map(async (source) => { + const start = Date.now(); + try { + await withRetry( + async () => { + await axios.get(source.url, { + timeout: CONFIG.sourceProbeTimeoutMs, + responseType: 'text', + maxRedirects: 3, + headers: { 'User-Agent': '2026fifa-bot/1.0' }, + }); + }, + { + retries: 1, + name: `source-probe:${source.id}`, + baseDelayMs: 250, + }, + ); + + setSourceRuntime(source.id, { + status: 'ok', + checkedAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + latencyMs: Date.now() - start, + lastError: '', + message: '連線成功', + }); + } catch (e) { + const msg = e?.response?.status + ? `HTTP ${e.response.status}` + : e.message || 'reference source probe failed'; + setSourceRuntime(source.id, { + status: 'error', + checkedAt: new Date().toISOString(), + latencyMs: Date.now() - start, + lastError: String(msg), + message: '參考源暫不可用', + }); + } + }); + + await Promise.allSettled(tasks); + state.sourceProbeAt = new Date().toISOString(); + state.errors.push({ + at: new Date().toISOString(), + message: `參考來源檢查完成,耗時 ${(Date.now() - startedAt)}ms`, + }); +} + +function normalizeOddsEvent(event) { + const start = new Date(event.commence_time || event.commenceTime); + const commenceTime = Number.isNaN(start.getTime()) ? null : start.toISOString(); + const rawVenue = event.venue || event.venue_name || event.stadium || event.location || null; + const venue = typeof rawVenue === 'string' + ? rawVenue + : rawVenue && typeof rawVenue === 'object' + ? String(rawVenue.name || rawVenue.stadium || '').trim() + : ''; + return { + id: String(event.id), + sportKey: event.sport_key || CONFIG.sportKey, + sportTitle: event.sport_title || 'FIFA World Cup', + commenceTime, + commenceTimeTaipei: toTaipeiDateTime(start), + venue, + city: event.city || event.country || null, + sportRound: event.sport_round || event.round || null, + homeTeam: String(event.home_team || event.home || ''), + awayTeam: String(event.away_team || event.away || ''), + bookmakers: Array.isArray(event.bookmakers) + ? event.bookmakers.map((book) => ({ + key: String(book.key || book.name || ''), + title: String(book.title || book.name || book.key || ''), + lastUpdate: book.last_update || null, + markets: Array.isArray(book.markets) + ? book.markets + .filter((m) => m && m.key && Array.isArray(m.outcomes)) + .map((m) => ({ + key: m.key, + lastUpdate: m.last_update || null, + outcomes: m.outcomes + .map((o) => ({ + name: String(o.name || ''), + price: parseFloatSafely(o.price), + point: parseFloatSafely(o.point), + description: o.description ? String(o.description) : null, + })) + .filter((o) => Number.isFinite(o.price) && o.price > 1), + })) + .filter((m) => m.outcomes.length > 0) + : [], + })) + : [], + raw: event, + }; +} + +function resolveVenueContext(match = {}) { + const venueRaw = [ + match.venue, + match.city, + match.raw && match.raw.venue, + match.raw && match.raw.stadium, + match.raw && match.raw.location, + ] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + const tokens = venueRaw + .flatMap((v) => v.split(/[,-]/).map((s) => s.trim()).filter(Boolean)) + .map((v) => v.toLowerCase()); + + for (const [key, value] of Object.entries(WORLD_CUP_VENUE_CONTEXT)) { + const needle = String(key || '').toLowerCase(); + if (tokens.includes(needle) || needle.includes(tokens[0] || '') || tokens[0]?.includes(needle)) { + return { venue: key, altitude: Number(value.altitude) || 0, key }; + } + } + + return { venue: venueRaw[0] || '未知球場', altitude: 0, key: null }; +} + +function evaluateRestRisk(daysRest) { + if (!Number.isFinite(daysRest)) return 0.04; + if (daysRest >= 6) return 0.0; + if (daysRest >= 4.5) return 0.02; + if (daysRest >= 3) return 0.08; + if (daysRest >= 2.5) return 0.12; + return 0.2; +} + +function classifyVenueRisk(altitude) { + if (!Number.isFinite(altitude) || altitude <= 0) return { level: 'unknown', label: '海拔資料未確定', risk: 0.04, score: 0.04 }; + if (altitude >= 1800) return { level: 'high', label: '高海拔賽場', risk: 0.18, score: 0.18 }; + if (altitude >= 1000) return { level: 'medium', label: '中高海拔賽場', risk: 0.08, score: 0.08 }; + if (altitude >= 500) return { level: 'low', label: '輕微海拔差異', risk: 0.03, score: 0.03 }; + return { level: 'normal', label: '海拔條件平穩', risk: 0, score: 0 }; +} + +function buildMatchContextProfiles(matches = []) { + const sorted = [...matches].sort((a, b) => { + const atA = new Date(a.commenceTime).getTime(); + const atB = new Date(b.commenceTime).getTime(); + return atA - atB; + }); + const teamChrono = {}; + const contextMap = {}; + + for (const match of sorted) { + const id = String(match.id); + contextMap[id] = { + matchId: id, + home: { restDays: null, restRisk: 0, fatigueLabel: '資料不足' }, + away: { restDays: null, restRisk: 0, fatigueLabel: '資料不足' }, + venue: resolveVenueContext(match), + venueRisk: classifyVenueRisk(resolveVenueContext(match).altitude), + recommendationBiasNote: [], + }; + } + + for (const match of sorted) { + const at = new Date(match.commenceTime).getTime(); + [ + { team: match.homeTeam, side: 'home' }, + { team: match.awayTeam, side: 'away' }, + ].forEach((entry) => { + if (!entry.team) return; + if (!teamChrono[entry.team]) teamChrono[entry.team] = []; + teamChrono[entry.team].push({ + matchId: String(match.id), + at, + side: entry.side, + }); + }); + } + + for (const records of Object.values(teamChrono)) { + const arr = records.sort((a, b) => a.at - b.at); + for (let i = 1; i < arr.length; i += 1) { + const prev = arr[i - 1]; + const cur = arr[i]; + const daysRest = Number(((cur.at - prev.at) / (24 * 3600 * 1000)).toFixed(2)); + const context = contextMap[cur.matchId]; + if (!context) continue; + const risk = evaluateRestRisk(daysRest); + const target = cur.side; + context[target] = { + restDays: round(daysRest, 2), + restRisk: risk, + fatigueLabel: daysRest < 2.5 ? '高' : daysRest < 3.5 ? '中' : daysRest < 5 ? '低' : '正常', + opponentRestDays: Number.isFinite(daysRest) ? daysRest : null, + previousMatchId: String(prev.matchId || ''), + }; + } + } + + return contextMap; +} + +function getSelectionSide(match, selection) { + if (!selection) return null; + const safeSelection = String(selection); + const homeTeam = String(match.homeTeam || ''); + const awayTeam = String(match.awayTeam || ''); + if (safeSelection.includes(homeTeam)) return 'home'; + if (safeSelection.includes(awayTeam)) return 'away'; + return null; +} + +function applyContextToSignal(match, row, context = {}, opts = {}) { + const side = getSelectionSide(match, row.selection); + const sideRisk = side ? Number(context[side]?.restRisk || 0) : (Number(context.overallFatigueRisk) || 0); + const venueRisk = Number(context.venueRisk?.score || 0); + const volatility = clamp(sideRisk * 0.6 + venueRisk + (opts.newsPenalty || 0), 0, 0.35); + const penalty = 1 - clamp(volatility, 0, 0.35); + const baseProb = Number(row.probability); + const baseConf = Number(row.confidence); + const outcome = { + ...row, + probability: Number.isFinite(baseProb) ? clamp(baseProb * penalty, 0.01, 0.99) : baseProb, + confidence: Number.isFinite(baseConf) ? clamp(baseConf * (0.9 + (1 - penalty) * -0.15), 0.02, 0.99) : baseConf, + contextRisk: clamp(volatility, 0, 0.35), + adjustmentReason: [ + sideRisk ? `${side === 'home' ? '主隊' : side === 'away' ? '客隊' : '雙方'}疲勞風險 ${round(sideRisk * 100, 1)}%` : '', + venueRisk ? `${context.venueRisk?.label || '賽場環境'} ${round(venueRisk * 100, 1)}%` : '', + opts.newsBias ? `新聞風險 ${round(opts.newsBias * 100, 1)}%` : '', + ].filter(Boolean).join(';'), + }; + return outcome; +} + +function buildMatchContextNote(context = {}, nowTs = Date.now()) { + const venueRisk = context.venueRisk || {}; + const home = context.home || {}; + const away = context.away || {}; + const notes = []; + if (context.venue?.venue) { + notes.push(`${context.venue.venue} / ${Number.isFinite(context.venue.altitude) ? `${context.venue.altitude}m` : '海拔待補'}`); + } + if (home.restDays !== null || away.restDays !== null) { + const items = []; + if (home.restDays !== null) { + items.push(`主隊休整 ${home.restDays} 天`); + } + if (away.restDays !== null) { + items.push(`客隊休整 ${away.restDays} 天`); + } + notes.push(items.join('、')); + } + if (venueRisk.label) notes.push(venueRisk.label); + return { + generatedAt: nowTs, + labels: { + venue: venueRisk.label || '賽場風險待補', + home: home.fatigueLabel || '資料不足', + away: away.fatigueLabel || '資料不足', + }, + summary: notes.join('|') || '資料待補', + overallFatigueRisk: round( + Math.max( + Number(home.restRisk || 0), + Number(away.restRisk || 0), + Number(venueRisk.score || 0), + ), + 4, + ), + home, + away, + venueRisk, + }; +} + +function safeNum(v, fallback = 0) { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +function normalizeMatchId(id) { + return String(id || '').trim(); +} + +function appendOddsHistorySnapshot(match, source = 'the_odds_api') { + if (!match || !match.id) return; + const matchId = normalizeMatchId(match.id); + const records = []; + for (const bookmaker of match.bookmakers || []) { + const bookmakerName = bookmaker.title || bookmaker.key || source; + for (const market of bookmaker.markets || []) { + const marketKey = String(market.key || '').toLowerCase(); + for (const outcome of market.outcomes || []) { + const price = Number(outcome.price); + if (!Number.isFinite(price) || price <= 1) continue; + records.push({ + matchId, + ts: new Date().toISOString(), + at: new Date().toLocaleTimeString('en-CA', { timeZone: CONFIG.timeZone }), + source, + bookmaker: bookmakerName, + market: market.key, + outcome: outcome.name, + point: Number.isFinite(outcome.point) ? outcome.point : null, + price, + }); + } + } + } + + if (!records.length) return; + state.oddsHistory = [...state.oddsHistory, ...records].slice(-25000); + const perMatch = state.oddsHistoryByMatch[matchId] || []; + state.oddsHistoryByMatch[matchId] = [...perMatch, ...records].slice(-5000); +} + +function getMatchById(matchId) { + return (state.matches || []).find((m) => String(m.id) === String(matchId)) || null; +} + +function getLatestOddsSelection(matchId, market, option, point) { + const records = (state.oddsHistoryByMatch[normalizeMatchId(matchId)] || []).filter( + (r) => String(r.market).toLowerCase() === String(market).toLowerCase(), + ); + if (!records.length) return null; + const normOption = String(option || '').toLowerCase(); + const normPoint = point === null || point === undefined ? null : round(Number(point), 2); + for (let i = records.length - 1; i >= 0; i -= 1) { + const row = records[i]; + if (!row || !row.price || row.price <= 1) continue; + if (String(row.outcome || '').toLowerCase() === normOption) { + if (normPoint === null || Number.isNaN(normPoint) || round(Number(row.point), 2) === normPoint) { + return { + source: row.source, + bookmaker: row.bookmaker, + odds: row.price, + ts: row.ts, + }; + } + } + } + return null; +} + +function buildPortfolioClv(records) { + const enriched = (records || []).map((bet) => { + const close = getLatestOddsSelection(bet.matchId, bet.market, bet.selection, bet.point); + const closeOdds = close && Number(close.odds); + const placed = Number(bet.odds); + const clv = Number.isFinite(closeOdds) && Number.isFinite(placed) && placed > 1 + ? round(((closeOdds - placed) / placed) * 100, 3) + : null; + const settled = (bet.result || '').toLowerCase() === 'win' ? 'win' + : (bet.result || '').toLowerCase() === 'loss' ? 'loss' + : (bet.result || '').toLowerCase() === 'push' ? 'push' : 'open'; + const pnl = settled === 'win' + ? round((placed * (Math.max(1, bet.odds || 1) - 1)), 4) + : settled === 'loss' + ? round(-1 * Number(bet.stake || 0), 4) + : 0; + return { ...bet, clv, closeOdds, settled, pnl }; + }); + + const settled = enriched.filter((b) => b.settled !== 'open'); + const stake = enriched.reduce((acc, b) => acc + (Number.isFinite(Number(b.stake)) ? Number(b.stake) : 0), 0); + const pnl = enriched.reduce((acc, b) => acc + (Number.isFinite(Number(b.pnl)) ? b.pnl : 0), 0); + const win = settled.filter((b) => b.settled === 'win').length; + const loss = settled.filter((b) => b.settled === 'loss').length; + const push = settled.filter((b) => b.settled === 'push').length; + const avgClv = settled.length + ? round( + settled.reduce((acc, b) => acc + (Number.isFinite(Number(b.clv)) ? Number(b.clv) : 0), 0) / settled.length, + 3, + ) + : 0; + + return { + bets: enriched, + totalStake: round(stake, 3), + totalPnl: round(pnl, 3), + roiPercent: stake > 0 ? round((pnl / stake) * 100, 3) : 0, + winRate: settled.length ? round((win / settled.length) * 100, 3) : 0, + settledCount: settled.length, + openCount: enriched.length - settled.length, + byResult: { win, loss, push }, + avgClvPercent: avgClv, + }; +} + +function buildPortfolioSummaryView() { + return buildPortfolioClv(state.portfolio || []); +} + +function normalizePortfolioPayload(payload = {}) { + if (!payload || typeof payload !== 'object') { + return { ok: false, reason: 'payload invalid' }; + } + + const matchId = String(payload.matchId || '').trim(); + if (!matchId) { + return { ok: false, reason: 'missing matchId' }; + } + + const match = getMatchById(matchId); + if (!match) { + return { ok: false, reason: `matchId not found: ${matchId}` }; + } + + const stake = Number(payload.stake); + const odds = Number(payload.odds); + if (!Number.isFinite(stake) || stake < 0) { + return { ok: false, reason: 'stake must be a non-negative number' }; + } + if (!Number.isFinite(odds) || odds <= 1) { + return { ok: false, reason: 'odds must be greater than 1' }; + } + + const rawPoint = payload.point; + const normalizedPoint = Number.isFinite(Number(rawPoint)) ? round(Number(rawPoint), 2) : null; + + const result = String(payload.result || '').toLowerCase(); + const normalized = { + id: String(payload.id || `${matchId}-${Date.now()}`), + matchId, + market: String(payload.market || '').trim(), + selection: String(payload.selection || '').trim(), + point: normalizedPoint, + odds: round(odds, 3), + stake: round(stake, 3), + result: ['win', 'loss', 'push', 'open'].includes(result) ? result : 'open', + placedAt: payload.placedAt || new Date().toISOString(), + meta: { + teams: `${match.homeTeam} vs ${match.awayTeam}`, + sourceMatch: match.sportTitle || 'FIFA World Cup', + }, + }; + + if (!normalized.market || !normalized.selection) { + return { ok: false, reason: 'market and selection are required' }; + } + + return { ok: true, payload: normalized }; +} + +function analyzeMatchForQuant(match) { + const recommendations = []; + const profile = collectMarketProfiles(match); + for (const rec of Object.values(profile.h2h || {})) { + const first = rec.sources && rec.sources[0]; + if (!first || !Number.isFinite(first.price)) continue; + recommendations.push({ + matchId: match.id, + market: '1X2', + selection: rec.name, + odds: safeNum(first.price, 0), + probability: clamp(rec.avgImplied, 0.02, 0.98), + fairProbability: clamp(rec.avgImplied, 0.02, 0.98), + }); + } + + const ev = buildEVSignals(recommendations); + const poisson = buildPoissonAnalysis(match); + const monteCarlo = buildMonteCarloAnalysis(match, { samples: CONFIG.monteCarloSamples }); + + return { + matchId: match.id, + teams: `${match.homeTeam} vs ${match.awayTeam}`, + generatedAt: new Date().toISOString(), + evSignals: ev, + poisson, + monteCarlo, + }; +} + +function emptyRecord() { + return { + counts: {}, + outcomes: {}, + lineGroups: {}, + }; +} + +function collectMarketProfiles(match) { + const profiles = {}; + for (const bookmaker of match.bookmakers) { + for (const market of bookmaker.markets) { + const mKey = market.key; + const bucket = profiles[mKey] || (profiles[mKey] = emptyRecord()); + const records = market.outcomes || []; + for (const outcome of records) { + bucket.counts[mKey] = (bucket.counts[mKey] || 0) + 1; + const pointKey = + typeof outcome.point === 'number' && !Number.isNaN(outcome.point) + ? `${outcome.point.toFixed(2)}` + : 'nopoint'; + const outcomeKey = + pointKey === 'nopoint' + ? outcome.name + : `${outcome.name}|${pointKey}`; + if (!bucket.outcomes[outcomeKey]) { + bucket.outcomes[outcomeKey] = { + name: outcome.name, + point: outcome.point, + prices: [], + implied: [], + sources: [], + }; + } + bucket.outcomes[outcomeKey].prices.push(outcome.price); + bucket.outcomes[outcomeKey].implied.push(1 / outcome.price); + bucket.outcomes[outcomeKey].sources.push({ + bookmaker: bookmaker.title || bookmaker.key, + price: outcome.price, + point: outcome.point, + }); + } + } + } + + Object.keys(profiles).forEach((marketKey) => { + const prof = profiles[marketKey]; + prof.marketKey = marketKey; + prof.counts = Object.keys(prof.outcomes).reduce((acc, o) => { + const r = prof.outcomes[o]; + const raw = r.implied.reduce((s, p) => s + p, 0); + const avgInv = raw / (r.implied.length || 1); + const best = r.prices.length ? Math.max(...r.prices) : null; + acc[o] = { + name: r.name, + point: r.point, + sample: r.prices.length, + avgPrice: round(r.prices.reduce((s, p) => s + p, 0) / (r.prices.length || 1), 3), + bestPrice: best, + avgImplied: avgInv, + sources: r.sources, + }; + return acc; + }, {}); + }); + + return profiles; +} + +function normalizeProbs(rows) { + const sum = rows.reduce((s, x) => s + (x.prob || 0), 0) || 1; + return rows.map((x) => ({ ...x, fairProb: clamp((x.prob || 0) / sum, 0, 1) })); +} + +function getRecommendationTier(probability, confidence) { + const p = clamp(Number(probability), 0, 1); + const c = clamp(Number(confidence), 0, 1); + const score = round(0.68 * p + 0.32 * c, 4); + if (score >= 0.72) return 'high'; + if (score >= 0.54) return 'medium'; + return 'low'; +} + +function normalizePlaybookMarket(market = '') { + const key = String(market).toLowerCase(); + if (key.includes('1x2')) return 'oneX2'; + if (key.includes('double chance')) return 'doubleChance'; + if (key.includes('handicap')) return 'handicap'; + if (key.includes('totals')) return 'totals'; + if (key.includes('both teams') || key.includes('btts')) return 'btts'; + return 'other'; +} + +function buildSinglePlaybook(matchAnalysis) { + const buckets = { + all: [], + oneX2: [], + handicap: [], + totals: [], + btts: [], + doubleChance: [], + }; + + for (const match of matchAnalysis) { + const source = [ + match.topRecommendation ? { ...match.topRecommendation } : null, + ...(match.recommendationBuckets?.high || []), + ...(match.recommendationBuckets?.medium || []), + ...(match.recommendationBuckets?.low || []), + ].filter(Boolean); + + const seen = new Set(); + for (const rec of source) { + const market = rec.market || ''; + const key = `${match.matchId}-${market}-${rec.selection}`; + if (seen.has(key)) continue; + seen.add(key); + const recommendationTier = rec.recommendationTier || getRecommendationTier(rec.probability, rec.confidence); + const payload = { + ...rec, + matchId: match.matchId, + teams: match.teams, + kickoffAt: match.kickoffAt, + kickoffAtTaipei: match.kickoffAtTaipei, + recommendationTier, + }; + buckets.all.push(payload); + + const g = normalizePlaybookMarket(market); + if (buckets[g]) buckets[g].push(payload); + } + } + + const sortByValue = (a, b) => { + const av = Number.isFinite(a.expectedRoiPercent) ? a.expectedRoiPercent : (Number.isFinite(a.expectedValue) ? a.expectedValue * 100 : -999); + const bv = Number.isFinite(b.expectedRoiPercent) ? b.expectedRoiPercent : (Number.isFinite(b.expectedValue) ? b.expectedValue * 100 : -999); + if (bv !== av) return bv - av; + return (b.confidence || 0) - (a.confidence || 0); + }; + + for (const k of Object.keys(buckets)) { + buckets[k].sort(sortByValue); + } + return { + all: buckets.all.slice(0, 120), + oneX2: buckets.oneX2.slice(0, 120), + handicap: buckets.handicap.slice(0, 120), + totals: buckets.totals.slice(0, 120), + btts: buckets.btts.slice(0, 120), + doubleChance: buckets.doubleChance.slice(0, 120), + }; +} + +function buildCrossMarketPairs(matchAnalysis) { + const out = []; + for (const match of matchAnalysis) { + const marketCandidates = []; + for (const pool of [match.recommendationBuckets?.high || [], match.recommendationBuckets?.medium || []]) { + for (const rec of pool) { + const group = normalizePlaybookMarket(rec.market); + if (group === 'other') continue; + const marker = `${group}::${rec.selection}`; + if (marketCandidates.find((x) => x.key === marker)) continue; + marketCandidates.push({ + key: marker, + market: rec.market, + selection: rec.selection, + odds: rec.odds, + probability: rec.probability, + rationale: rec.rationale || '', + }); + } + } + const uniqGroups = new Set(marketCandidates.map((c) => c.market)); + if (uniqGroups.size >= 2) { + out.push({ + matchId: match.matchId, + teams: match.teams, + kickoffAt: match.kickoffAt, + kickoffAtTaipei: match.kickoffAtTaipei, + candidates: marketCandidates.slice(0, 4), + }); + } + } + return out.slice(0, 24); +} + +function buildCombosByStyle(rows = [], size = 2, style = 'balanced') { + const safeRows = Array.isArray(rows) ? rows : []; + const base = safeRows.filter((r) => Number.isFinite(r.probability) && Number.isFinite(r.odds) && r.odds > 1); + + const candidates = buildParlayCandidates(base, size); + if (style === 'conservative') { + return candidates + .filter((row) => { + const legs = Array.isArray(row.legs) ? row.legs : []; + if (!legs.length) return false; + const minConf = Math.min(...legs.map((leg) => (Number.isFinite(leg.confidence) ? leg.confidence : 0))); + return Number.isFinite(row.hitProbability) && row.hitProbability >= 0.04 && minConf >= 0.72 && row.expectedRoi > 0; + }) + .sort((a, b) => b.hitProbability - a.hitProbability) + .slice(0, 16); + } + if (style === 'value') { + return candidates + .filter((row) => Number.isFinite(row.expectedRoi) && row.expectedRoi >= 0.02 && Number.isFinite(row.hitProbability)) + .sort((a, b) => (b.expectedRoi || 0) - (a.expectedRoi || 0)) + .slice(0, 16); + } + return candidates + .filter((row) => Number.isFinite(row.hitProbability)) + .sort((a, b) => (b.expectedRoi || 0) - (a.expectedRoi || 0)) + .slice(0, 16); +} + +function buildSystemCombinations(rows = [], totalLegs = 4, includedLegs = 2, limit = 140) { + const selected = rows.slice(0, totalLegs); + if (selected.length < totalLegs || includedLegs <= 0 || includedLegs > totalLegs) return null; + + const combos = []; + const dfs = (start, depth, current) => { + if (combos.length >= limit) return; + if (depth === 0) { + const odds = current.reduce((acc, r) => acc * (Number(r.odds) || 1), 1); + const hit = current.reduce((acc, r) => acc * (Number(r.probability) || 0), 1); + const roi = odds * hit - 1; + combos.push({ + legs: [...current], + odds: round(odds, 2), + hitProbability: round(hit, 4), + expectedRoi: round(roi, 4), + }); + return; + } + for (let i = start; i < selected.length; i += 1) { + if (combos.length >= limit) return; + current.push(selected[i]); + dfs(i + 1, depth - 1, current); + current.pop(); + } + }; + dfs(0, includedLegs, []); + if (!combos.length) return null; + + const total = combos.length; + const totalHit = combos.reduce((acc, c) => acc + (c.hitProbability || 0), 0); + const totalRoi = combos.reduce((acc, c) => acc + (c.expectedRoi || 0), 0); + const sample = combos.slice(0, 3).map((item) => `命中 ${(item.hitProbability * 100).toFixed(1)}% / 賠率 ${item.odds} / ROI ${(item.expectedRoi * 100).toFixed(1)}%`); + return { + pattern: `${totalLegs}串${includedLegs}`, + slips: total, + avgHitProbability: round(totalHit / total, 4), + expectedRoi: round(totalRoi / total, 4), + sample, + legs: selected.map((r) => ({ + market: r.market, + selection: r.selection, + matchId: r.matchId, + teams: r.teams, + })), + }; +} + +function buildSystemPlaybook(rows = []) { + const pickRows = rows + .filter((row) => Number.isFinite(row.confidence) ? row.confidence >= 0.55 : true) + .filter((row) => Number.isFinite(row.probability) && Number.isFinite(row.odds) && row.odds > 1) + .filter((row, idx, arr) => idx === arr.findIndex((x) => x.matchId === row.matchId)) + .slice(0, 12); + + if (pickRows.length < 4) return []; + + const candidates = []; + [[4, 2], [4, 3], [5, 2], [5, 3]].forEach(([n, k]) => { + const template = buildSystemCombinations(pickRows, n, k); + if (template) candidates.push(template); + }); + return candidates + .sort((a, b) => b.expectedRoi - a.expectedRoi) + .slice(0, 8) + .map((c) => ({ ...c, coreSamples: c.legs.map((r) => `${r.market} ${r.selection}`).join(';') })); +} + +function buildPortfolioProfile(matchAnalysis = [], allSingles = []) { + const totalMatches = Array.isArray(matchAnalysis) ? matchAnalysis.length : 0; + const totalSingles = Array.isArray(allSingles) ? allSingles.length : 0; + const marketCount = {}; + let totalNewsSignals = 0; + let newsFreshSignals = 0; + let totalTierHigh = 0; + let totalTierMedium = 0; + let totalTierLow = 0; + let confidenceSum = 0; + let confidenceCount = 0; + + for (const item of matchAnalysis) { + const upsets = Array.isArray(item.upsetSignals) ? item.upsetSignals : []; + const rows = Array.isArray(upsets) ? upsets : []; + totalNewsSignals += Array.isArray(item.news) ? item.news.length : 0; + for (const u of rows) { + const riskLabel = String(u?.riskLabel || 'low').toLowerCase(); + if (riskLabel === 'high') { + newsFreshSignals += 1; + } + } + for (const rec of item.recommendations || []) { + const tier = rec.recommendationTier || getRecommendationTier(rec.probability, rec.confidence); + if (tier === 'high') totalTierHigh += 1; + else if (tier === 'medium') totalTierMedium += 1; + else totalTierLow += 1; + marketCount[rec.market] = (marketCount[rec.market] || 0) + 1; + confidenceSum += Number.isFinite(rec.confidence) ? rec.confidence : 0; + confidenceCount += 1; + } + } + + const marketCoverageList = Object.entries(marketCount).map(([market, count]) => ({ market, count })).sort((a, b) => b.count - a.count); + const highSingles = Array.isArray(allSingles) ? allSingles.filter((x) => (x.recommendationTier || getRecommendationTier(x.probability, x.confidence)) === 'high').length : 0; + const avgConfidence = confidenceCount > 0 ? round(confidenceSum / confidenceCount, 4) : 0; + const riskProfile = totalSingles > 0 ? { + high: round(totalTierHigh / totalSingles, 4), + medium: round(totalTierMedium / totalSingles, 4), + low: round(totalTierLow / totalSingles, 4), + } : { high: 0, medium: 0, low: 0 }; + + return { + totalMatches, + totalSingles, + totalRecommendations: totalSingles, + totalUpsetSignals: matchAnalysis.reduce((acc, item) => acc + (Array.isArray(item?.upsetSignals) ? item.upsetSignals.length : 0), 0), + totalNewsSignals, + mediaFreshCount: newsFreshSignals, + riskProfile, + marketCoverage: marketCoverageList, + avgConfidence, + topHighProbabilities: highSingles, + summaryLine: `共 ${totalMatches} 場 · ${totalSingles} 個有效投注建議 · 高中低比: ${totalTierHigh}:${totalTierMedium}:${totalTierLow}`, + }; +} + +function buildMultiLegPlaybook(rows = [], legs = 2, style = 'balanced', limit = 10) { + const safeRows = Array.isArray(rows) ? rows : []; + return buildCombosByStyle(safeRows, Number(legs) || 2, style).slice(0, limit); +} + +function normalizeMarketOutcomeName(match, name) { + if (name === match.homeTeam) return `${match.homeTeam}勝`; + if (name === match.awayTeam) return `${match.awayTeam}勝`; + if (name === 'Draw' || name === '平局') return '和局'; + return name || '未知盤路'; +} + +function buildH2hUpsetSignals(match, h2hNormalized, nowTs, context = {}, newsBias = 0) { + if (!Array.isArray(h2hNormalized) || h2hNormalized.length < 2) return []; + const sorted = [...h2hNormalized].sort((a, b) => b.fairProb - a.fairProb); + const favorite = sorted[0]; + if (!favorite) return []; + const upsetCandidates = sorted.slice(1).filter((item) => Number.isFinite(item.fairProb) && item.fairProb >= 0.06); + if (!upsetCandidates.length) return []; + const best = upsetCandidates[0]; + const favoriteProb = round(favorite.fairProb, 4); + const contextRisk = Number(context.overallFatigueRisk || context.venueRisk?.score || 0); + const upsetProbBase = Number(best.fairProb); + const contextBoost = clamp(1 + contextRisk * 0.45 + clamp(-newsBias || 0, -0.05, 0.05), 1, 1.45); + const upsetProb = round(clamp(upsetProbBase * contextBoost, 0.01, 0.99), 4); + const bestPrice = Number(best.bestPrice); + const implied = Number(best.avgImplied); + const ev = expectedValue(upsetProb, bestPrice); + const valueEdge = Number.isFinite(implied) ? round(upsetProb - implied, 4) : null; + const confidence = round( + clamp(0.32 + upsetProb * 0.66 + (best.sample || 0) * 0.02 - contextRisk, 0.1, 0.95), + 2, + ); + const selectionSide = getSelectionSide(match, best.name); + const sideFatigueRisk = selectionSide ? Number(context[selectionSide]?.restRisk || 0) : Number(context.overallFatigueRisk || 0); + const riskBias = clamp(1 - sideFatigueRisk, 0.35, 1.25); + const adjustedConfidence = clamp(confidence * (0.85 + riskBias * 0.15), 0.1, 0.95); + return [ + { + market: '1X2爆冷', + selection: normalizeMarketOutcomeName(match, best.name), + probability: upsetProb, + confidence: adjustedConfidence, + fairProbability: upsetProb, + impliedProbability: implied, + odds: Number.isFinite(bestPrice) ? round(bestPrice, 2) : null, + expectedValue: ev, + expectedRoiPercent: ev !== null ? round(ev * 100, 2) : null, + kellyFraction: Number.isFinite(bestPrice) ? kellyFraction(upsetProb * riskBias, bestPrice) : 0, + valueEdge, + modelGrade: adjustedConfidence >= 0.8 ? 'A' : adjustedConfidence >= 0.7 ? 'B' : adjustedConfidence >= 0.6 ? 'C' : 'D', + riskLabel: upsetProb >= 0.28 ? 'high' : upsetProb >= 0.18 ? 'medium' : 'low', + isUpset: true, + rationale: `主流仍看${normalizeMarketOutcomeName(match, favorite.name)}(${(favorite.fairProb * 100).toFixed(1)}%),但此結果受賽程/環境風險放大後呈現更高爆冷空間`, + generatedAt: nowTs, + competitorOutcome: normalizeMarketOutcomeName(match, favorite.name), + favoriteProbability: favoriteProb, + contextRisk: round(contextRisk + sideFatigueRisk, 4), + }, + ]; +} + +function buildMarketRecommendations(match, matchContext = {}, newsBias = 0) { + const profiles = collectMarketProfiles(match); + const now = Date.now(); + const advice = []; + const marketSummary = []; + const newsBoost = match.newsSignal || { home: 0, away: 0 }; + const marketKeys = Object.keys(profiles); + const coverage = { + bookmakers: match.bookmakers?.length || 0, + markets: marketKeys.length, + availableMarkets: marketKeys, + }; + let h2hNormalized = null; + + const h2h = profiles.h2h; + if (h2h) { + const outcomes = Object.values(h2h.counts) + .map((o) => ({ ...o, prob: o.avgImplied })) + .sort((a, b) => b.avgPrice - a.avgPrice); + if (outcomes.length > 0) { + const normalized = normalizeProbs(outcomes); + h2hNormalized = normalized; + const best = normalized.reduce((a, b) => (a.fairProb > b.fairProb ? a : b), normalized[0]); + const confidence = clamp(0.35 + best.fairProb * 0.55 + Math.min(0.25, best.sample * 0.05), 0, 0.99); + const name = best.name === match.homeTeam ? '主勝' : best.name === match.awayTeam ? '客勝' : '和局'; + marketSummary.push({ + market: 'h2h', + bestOutcome: name, + bestOdds: best.bestPrice, + fairProbability: round(best.fairProb, 4), + confidence: round(confidence, 4), + impliedProbability: round(best.avgImplied, 4), + }); + advice.push({ + market: '1X2', + selection: name, + odds: round(best.bestPrice, 2), + probability: round(best.fairProb, 4), + confidence: round(confidence, 2), + impliedProbability: round(best.avgImplied, 4), + rationale: `依市場共識計算,${name}為高於其他盤路的最大勝率候選`, + }); + } + + const normalized = normalizeProbs(outcomes); + const mapProb = Object.fromEntries( + normalized.map((x) => [ + x.name, + x.fairProb, + ]), + ); + const home = mapProb[match.homeTeam] || 0; + const away = mapProb[match.awayTeam] || 0; + const draw = mapProb.Draw || mapProb['平局'] || 0; + if (home + draw >= 0.65) { + advice.push({ + market: 'Double Chance', + selection: `主勝/和局(${match.homeTeam})`, + odds: round(1 / clamp(home + draw, 0.01, 0.99) * 1.02, 2), + probability: round(home + draw, 4), + confidence: round(0.6 + (home + draw - 0.65) * 1.2, 2), + impliedProbability: round(home + draw, 4), + rationale: '主隊與和局組合,實戰上通常勝率較單場選擇更穩健', + }); + } + if (away + draw >= 0.65) { + advice.push({ + market: 'Double Chance', + selection: `客勝/和局(${match.awayTeam})`, + odds: round(1 / clamp(away + draw, 0.01, 0.99) * 1.02, 2), + probability: round(away + draw, 4), + confidence: round(0.6 + (away + draw - 0.65) * 1.2, 2), + impliedProbability: round(away + draw, 4), + rationale: '客隊與和局組合,降波動可提高落盤穩定性', + }); + } + } + + const totals = profiles.totals; + if (totals) { + const overUnder = Object.entries(totals.counts).map(([k, row]) => ({ ...row, rowKey: k })); + const near25 = overUnder.filter((x) => { + if (x.point === null || !Number.isFinite(x.point)) return false; + return Math.abs(x.point - 2.5) < 0.01; + }); + const rows = (near25.length ? near25 : overUnder).map((row) => ({ ...row, prob: row.avgImplied })); + const normalized = normalizeProbs(rows); + const best = normalized.reduce((a, b) => (a.fairProb > b.fairProb ? a : b), normalized[0]); + if (best) { + const overUnderLabel = best.name === 'Over' ? '大球' : '小球'; + marketSummary.push({ + market: 'totals', + bestOutcome: `${overUnderLabel} ${best.point ?? ''}`, + bestOdds: best.bestPrice, + fairProbability: round(best.fairProb, 4), + impliedProbability: round(best.avgImplied, 4), + }); + advice.push({ + market: 'Totals', + selection: `${overUnderLabel} ${best.point ?? ''}`, + odds: round(best.bestPrice, 2), + probability: round(best.fairProb, 4), + confidence: round(clamp(0.55 + best.fairProb * 0.5, 0.1, 0.98), 2), + impliedProbability: round(best.avgImplied, 4), + rationale: '2.5球線是世界盃常用參考線,取接近共識中心線進行比較', + }); + } + } + + const btts = profiles.btts; + if (btts) { + const rows = Object.entries(btts.counts).map(([k, r]) => ({ ...r, rowKey: k, prob: r.avgImplied })); + if (rows.length >= 2) { + const normalized = normalizeProbs(rows); + const yes = normalized.find((r) => r.name === 'Yes'); + const no = normalized.find((r) => r.name === 'No'); + const bestRaw = normalized.reduce((a, b) => (a.fairProb > b.fairProb ? a : b), normalized[0]); + if (bestRaw) { + marketSummary.push({ + market: 'btts', + bestOutcome: bestRaw.name === 'Yes' ? '雙方進球' : '任一方零進球', + bestOdds: bestRaw.bestPrice, + fairProbability: round(bestRaw.fairProb, 4), + impliedProbability: round(bestRaw.avgImplied, 4), + }); + advice.push({ + market: 'Both Teams To Score', + selection: bestRaw.name === 'Yes' ? '雙方都有進球' : '至少一隊零進球', + odds: round(bestRaw.bestPrice, 2), + probability: round(bestRaw.fairProb, 4), + confidence: round(clamp(0.5 + bestRaw.fairProb * 0.45, 0.1, 0.95), 2), + impliedProbability: round(bestRaw.avgImplied, 4), + rationale: '以總入球與攻守傾向交叉對照,挑選高勝率方向', + }); + } + if (yes && no) { + const bias = (yes.fairProb - no.fairProb); + if (Math.abs(bias) > 0.2) { + advice.push({ + market: 'BTTS Trend', + selection: bias > 0 ? '偏雙方進球高於平均' : '偏至少一隊零進球', + odds: round((bias > 0 ? yes.bestPrice : no.bestPrice), 2), + probability: round(Math.max(yes.fairProb, no.fairProb), 4), + confidence: round(0.5 + Math.abs(bias), 2), + impliedProbability: round(Math.max(yes.avgImplied, no.avgImplied), 4), + rationale: '該盤差距明顯,通常代表策略清晰度較高', + }); + } + } + } + } + + const spreads = profiles.spreads; + if (spreads) { + const rows = Object.entries(spreads.counts).map(([k, r]) => ({ ...r, rowKey: k, prob: r.avgImplied })); + const normalized = normalizeProbs(rows); + const best = normalized.reduce((a, b) => (a.fairProb > b.fairProb ? a : b), normalized[0]); + if (best && Number.isFinite(best.point)) { + const label = `${best.name} ${best.point > 0 ? '+' : ''}${best.point}`; + marketSummary.push({ + market: 'spreads', + bestOutcome: `${label}`, + bestOdds: best.bestPrice, + fairProbability: round(best.fairProb, 4), + impliedProbability: round(best.avgImplied, 4), + }); + advice.push({ + market: 'Handicap', + selection: `${label}`, + odds: round(best.bestPrice, 2), + probability: round(best.fairProb, 4), + confidence: round(clamp(0.48 + best.fairProb * 0.45, 0.1, 0.94), 2), + impliedProbability: round(best.avgImplied, 4), + rationale: '讓球盤提供場面分化,配合讓分與近期走勢更有價值', + }); + } + } + + // 新聞情緒調整:若某隊在近期頭條帶有明顯負面訊號,降低同隊勝率 + if (newsBoost.home < 0 || newsBoost.away < 0) { + for (let i = 0; i < advice.length; i += 1) { + const p = advice[i].selection; + if (newsBoost.home <= -0.04 && p.includes(match.homeTeam)) { + advice[i].probability = clamp(advice[i].probability + newsBoost.home, 0.01, 0.99); + advice[i].rationale += `;${match.homeTeam}近期新聞負向修正已套用`; + } + if (newsBoost.away <= -0.04 && p.includes(match.awayTeam)) { + advice[i].probability = clamp(advice[i].probability + newsBoost.away, 0.01, 0.99); + advice[i].rationale += `;${match.awayTeam}近期新聞負向修正已套用`; + } + } + } + + const uniqueAdvice = []; + const seen = new Set(); + for (const item of advice) { + const key = `${item.market}-${item.selection}`; + if (!seen.has(key)) { + seen.add(key); + const adjusted = applyContextToSignal(match, item, matchContext, { newsBias }); + const ev = expectedValue(adjusted.probability, adjusted.odds); + const tier = getRecommendationTier(adjusted.probability, adjusted.confidence); + uniqueAdvice.push({ + ...adjusted, + expectedValue: ev, + expectedRoiPercent: ev !== null ? round(ev * 100, 2) : null, + kellyFraction: kellyFraction(adjusted.probability, adjusted.odds), + valueEdge: Number.isFinite(adjusted.impliedProbability) + ? round(adjusted.probability - adjusted.impliedProbability, 4) + : null, + modelGrade: adjusted.confidence >= 0.8 ? 'A' : adjusted.confidence >= 0.7 ? 'B' : adjusted.confidence >= 0.6 ? 'C' : 'D', + recommendationTier: tier, + generatedAt: now, + }); + } + } + + uniqueAdvice.sort((a, b) => b.confidence - a.confidence); + const recommendationBuckets = { + high: [], + medium: [], + low: [], + }; + for (const item of uniqueAdvice) { + if (item.recommendationTier === 'high') recommendationBuckets.high.push(item); + else if (item.recommendationTier === 'medium') recommendationBuckets.medium.push(item); + else recommendationBuckets.low.push(item); + } + return { + marketSummary: marketSummary.slice(0, 8), + recommendations: uniqueAdvice.slice(0, 6), + recommendationBuckets, + topRecommendation: uniqueAdvice[0] || null, + marketCoverage: coverage, + upsetSignals: buildH2hUpsetSignals(match, h2hNormalized, now, matchContext, newsBoost.home + newsBoost.away), + matchContext: buildMatchContextNote(matchContext, now), + }; +} + +function teamNewsSentiment(match, articles) { + const negWords = [ + 'injury', + 'injuries', + 'injured', + 'sidelined', + 'suspension', + 'red card', + 'yellow', + 'out', + 'absence', + 'missing', + 'doubtful', + 'coach', + 'tension', + 'rotation', + 'fatigue', + '疲勞', + '傷兵', + '缺陣', + '受傷', + '停賽', + '紅牌', + '傷', + '戰意', + ]; + const team = { + home: 0, + away: 0, + }; + for (const item of articles) { + const haystack = `${item.title} ${item.description || ''}`.toLowerCase(); + const homeHits = negWords.some((w) => haystack.includes(String(w).toLowerCase()) && haystack.includes(match.homeTeam.toLowerCase())); + const awayHits = negWords.some((w) => haystack.includes(String(w).toLowerCase()) && haystack.includes(match.awayTeam.toLowerCase())); + if (homeHits) team.home -= 0.04; + if (awayHits) team.away -= 0.04; + } + return team; +} + +async function fetchOddsMatches() { + if (!CONFIG.oddsApiKey) { + setSourceRuntime('the_odds_api', { + status: 'error', + checkedAt: new Date().toISOString(), + lastError: 'THE_ODDS_API_KEY 未設定', + message: '缺少 THE_ODDS_API_KEY', + }); + throw new Error('THE_ODDS_API_KEY 未設定,無法自動抓取賠率。'); + } + const startedAt = Date.now(); + const url = `${CONFIG.oddsBase}/v4/sports/${encodeURIComponent(CONFIG.sportKey)}/odds`; + try { + const { data } = await withRetry( + async () => { + const response = await axios.get(url, { + timeout: CONFIG.requestTimeoutMs, + params: { + regions: CONFIG.regions, + markets: CONFIG.markets, + oddsFormat: 'decimal', + apiKey: CONFIG.oddsApiKey, + }, + }); + return response; + }, + { + retries: CONFIG.requestRetryTimes, + name: 'the_odds_api', + baseDelayMs: CONFIG.requestRetryBaseDelayMs, + }, + ); + const events = Array.isArray(data) ? data : []; + setSourceRuntime('the_odds_api', { + status: 'ok', + checkedAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + lastError: '', + message: `成功取得 ${events.length} 場`, + }); + return events.map(normalizeOddsEvent); + } catch (e) { + const msg = e?.response?.data?.message || e.message || 'The Odds API 抓取失敗'; + setSourceRuntime('the_odds_api', { + status: 'error', + checkedAt: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + lastError: String(msg), + message: '抓取失敗', + }); + throw e; + } +} + +function decodeHtmlEntities(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function extractTag(block, tagName) { + const regex = new RegExp(`<${tagName}(?: [^>]+)?>[\\s\\S]*?<\\/${tagName}>`, 'i'); + const match = block.match(regex); + if (!match) return null; + let raw = match[0]; + raw = raw.replace(new RegExp(`^<${tagName}(?: [^>]+)?>`, 'i'), '').replace(new RegExp(`<\\/${tagName}>$`, 'i'), ''); + raw = raw.replace(//g, '$1'); + return decodeHtmlEntities(raw.trim()); +} + +async function fetchNewsFromNewsApi(match) { + const q = `${match.homeTeam} ${match.awayTeam} 2026 World Cup`; + const url = 'https://newsapi.org/v2/everything'; + const since = new Date(Date.now() - CONFIG.newsLookbackDays * 24 * 60 * 60 * 1000).toISOString(); + const startedAt = Date.now(); + setSourceRuntime('newsapi', { + status: 'checking', + checkedAt: new Date().toISOString(), + message: '開始抓取', + }); + try { + const { data } = await withRetry( + async () => { + const response = await axios.get(url, { + timeout: CONFIG.requestTimeoutMs, + params: { + q, + language: 'en', + sortBy: 'publishedAt', + from: since, + pageSize: 8, + apiKey: CONFIG.newsApiKey, + }, + }); + return response; + }, + { + retries: CONFIG.requestRetryTimes, + name: 'newsapi', + baseDelayMs: CONFIG.requestRetryBaseDelayMs, + }, + ); + const list = Array.isArray(data.articles) ? data.articles : []; + setSourceRuntime('newsapi', { + status: 'ok', + checkedAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + lastError: '', + message: `成功抓取 ${list.length} 則`, + }); + return list.slice(0, 5).map((a) => ({ + title: a.title, + link: a.url, + publishedAt: a.publishedAt, + source: a.source && a.source.name ? a.source.name : 'NewsAPI', + description: a.description || a.content || '', + })); + } catch (e) { + const msg = e?.response?.data?.message || e.message || 'NewsAPI 抓取失敗'; + setSourceRuntime('newsapi', { + status: 'error', + checkedAt: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + lastError: String(msg), + message: '抓取失敗', + }); + throw e; + } +} + +function buildRssQuery(target, match) { + const baseQuery = `${match.homeTeam} ${match.awayTeam} FIFA World Cup 2026`; + const query = encodeURIComponent(target.site + ? `${baseQuery} site:${target.site}` + : baseQuery); + const lang = target.queryLang || 'en-US'; + const locale = target.locale || 'US'; + const ceid = locale === 'TW' ? 'TW:zh-Hant' : locale === 'GB' ? 'GB:en-GB' : `${locale}:en`; + return `https://news.google.com/rss/search?q=${query}&hl=${lang}&gl=${locale}&ceid=${ceid}`; +} + +function parseRssItems(xmlText, sourceLabel = 'RSS', limit = 4) { + const xml = String(xmlText || ''); + const out = []; + const itemRegex = //g; + let m = null; + while ((m = itemRegex.exec(xml)) !== null) { + const block = m[0]; + const title = extractTag(block, 'title'); + if (!title) continue; + const link = extractTag(block, 'link'); + const pubRaw = extractTag(block, 'pubDate'); + const description = extractTag(block, 'description'); + const publishedDate = new Date(pubRaw); + const publishedAt = Number.isFinite(publishedDate.getTime()) ? publishedDate.toISOString() : new Date().toISOString(); + out.push({ + title, + link, + publishedAt, + description, + source: sourceLabel, + }); + if (out.length >= limit) break; + } + return out; +} + +async function fetchNewsFromRss(match) { + const all = []; + const sourceList = NEWS_RSS_REFERENCE_TARGETS.length + ? NEWS_RSS_REFERENCE_TARGETS + : [{ key: 'google_news_rss', source: 'Google News RSS', queryLang: 'zh-TW', locale: 'TW' }]; + for (const source of sourceList) { + const sourceKey = source.key || 'google_news_rss'; + const sourceName = source.source || sourceKey; + const startedAt = Date.now(); + setSourceRuntime(sourceKey, { + status: 'checking', + checkedAt: new Date().toISOString(), + message: `開始抓取 ${sourceName}`, + }); + try { + const url = buildRssQuery(source, match); + const { data } = await withRetry( + async () => { + const response = await axios.get(url, { + timeout: CONFIG.requestTimeoutMs, + responseType: 'text', + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; 2026fifa-research-bot/1.0)', + }, + }); + return response; + }, + { + retries: CONFIG.requestRetryTimes, + name: sourceKey, + baseDelayMs: CONFIG.requestRetryBaseDelayMs, + }, + ); + const rows = parseRssItems(data, sourceName, 4); + all.push(...rows); + setSourceRuntime(sourceKey, { + status: 'ok', + checkedAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + lastError: '', + message: `成功抓取 ${rows.length} 則`, + }); + } catch (e) { + const msg = e?.response?.data?.message || e.message || `${sourceName} 抓取失敗`; + setSourceRuntime(sourceKey, { + status: 'error', + checkedAt: new Date().toISOString(), + latencyMs: Date.now() - startedAt, + lastError: String(msg), + message: '抓取失敗', + }); + } + await sleep(90); + } + + const dedupe = []; + const used = new Set(); + for (const row of all.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())) { + const dedupeKey = `${String(row.title || '').toLowerCase()}::${String(row.link || '').toLowerCase()}`; + if (used.has(dedupeKey)) continue; + used.add(dedupeKey); + dedupe.push(row); + if (dedupe.length >= 12) break; + } + return dedupe; +} + +async function fetchMatchNews(match) { + try { + if (CONFIG.newsApiKey && CONFIG.newsProvider === 'newsapi') { + return await fetchNewsFromNewsApi(match); + } + return await fetchNewsFromRss(match); + } catch (e) { + return []; + } +} + +async function hydrateNews(matches) { + const targets = matches.slice(0, CONFIG.maxMatchesForNews); + const limit = CONFIG.newsFetchConcurrency; + const out = []; + const queue = [...targets]; + const workers = Array.from({ length: Math.min(limit, targets.length) }, async () => { + while (queue.length > 0) { + const match = queue.shift(); + if (!match) break; + const articles = await fetchMatchNews(match); + match.news = articles; + match.newsSignal = teamNewsSentiment(match, articles); + // 先放慢速率,避免部分 RSS 來源對高併發限流 + await sleep(200); + } + }); + await Promise.all(workers); + return out; +} + +function analyzeMatches(matches) { + const contextByMatch = buildMatchContextProfiles(matches); + const matchAnalysis = matches + .map((match) => { + const context = buildMatchContextNote(contextByMatch[String(match.id)] || {}, Date.now()); + const recommendation = buildMarketRecommendations(match, context, (match.newsSignal?.home || 0) + (match.newsSignal?.away || 0)); + match.matchContext = context; + const quantitative = analyzeMatchForQuant(match); + const allArticles = Array.isArray(match.news) ? match.news : []; + const latestNewsAt = allArticles.length ? allArticles[0].publishedAt : null; + const buckets = recommendation.recommendationBuckets || { + high: [], + medium: [], + low: [], + }; + const upsets = Array.isArray(recommendation.upsetSignals) ? recommendation.upsetSignals : []; + return { + matchId: match.id, + kickoffAt: match.commenceTime, + kickoffAtTaipei: match.commenceTimeTaipei || toTaipeiDateTime(match.commenceTime), + teams: `${match.homeTeam} vs ${match.awayTeam}`, + source: match.sportTitle || 'FIFA World Cup', + marketSummary: recommendation.marketSummary, + recommendations: recommendation.recommendations, + matchContext: context, + recommendationBuckets: buckets, + upsetSignals: upsets, + topRecommendation: recommendation.topRecommendation, + marketCoverage: recommendation.marketCoverage || { + bookmakers: 0, + markets: 0, + availableMarkets: [], + }, + news: allArticles, + latestNewsAt, + quantitative, + }; + }) + .sort((a, b) => new Date(a.kickoffAt) - new Date(b.kickoffAt)); + + const flat = []; + const tierFlat = { + high: [], + medium: [], + low: [], + }; + const upsetFlat = []; + for (const item of matchAnalysis) { + if (item.topRecommendation) { + const tier = getRecommendationTier(item.topRecommendation.probability, item.topRecommendation.confidence); + const entry = { + matchId: item.matchId, + teams: item.teams, + kickoffAt: item.kickoffAt, + ...item.topRecommendation, + }; + flat.push(entry); + if (tier === 'high') tierFlat.high.push(entry); + else if (tier === 'medium') tierFlat.medium.push(entry); + else tierFlat.low.push(entry); + } + if (item.upsetSignals?.length) { + upsetFlat.push({ + matchId: item.matchId, + teams: item.teams, + kickoffAt: item.kickoffAt, + signals: item.upsetSignals, + }); + } + } + + const byConf = [...flat].sort((a, b) => b.confidence - a.confidence); + const doubles = buildParlayCandidates(byConf, 2); + const triples = buildParlayCandidates(byConf, 3); + const playbookSingles = buildSinglePlaybook(matchAnalysis); + const playbookParlay = { + double: { + conservative: buildCombosByStyle(byConf, 2, 'conservative'), + balanced: buildCombosByStyle(byConf, 2, 'balanced'), + value: buildCombosByStyle(byConf, 2, 'value'), + }, + triple: { + conservative: buildCombosByStyle(byConf, 3, 'conservative'), + balanced: buildCombosByStyle(byConf, 3, 'balanced'), + value: buildCombosByStyle(byConf, 3, 'value'), + }, + }; + const playbookSystem = buildSystemPlaybook(playbookSingles.all); + const playbookCrossMarket = buildCrossMarketPairs(matchAnalysis); + const profile = buildPortfolioProfile(matchAnalysis, byConf); + const comprehensiveParlay = { + twoLeg: { + conservative: buildMultiLegPlaybook(byConf, 2, 'conservative', 8), + balanced: buildMultiLegPlaybook(byConf, 2, 'balanced', 8), + value: buildMultiLegPlaybook(byConf, 2, 'value', 8), + }, + threeLeg: { + conservative: buildMultiLegPlaybook(byConf, 3, 'conservative', 8), + balanced: buildMultiLegPlaybook(byConf, 3, 'balanced', 8), + value: buildMultiLegPlaybook(byConf, 3, 'value', 8), + }, + fourLeg: { + conservative: buildMultiLegPlaybook(byConf, 4, 'conservative', 6), + balanced: buildMultiLegPlaybook(byConf, 4, 'balanced', 6), + value: buildMultiLegPlaybook(byConf, 4, 'value', 6), + }, + fiveLeg: { + conservative: buildMultiLegPlaybook(byConf, 5, 'conservative', 6), + balanced: buildMultiLegPlaybook(byConf, 5, 'balanced', 6), + value: buildMultiLegPlaybook(byConf, 5, 'value', 6), + }, + }; + + return { + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + perMatch: matchAnalysis, + topSingles: byConf.slice(0, 12), + highProbabilitySingles: tierFlat.high.slice(0, 12), + mediumProbabilitySingles: tierFlat.medium.slice(0, 12), + lowProbabilitySingles: tierFlat.low.slice(0, 12), + upsetSignals: upsetFlat, + professionalPlaybook: { + singles: playbookSingles, + parlay: { + double: { + conservative: (playbookParlay.double.conservative || []).slice(0, 8), + balanced: (playbookParlay.double.balanced || []).slice(0, 8), + value: (playbookParlay.double.value || []).slice(0, 8), + }, + triple: { + conservative: (playbookParlay.triple.conservative || []).slice(0, 8), + balanced: (playbookParlay.triple.balanced || []).slice(0, 8), + value: (playbookParlay.triple.value || []).slice(0, 8), + }, + }, + system: playbookSystem, + crossMarket: playbookCrossMarket, + overall: { + portfolio: profile, + multiLegParlay: comprehensiveParlay, + }, + methodology: { + source: 'server-analysis-playbook', + generatedAt: new Date().toISOString(), + }, + }, + recommendationBuckets: { + high: byConf.filter((r) => getRecommendationTier(r.probability, r.confidence) === 'high').slice(0, 24), + medium: byConf.filter((r) => getRecommendationTier(r.probability, r.confidence) === 'medium').slice(0, 24), + low: byConf.filter((r) => getRecommendationTier(r.probability, r.confidence) === 'low').slice(0, 24), + }, + doublePlay: doubles.slice(0, 8), + triplePlay: triples.slice(0, 8), + bankrollGuide: buildBankrollGuide(), + }; +} + +function buildParlayCandidates(pickPool, size = 2) { + const uniq = []; + const seenMatch = new Set(); + for (const p of pickPool) { + const matchId = p.matchId; + if (!seenMatch.has(matchId)) { + uniq.push(p); + seenMatch.add(matchId); + } + } + const candidates = []; + const recs = uniq.filter((r) => r.confidence >= 0.55); + if (recs.length < size) return []; + + function combine(start, depth, current) { + if (depth === 0) { + const odds = current.reduce((acc, r) => acc * (r.odds || 1), 1); + const prob = current.reduce((acc, r) => acc * (r.probability || 0), 1); + const expectedPayout = round(odds * 1, 2); + candidates.push({ + legs: [...current], + odds: round(odds, 2), + hitProbability: round(prob, 4), + expectedRoi: round(prob * expectedPayout - 1, 4), + notes: `含 ${current.length} 段,建議比對場次間關聯與輪換因素`, + }); + return; + } + for (let i = start; i <= recs.length - depth; i += 1) { + current.push(recs[i]); + combine(i + 1, depth - 1, current); + current.pop(); + } + } + + combine(0, size, []); + return candidates + .filter((x) => x.hitProbability > 0) + .sort((a, b) => b.expectedRoi - a.expectedRoi); +} + +function buildBankrollGuide() { + return { + principle: [ + '以單筆資金管理優先:高勝率玩法先用 0.5%~1% 單位下注。', + 'Kelly 增益法僅作為參考:p*(odds-1)- (1-p);結果過低時直接降到 0。', + '串關只做短段(2~3 串);不宜一次塞入過長組合,避免波動過大。', + '單邊風險集中時,加入「反對沖」保命:同場不同市場同時下注比例需嚴控。', + '保留 20~30% bankroll 做臨場補倉與修正,不做全倉追注。', + ], + suggestedModel: + '以 1.5 倍 Kelly 上限為外界容錯,實際建議使用 0.3~0.8 倍縮放;新聞負面信號時降低下注比例。', + warning: + '此策略僅做研究與風險分析,不保證命中;請設置明確虧損停損邏輯,避免情緒化追注。', + }; +} + +function buildScheduleComparison(matches) { + const dateMap = {}; + const contextMap = buildMatchContextProfiles(matches); + const restWarnings = []; + + const sorted = [...matches].sort((a, b) => new Date(a.commenceTime) - new Date(b.commenceTime)); + for (const match of sorted) { + const key = toTaipeiDate(match.commenceTime); + if (!dateMap[key]) dateMap[key] = []; + dateMap[key].push({ + matchId: match.id, + kickoffAt: match.commenceTime, + kickoffAtTaipei: match.commenceTimeTaipei || toTaipeiDateTime(match.commenceTime), + teams: `${match.homeTeam} vs ${match.awayTeam}`, + }); + + } + + Object.entries(contextMap).forEach(([matchId, profile]) => { + const match = getMatchById(matchId); + if (!match) return; + const makeWarning = (side) => { + const sideInfo = profile[side] || {}; + if (!Number.isFinite(sideInfo.restDays) || sideInfo.restDays >= 2.5 || !sideInfo.previousMatchId) return null; + const opponent = side === 'home' ? match.awayTeam : match.homeTeam; + return { + team: side === 'home' ? match.homeTeam : match.awayTeam, + matchId, + daysRest: sideInfo.restDays, + side, + opponent, + note: `僅有 ${sideInfo.restDays} 天休息,需提高疲勞權重`, + }; + }; + const warnHome = makeWarning('home'); + const warnAway = makeWarning('away'); + if (warnHome) restWarnings.push(warnHome); + if (warnAway) restWarnings.push(warnAway); + }); + + const hotNewsMatches = []; + for (const match of matches) { + const articles = Array.isArray(match.news) ? match.news : []; + const latest = articles.length ? new Date(articles[0].publishedAt).getTime() : null; + const ageHours = latest ? round((Date.now() - latest) / 3600000, 2) : null; + if (latest && ageHours <= 48) { + hotNewsMatches.push({ + matchId: match.id, + teams: `${match.homeTeam} vs ${match.awayTeam}`, + ageHours, + sampleHeadlines: articles.slice(0, 2).map((a) => a.title), + }); + } + } + + return { + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + byDate: Object.entries(dateMap).map(([date, events]) => ({ + date, + count: events.length, + events, + })), + restWarnings, + hotNewsWithin48h: hotNewsMatches.sort((a, b) => a.ageHours - b.ageHours), + }; +} + +function getMatchKickoffRaw(match) { + return match?.commenceTime || match?.kickoffAt || match?.startTime || match?.start_time || match?.raw?.commence_time || match?.raw?.start_time; +} + +function buildTodayInsights(matches = [], analysisPayload = {}, at = new Date()) { + const dateKey = toTaipeiDate(at); + const perMatch = Array.isArray(analysisPayload?.perMatch) ? analysisPayload.perMatch : []; + const matchMap = new Map( + perMatch + .map((row) => [String(row?.matchId || ''), row]) + .filter(([matchId, row]) => matchId && row), + ); + + const todayMatches = Array.isArray(matches) + ? matches + .map((match) => { + const kickoff = new Date(getMatchKickoffRaw(match)); + if (!Number.isFinite(kickoff.getTime())) return null; + if (toTaipeiDate(kickoff) !== dateKey) return null; + + const row = matchMap.get(String(match.id)); + if (!row) return null; + const buckets = row.recommendationBuckets || {}; + return { + matchId: String(match.id), + teams: row.teams || `${match.homeTeam} vs ${match.awayTeam}`, + kickoffAt: row.kickoffAt || kickoff.toISOString(), + kickoffAtTaipei: row.kickoffAtTaipei || toTaipeiDateTime(kickoff), + topRecommendation: row.topRecommendation || null, + highCount: Array.isArray(buckets.high) ? buckets.high.length : 0, + mediumCount: Array.isArray(buckets.medium) ? buckets.medium.length : 0, + lowCount: Array.isArray(buckets.low) ? buckets.low.length : 0, + upsets: Array.isArray(row.upsetSignals) ? row.upsetSignals.slice(0, 4) : [], + }; + }) + .filter(Boolean) + .sort((a, b) => new Date(a.kickoffAt).getTime() - new Date(b.kickoffAt).getTime()) + : []; + + const todayMatchIds = new Set(todayMatches.map((row) => row.matchId)); + + const upsetSignals = []; + const upsetBreakdown = { high: 0, medium: 0, low: 0 }; + const flatUpsets = Array.isArray(analysisPayload?.upsetSignals) ? analysisPayload.upsetSignals : []; + for (const row of flatUpsets) { + const rowMatchId = String(row?.matchId || ''); + if (!todayMatchIds.has(rowMatchId)) continue; + const signals = Array.isArray(row.signals) ? row.signals : []; + for (const s of signals) { + const risk = s?.riskLabel || 'low'; + if (risk === 'high') upsetBreakdown.high += 1; + else if (risk === 'medium') upsetBreakdown.medium += 1; + else upsetBreakdown.low += 1; + upsetSignals.push({ + ...s, + matchId: rowMatchId, + teams: row.teams || row.homeTeam || row.awayTeam || matchMap.get(rowMatchId)?.teams || '-', + }); + } + } + + const topHighSingles = (Array.isArray(analysisPayload?.highProbabilitySingles) ? analysisPayload.highProbabilitySingles : []).filter((row) => + todayMatchIds.has(String(row?.matchId || '')), + ); + const topMediumSingles = (Array.isArray(analysisPayload?.mediumProbabilitySingles) ? analysisPayload.mediumProbabilitySingles : []).filter((row) => + todayMatchIds.has(String(row?.matchId || '')), + ); + const topLowSingles = (Array.isArray(analysisPayload?.lowProbabilitySingles) ? analysisPayload.lowProbabilitySingles : []).filter((row) => + todayMatchIds.has(String(row?.matchId || '')), + ); + + return { + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + date: dateKey, + dateLabel: `${dateKey}(台北時間)`, + matchCount: todayMatches.length, + matches: todayMatches, + topSinglesByTier: { + high: topHighSingles, + medium: topMediumSingles, + low: topLowSingles, + }, + upsetSignals: upsetSignals.sort((a, b) => (Number(b.probability) || 0) - (Number(a.probability) || 0)).slice(0, 18), + summary: { + highSingles: topHighSingles.length, + mediumSingles: topMediumSingles.length, + lowSingles: topLowSingles.length, + upsetSignals: upsetSignals.length, + }, + upsetBreakdown, + }; +} + +function isMatchWithinTimeWindow(match, now, startOffsetHours, endOffsetHours = Number.POSITIVE_INFINITY) { + const kickoff = new Date(match.commenceTime || match.raw?.commence_time || match.commence_time).getTime(); + if (!Number.isFinite(kickoff)) return false; + const deltaHours = (kickoff - now) / 3600000; + return deltaHours >= -endOffsetHours && deltaHours <= startOffsetHours; +} + +function resolveRefreshIntervalMs() { + if (!state.matches || !state.matches.length) return Math.max(30, CONFIG.refreshMinutes) * 60 * 1000; + const now = Date.now(); + const liveOrImminent = state.matches.some((match) => + isMatchWithinTimeWindow(match, now, CONFIG.fastRefreshHours, 4), + ); + return liveOrImminent + ? Math.max(20, CONFIG.liveRefreshSeconds) * 1000 + : Math.max(60, CONFIG.refreshMinutes) * 60 * 1000; +} + +function safeFormatSchedule(schedule) { + if (Array.isArray(schedule)) return schedule.slice(0, 3); + return []; +} + +async function refreshData() { + state.status = 'refreshing'; + state.errors = []; + try { + const matches = await fetchOddsMatches(); + state.matches = matches; + matches.forEach((m) => appendOddsHistorySnapshot(m)); + await hydrateNews(matches); + await probeReferenceSources(); + const now = new Date(); + state.lastUpdated = now.toISOString(); + state.lastUpdatedTaipei = toTaipeiDateTime(now); + state.analysis = analyzeMatches(matches); + state.scheduleComparison = buildScheduleComparison(matches); + state.todayInsights = buildTodayInsights(matches, state.analysis, now); + state.status = 'ready'; + return { + ok: true, + count: matches.length, + matches, + analysis: state.analysis, + scheduleComparison: state.scheduleComparison, + todayInsights: state.todayInsights, + }; + } catch (e) { + state.status = 'error'; + state.errors.push({ at: new Date().toISOString(), message: e.message || String(e) }); + return { + ok: false, + error: e.message || 'refresh failed', + count: 0, + }; + } +} + +function toTaipeiNowISO() { + return toTaipeiDateTime(Date.now()); +} + +function parseMatchIds(queryValue) { + if (Array.isArray(queryValue)) { + return queryValue + .map((v) => String(v || '').trim()) + .filter(Boolean); + } + if (typeof queryValue === 'string' && queryValue.trim()) { + return queryValue + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + } + return []; +} + +function latestPortfolioSnapshot() { + return { + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + ...(buildPortfolioSummaryView() || {}), + }; +} + +function getAllSharMoney(limit = 80) { + const pairs = []; + for (const [matchId, history] of Object.entries(state.oddsHistoryByMatch)) { + const match = getMatchById(matchId); + const signals = buildSharpMoneyFromHistory(match, history); + for (const item of signals) { + pairs.push({ + ...item, + matchId, + match: match ? `${match.homeTeam} vs ${match.awayTeam}` : matchId, + }); + } + } + return pairs + .sort((a, b) => b.deltaSignal - a.deltaSignal) + .slice(0, limit); +} + +app.get('/api/health', (req, res) => { + res.json({ + status: state.status, + lastUpdated: state.lastUpdated, + lastUpdatedTaipei: state.lastUpdatedTaipei, + publicOrigin: CONFIG.publicOrigin, + timeZone: CONFIG.timeZone, + matchCount: state.matches.length, + sourceProbeAt: state.sourceProbeAt, + sourceRegistry: sourceStatusView(), + errors: state.errors.slice(-10), + }); +}); + +app.get('/api/source-registry', async (req, res) => { + if (req.query.force === '1') { + await probeReferenceSources(true); + } + res.json({ + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + methodology: '多來源參考 + 權重校準(主幹賠率、新聞輔助、官方資料對照)', + sources: sourceStatusView(), + }); +}); + +app.get('/api/today-insights', async (req, res) => { + if (!state.matches.length || !state.analysis || req.query.force === '1') { + await refreshData(); + } + + const parsed = req.query.date ? new Date(String(req.query.date)) : new Date(); + const at = Number.isFinite(parsed.getTime()) ? parsed : new Date(); + const payload = buildTodayInsights(state.matches, state.analysis, at); + state.todayInsights = payload; + res.json(payload); +}); + +app.get('/api/market-matrix', async (req, res) => { + if (!state.matches.length || req.query.force === '1') { + await refreshData(); + } + const matchId = req.query.matchId; + const matchIds = parseMatchIds(req.query.matchIds || req.query.matchIdIn || matchId); + const result = buildOddsMatrix(state.matches, { + matchId: matchId ? String(matchId) : null, + matchIds, + }); + res.json({ + generatedAt: state.lastUpdated, + generatedAtTaipei: state.lastUpdatedTaipei, + source: 'server-engine:odds-matrix', + ...result, + }); +}); + +app.get('/api/line-movement', async (req, res) => { + if (!state.matches.length) { + await refreshData(); + } + const matchId = req.query.matchId; + if (!matchId) { + return res.status(400).json({ error: 'matchId 是必填參數' }); + } + + const market = req.query.market ? String(req.query.market) : ''; + const row = extractLineMovement(state.oddsHistory, matchId, market); + const history = state.oddsHistoryByMatch[String(matchId)] || []; + const lastTs = history.length ? history[history.length - 1].ts : null; + const match = getMatchById(matchId); + res.json({ + matchId, + teams: match ? `${match.homeTeam} vs ${match.awayTeam}` : String(matchId), + market, + generatedAt: toTaipeiDateTime(Date.now()), + lastSnapshotAt: lastTs, + data: row, + }); +}); + +app.get('/api/sharp-money', async (req, res) => { + if (!state.matches.length || req.query.force === '1') { + await refreshData(); + } + + const matchId = req.query.matchId; + const limit = Math.max(10, Math.min(240, Number(req.query.limit) || 80)); + + if (matchId) { + const match = getMatchById(matchId); + const history = state.oddsHistoryByMatch[String(matchId)] || []; + const all = buildSharpMoneyFromHistory(match, history).map((item) => ({ + ...item, + matchId: String(matchId), + match: match ? `${match.homeTeam} vs ${match.awayTeam}` : String(matchId), + })); + return res.json({ + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + matchId: String(matchId), + match: match ? `${match.homeTeam} vs ${match.awayTeam}` : String(matchId), + signals: all.slice(0, limit), + }); + } + + res.json({ + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + signals: getAllSharMoney(limit), + }); +}); + +app.get('/api/live-center', async (req, res) => { + if (!state.matches.length) { + await refreshData(); + } + const matchIds = parseMatchIds(req.query.matchId || req.query.matchIds || ''); + const refreshIntervalMs = Number(req.query.refreshMs) || 0; + const targets = matchIds.length ? matchIds : state.matches.slice(0, 24).map((m) => String(m.id)); + const out = []; + const now = Date.now(); + + for (const id of targets) { + const cache = state.liveCenterCache[id] || {}; + const match = getMatchById(id); + if (!match) continue; + const ttl = Number(cache._ts) ? now - cache._ts : Number.POSITIVE_INFINITY; + if (!cache.payload || ttl > Math.max(10000, refreshIntervalMs)) { + const payload = buildLiveCenterSnapshot(match); + state.liveCenterCache[id] = { _ts: now, payload }; + out.push(payload); + continue; + } + out.push(cache.payload); + } + + res.json({ + generatedAt: toTaipeiDateTime(Date.now()), + data: out, + }); +}); + +app.get('/api/live-center/:matchId', async (req, res) => { + if (!state.matches.length) { + await refreshData(); + } + const id = String(req.params.matchId || '').trim(); + const match = getMatchById(id); + if (!match) { + return res.status(404).json({ error: `matchId not found: ${id}` }); + } + + const payload = buildLiveCenterSnapshot(match); + state.liveCenterCache[id] = { _ts: Date.now(), payload }; + res.json({ + generatedAt: toTaipeiDateTime(Date.now()), + data: payload, + }); +}); + +app.get('/api/quantitative', async (req, res) => { + if (!state.matches.length || req.query.force === '1') { + await refreshData(); + } + const requested = parseMatchIds(req.query.matchIds || req.query.matchId || ''); + const list = requested.length + ? requested.map((id) => analyzeMatchForQuant(getMatchById(id))).filter(Boolean) + : state.matches.map((m) => analyzeMatchForQuant(m)); + + res.json({ + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + count: list.length, + items: list, + }); +}); + +app.get('/api/quantitative/:matchId', async (req, res) => { + if (!state.matches.length) { + await refreshData(); + } + const id = String(req.params.matchId || '').trim(); + const match = getMatchById(id); + if (!match) { + return res.status(404).json({ error: `matchId not found: ${id}` }); + } + const payload = analyzeMatchForQuant(match); + res.json(payload); +}); + +app.get('/api/portfolio', async (_req, res) => { + if (!state.matches.length) { + await refreshData(); + } + res.json(latestPortfolioSnapshot()); +}); + +app.post('/api/portfolio', async (req, res) => { + const parsed = normalizePortfolioPayload(req.body || {}); + if (!parsed.ok) { + return res.status(400).json({ error: parsed.reason }); + } + + state.portfolio = [ + parsed.payload, + ...state.portfolio.filter((item) => String(item.id) !== String(parsed.payload.id)), + ].slice(0, 500); + safeSavePortfolio(state.portfolio); + res.json(latestPortfolioSnapshot()); +}); + +app.get('/api/matches', async (req, res) => { + if (!state.matches.length || req.query.force === '1') { + await refreshData(); + } + res.json({ + generatedAt: state.lastUpdated, + generatedAtTaipei: state.lastUpdatedTaipei, + matches: state.matches, + source: state.source, + errors: state.errors.slice(-5), + }); +}); + +app.get('/api/analyze', async (req, res) => { + if (!state.analysis) { + await refreshData(); + } + res.json(state.analysis || { + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + perMatch: [], + topSingles: [], + professionalPlaybook: { + singles: { + all: [], + oneX2: [], + handicap: [], + totals: [], + btts: [], + doubleChance: [], + }, + parlay: { + double: { conservative: [], balanced: [], value: [] }, + triple: { conservative: [], balanced: [], value: [] }, + }, + system: [], + crossMarket: [], + overall: { + portfolio: {}, + multiLegParlay: {}, + }, + }, + doublePlay: [], + triplePlay: [], + bankrollGuide: buildBankrollGuide(), + }); +}); + +app.get('/api/schedule-comparison', async (req, res) => { + if (!state.scheduleComparison) { + await refreshData(); + } + res.json(state.scheduleComparison || { + generatedAt: new Date().toISOString(), + generatedAtTaipei: toTaipeiDateTime(Date.now()), + byDate: [], + restWarnings: [], + hotNewsWithin48h: [], + }); +}); + +app.get('/api/refresh', async (req, res) => { + const result = await refreshData(); + res.json(result); +}); + +let refreshTimer = null; +function scheduleNextRefresh(immediate = false) { + const nextMs = immediate ? 1000 : resolveRefreshIntervalMs(); + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = setTimeout(async () => { + await refreshData(); + scheduleNextRefresh(); + }, nextMs); +} + +async function bootstrap() { + await refreshData(); + scheduleNextRefresh(true); +} + +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, '../public/index.html')); +}); + +app.listen(PORT, async () => { + await bootstrap(); + console.log(`2026 FIFA World Cup odds dashboard running at http://localhost:${PORT}`); +});