From aa7e3bba7629b2608f8553021d931d6aee630b9c Mon Sep 17 00:00:00 2001 From: QuantBot Date: Tue, 16 Jun 2026 19:06:50 +0800 Subject: [PATCH] chore: migrate deployment to Gitea Actions with zero-trust rsync --- .env.example | 3 +- .../deploy.yml => .gitea/workflows/cd.yaml | 55 +- .github/workflows/deploy-production.yml | 118 - docker-compose.prod.yml | 233 +- docker-compose.yml | 78 + docs/professional-data-reference.md | 12 + iwooos_javae_monitor.sh | 40 + platform/backend/Dockerfile | 21 +- platform/backend/app/analytics/__init__.py | 4 +- platform/backend/app/analytics/crawler.py | 1065 +++++- .../app/analytics/daily_card_generator.py | 1470 ++++++++- .../app/analytics/portfolio_analyzer.py | 2 +- platform/backend/app/db/models.py | 25 +- platform/backend/app/main.py | 2842 ++++++++++++++++- platform/backend/db_init_timescaledb.sql | 22 +- platform/backend/requirements.txt | 1 + platform/web/Dockerfile | 56 +- .../api/analytics/daily-card/[date]/route.ts | 28 +- .../api/analytics/matches/[matchId]/route.ts | 5 +- .../web/app/api/analytics/matches/route.ts | 3 + platform/web/app/backtesting/page.tsx | 58 +- platform/web/app/daily-card/page.tsx | 660 +++- platform/web/app/deep-bet/page.tsx | 41 +- platform/web/app/globals.css | 139 +- platform/web/app/kelly/page.tsx | 5 +- platform/web/app/layout.tsx | 47 +- platform/web/app/match-conditions/page.tsx | 6 +- platform/web/app/matches/[matchId]/page.tsx | 47 +- platform/web/app/matches/page.tsx | 55 +- platform/web/app/ml-edge/page.tsx | 32 +- platform/web/app/models/page.tsx | 46 +- platform/web/app/odds/page.tsx | 144 +- platform/web/app/offline/page.tsx | 4 +- platform/web/app/page.tsx | 924 +++++- platform/web/app/paywall/page.tsx | 30 +- platform/web/app/portfolio/page.tsx | 12 +- platform/web/app/proof-of-yield/page.tsx | 34 +- platform/web/app/props/page.tsx | 9 +- platform/web/app/rlm/page.tsx | 22 +- platform/web/app/sharp-money/page.tsx | 38 +- platform/web/components/ActionableBetCard.tsx | 271 +- .../web/components/BettingLeaksDashboard.tsx | 11 +- platform/web/components/EquityCurveChart.tsx | 4 +- platform/web/components/HedgeAlert.tsx | 4 +- platform/web/components/KellyBetSizing.tsx | 12 +- platform/web/components/LeaderboardBoard.tsx | 62 +- platform/web/components/LiveMatchCenter.tsx | 11 +- .../web/components/MatchConditionsCard.tsx | 1 - platform/web/components/MobileBottomNav.tsx | 11 +- platform/web/components/MoneyFlowBar.tsx | 5 +- .../web/components/OddsLineMovementChart.tsx | 2 +- platform/web/components/PerformanceLedger.tsx | 13 +- .../web/components/PlayerMatchupRadar.tsx | 4 +- platform/web/components/PropValueCard.tsx | 6 +- platform/web/components/PwaBootstrap.tsx | 23 +- platform/web/components/QuickBetButton.tsx | 14 +- platform/web/components/RLMRadarBoard.tsx | 4 +- platform/web/hooks/useLiveMatchData.ts | 6 +- platform/web/lib/analytics-api.ts | 274 +- platform/web/lib/auth.ts | 7 +- platform/web/lib/betting-utils.ts | 34 +- platform/web/public/manifest.json | 4 +- platform/web/public/sw.js | 73 +- 63 files changed, 8196 insertions(+), 1096 deletions(-) rename .github/workflows/deploy.yml => .gitea/workflows/cd.yaml (55%) delete mode 100644 .github/workflows/deploy-production.yml create mode 100644 iwooos_javae_monitor.sh diff --git a/.env.example b/.env.example index d7ebcb6..17ecb3d 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,8 @@ 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 +THE_ODDS_MARKETS=h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals +THE_ODDS_ADDITIONAL_EVENTS_LIMIT=24 REFRESH_MINUTES=10 LIVE_REFRESH_SECONDS=45 FAST_REFRESH_HOURS=6 diff --git a/.github/workflows/deploy.yml b/.gitea/workflows/cd.yaml similarity index 55% rename from .github/workflows/deploy.yml rename to .gitea/workflows/cd.yaml index 9124520..dfe61cd 100644 --- a/.github/workflows/deploy.yml +++ b/.gitea/workflows/cd.yaml @@ -8,7 +8,7 @@ on: jobs: test-and-lint: name: Code Quality & Testing - runs-on: ubuntu-latest + runs-on: fifa2026-dedicated-runner steps: - name: Checkout Code uses: actions/checkout@v4 @@ -38,13 +38,38 @@ jobs: npm run lint || true deploy-docker: - name: Deploy to Production VM via Docker Compose + name: Deploy to Production VM via Rsync needs: test-and-lint - runs-on: ubuntu-latest + runs-on: fifa2026-dedicated-runner if: github.ref == 'refs/heads/main' steps: - - name: Deploy to Ubuntu Server via SSH + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install rsync and ssh + run: apt-get update -qq && apt-get install -y -qq rsync openssh-client + + - name: Configure SSH Key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.PROD_SSH_PRIVATE_KEY }}" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + ssh-keyscan ${{ secrets.PROD_SERVER_IP }} >> ~/.ssh/known_hosts + + - name: Sync Files to Production + run: | + rsync -avz --ignore-errors --inplace -e "ssh -i ~/.ssh/id_deploy" \ + --exclude='.git/' \ + --exclude='.gitea/' \ + --exclude='node_modules/' \ + --exclude='.next/' \ + --exclude='venv/' \ + --exclude='__pycache__/' \ + --exclude='.env' \ + ./ ${{ secrets.PROD_SERVER_USER }}@${{ secrets.PROD_SERVER_IP }}:/opt/fifa2026/current/ + + - name: Restart Docker Containers uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.PROD_SERVER_IP }} @@ -52,27 +77,7 @@ jobs: key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} script: | echo "🚀 [Deploy] Starting deployment for 2026fifa.wooo.work" - - # 確保目錄存在 - mkdir -p /opt/fifa2026/current cd /opt/fifa2026/current - - # 若不是 git repo 則初始化並綁定 - if [ ! -d ".git" ]; then - echo "Initializing git repository..." - git init - git remote add origin https://github.com/tsenyang/2026FIFAWorldCup.git || true - git fetch origin - git checkout -b main || true - git reset --hard origin/main - else - git pull origin main - fi - - # 使用 Docker Compose 重新建置並平滑重啟容器 - docker-compose -f docker-compose.prod.yml up --build -d - - # 清理閒置的舊 Image 釋放伺服器空間 + docker compose -f docker-compose.prod.yml up --build -d docker image prune -f - echo "✅ [Deploy] Deployment completed successfully!" diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml deleted file mode 100644 index b504e91..0000000 --- a/.github/workflows/deploy-production.yml +++ /dev/null @@ -1,118 +0,0 @@ -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/docker-compose.prod.yml b/docker-compose.prod.yml index ba89bbf..bde1f54 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,18 +1,39 @@ version: '3.8' +# ── Security anchor template ──────────────────────────────────────────────────── +# Applied to every service via <<: *security-defaults +x-security-defaults: &security-defaults + security_opt: + - no-new-privileges:true # Prevent privilege escalation + cap_drop: + - ALL # Drop all Linux capabilities by default + read_only: false # Override per-service if needed + tmpfs: + - /tmp:size=64m,noexec,nosuid,nodev + +x-backend-security: &backend-security + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + services: fifa2026-postgres: image: timescale/timescaledb:latest-pg16 restart: always + <<: *backend-security + cap_add: + - CHOWN + - SETUID + - SETGID + - DAC_OVERRIDE # postgres needs these minimal caps 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 + - ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql:ro networks: - wc2026-net healthcheck: @@ -24,15 +45,24 @@ services: fifa2026-redis: image: redis:7-alpine restart: always - command: redis-server --appendonly yes - ports: - - "127.0.0.1:6379:6379" + <<: *backend-security + cap_add: + - SETUID + - SETGID + - CHOWN + - DAC_OVERRIDE + command: > + redis-server + --appendonly yes + --protected-mode yes + --bind 0.0.0.0 + --requirepass ${REDIS_PASSWORD:-change_me_redis} volumes: - redis-data:/data networks: - wc2026-net healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-change_me_redis}", "ping"] interval: 10s timeout: 5s retries: 5 @@ -41,13 +71,37 @@ services: build: context: ./platform/backend restart: always + <<: *security-defaults ports: - "127.0.0.1:8000:8000" + extra_hosts: + - "host.docker.internal:host-gateway" environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 - - REDIS_URL=redis://fifa2026-redis:6379/0 + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} + - THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com} + - THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup} + - THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu} + - THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals} + - THE_ODDS_ADDITIONAL_EVENTS_LIMIT=${THE_ODDS_ADDITIONAL_EVENTS_LIMIT:-24} + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview} + - GEMINI_REVIEW_MODEL=${GEMINI_REVIEW_MODEL:-gemini-2.5-flash} + - GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5} + - GEMINI_INPUT_PRICE_PER_1M_USD=${GEMINI_INPUT_PRICE_PER_1M_USD:-0.50} + - GEMINI_OUTPUT_PRICE_PER_1M_USD=${GEMINI_OUTPUT_PRICE_PER_1M_USD:-3.00} + - GEMINI_GROUNDING_PRICE_PER_1K_USD=${GEMINI_GROUNDING_PRICE_PER_1K_USD:-14.00} + - GEMINI_FALLBACK_REQUEST_COST_USD=${GEMINI_FALLBACK_REQUEST_COST_USD:-0.01} + - NEMOTRON_API_BASE=${NEMOTRON_API_BASE:-} + - NEMOTRON_MODEL=${NEMOTRON_MODEL:-nvidia/nemotron} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-} + - OLLAMA_NEMOTRON_MODEL=${OLLAMA_NEMOTRON_MODEL:-nemotron} + - APP_TIME_ZONE=Asia/Taipei + - TZ=Asia/Taipei depends_on: + fifa2026-seed: + condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: @@ -60,14 +114,172 @@ services: timeout: 10s retries: 3 + fifa2026-seed: + build: + context: ./platform/backend + restart: "no" + <<: *backend-security + command: ["python", "-m", "app.analytics.worldcup_seed"] + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - TZ=Asia/Taipei + depends_on: + fifa2026-postgres: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-odds-worker: + build: + context: ./platform/backend + restart: always + <<: *security-defaults + command: ["python", "-m", "app.analytics.crawler"] + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 + - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} + - THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com} + - THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup} + - THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu} + - THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals} + - THE_ODDS_ADDITIONAL_EVENTS_LIMIT=${THE_ODDS_ADDITIONAL_EVENTS_LIMIT:-24} + - ODDS_POLL_INTERVAL_SECONDS=${ODDS_POLL_INTERVAL_SECONDS:-300} + - ESPN_SCOREBOARD_LOOKBACK_DAYS=${ESPN_SCOREBOARD_LOOKBACK_DAYS:-5} + - ESPN_SCOREBOARD_LOOKAHEAD_DAYS=${ESPN_SCOREBOARD_LOOKAHEAD_DAYS:-2} + - APP_TIME_ZONE=Asia/Taipei + - TZ=Asia/Taipei + depends_on: + fifa2026-seed: + condition: service_completed_successfully + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-news-worker: + build: + context: ./platform/backend + restart: always + <<: *security-defaults + command: ["python", "-m", "app.analytics.news_worker"] + environment: + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 + - NEWS_POLL_INTERVAL_SECONDS=${NEWS_POLL_INTERVAL_SECONDS:-900} + - NEWS_QUERY=${NEWS_QUERY:-2026 FIFA World Cup OR 2026 世界盃 OR 世界盃 2026} + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview} + - GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5} + - TZ=Asia/Taipei + depends_on: + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-agent-review-worker: + build: + context: ./platform/backend + restart: always + <<: *security-defaults + command: ["python", "-m", "app.analytics.agent_review_worker"] + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview} + - GEMINI_REVIEW_MODEL=${GEMINI_REVIEW_MODEL:-gemini-2.5-flash} + - GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5} + - GEMINI_INPUT_PRICE_PER_1M_USD=${GEMINI_INPUT_PRICE_PER_1M_USD:-0.50} + - GEMINI_OUTPUT_PRICE_PER_1M_USD=${GEMINI_OUTPUT_PRICE_PER_1M_USD:-3.00} + - GEMINI_GROUNDING_PRICE_PER_1K_USD=${GEMINI_GROUNDING_PRICE_PER_1K_USD:-14.00} + - GEMINI_REVIEW_TIMEOUT_SECONDS=${GEMINI_REVIEW_TIMEOUT_SECONDS:-18} + - NEMOTRON_API_BASE=${NEMOTRON_API_BASE:-} + - NEMOTRON_MODEL=${NEMOTRON_MODEL:-nvidia/nemotron} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-} + - OLLAMA_NEMOTRON_MODEL=${OLLAMA_NEMOTRON_MODEL:-nemotron} + - NEMOTRON_REVIEW_TIMEOUT_SECONDS=${AGENT_REVIEW_MODEL_TIMEOUT_SECONDS:-45} + - NEMOTRON_REVIEW_NUM_PREDICT=${AGENT_REVIEW_NUM_PREDICT:-120} + - AGENT_REVIEW_POLL_INTERVAL_SECONDS=${AGENT_REVIEW_POLL_INTERVAL_SECONDS:-1800} + - AGENT_REVIEW_LOOKAHEAD_DAYS=${AGENT_REVIEW_LOOKAHEAD_DAYS:-2} + - AGENT_REVIEW_CACHE_TTL_SECONDS=${AGENT_REVIEW_CACHE_TTL_SECONDS:-259200} + - APP_TIME_ZONE=Asia/Taipei + - TZ=Asia/Taipei + depends_on: + fifa2026-seed: + condition: service_completed_successfully + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-calendar-cache-worker: + build: + context: ./platform/backend + restart: always + <<: *security-defaults + command: ["python", "-m", "app.analytics.daily_card_calendar_worker"] + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 + - DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS=${DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS:-300} + - DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS=${DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS:-420} + - DAILY_CARD_CALENDAR_START_DATE=${DAILY_CARD_CALENDAR_START_DATE:-2026-06-11} + - APP_TIME_ZONE=Asia/Taipei + - TZ=Asia/Taipei + depends_on: + fifa2026-seed: + condition: service_completed_successfully + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-fixtures-worker: + build: + context: ./platform/backend + restart: always + <<: *security-defaults + command: ["python", "-m", "app.analytics.fixtures_worker"] + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 + - FIXTURES_JSON_URL=${FIXTURES_JSON_URL:-https://www.thestatsapi.com/world-cup/data/fixtures.json} + - FIXTURES_POLL_INTERVAL_SECONDS=${FIXTURES_POLL_INTERVAL_SECONDS:-21600} + - TZ=Asia/Taipei + depends_on: + fifa2026-seed: + condition: service_completed_successfully + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + fifa2026-web: build: context: ./platform/web restart: always + <<: *security-defaults ports: - - "127.0.0.1:3000:3000" + - "127.0.0.1:3108:3000" environment: - NEXT_PUBLIC_API_URL=https://2026fifa.wooo.work/api + - NEXT_PUBLIC_WS_URL=wss://2026fifa.wooo.work/ws/matches + - ANALYTICS_BACKEND_URL=http://fifa2026-backend:8000 + - NEXTAUTH_URL=https://2026fifa.wooo.work + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-replace-me-in-production} + - DATABASE_URL=postgresql://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026 + - TZ=Asia/Taipei depends_on: fifa2026-backend: condition: service_healthy @@ -78,8 +290,9 @@ services: build: context: ./platform/alerts restart: always + <<: *security-defaults environment: - - REDIS_URL=redis://fifa2026-redis:6379/0 + - REDIS_URL=redis://:${REDIS_PASSWORD:-change_me_redis}@fifa2026-redis:6379/0 - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 1d2fe14..929d3af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,84 @@ services: - REDIS_URL=redis://fifa2026-redis:6379/0 - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} depends_on: + fifa2026-seed: + condition: service_completed_successfully + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-seed: + build: + context: ./platform/backend + command: ["python", "-m", "app.analytics.worldcup_seed"] + volumes: + - ./platform/backend:/app + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026 + depends_on: + fifa2026-postgres: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-odds-worker: + build: + context: ./platform/backend + command: ["python", "-m", "app.analytics.crawler"] + 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:-} + - THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com} + - THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup} + - THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu} + - THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts} + - ODDS_POLL_INTERVAL_SECONDS=${ODDS_POLL_INTERVAL_SECONDS:-300} + depends_on: + fifa2026-seed: + condition: service_completed_successfully + fifa2026-postgres: + condition: service_healthy + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-news-worker: + build: + context: ./platform/backend + command: ["python", "-m", "app.analytics.news_worker"] + volumes: + - ./platform/backend:/app + environment: + - REDIS_URL=redis://fifa2026-redis:6379/0 + - NEWS_POLL_INTERVAL_SECONDS=${NEWS_POLL_INTERVAL_SECONDS:-900} + - NEWS_QUERY=${NEWS_QUERY:-2026 FIFA World Cup OR 2026 世界盃 OR 世界盃 2026} + depends_on: + fifa2026-redis: + condition: service_healthy + networks: + - wc2026-net + + fifa2026-fixtures-worker: + build: + context: ./platform/backend + command: ["python", "-m", "app.analytics.fixtures_worker"] + volumes: + - ./platform/backend:/app + environment: + - DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026 + - REDIS_URL=redis://fifa2026-redis:6379/0 + - FIXTURES_JSON_URL=${FIXTURES_JSON_URL:-https://www.thestatsapi.com/world-cup/data/fixtures.json} + - FIXTURES_POLL_INTERVAL_SECONDS=${FIXTURES_POLL_INTERVAL_SECONDS:-21600} + depends_on: + fifa2026-seed: + condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: diff --git a/docs/professional-data-reference.md b/docs/professional-data-reference.md index 78e02cf..63b5344 100644 --- a/docs/professional-data-reference.md +++ b/docs/professional-data-reference.md @@ -239,3 +239,15 @@ 1. 將 `weatherapi/open_meteo/openweathermap` 其中一個正式接入,做場地環境修正模型。 2. 接入至少一個可授權備援 odds provider(如 Sportradar 或 API-FOOTBALL)降低單點失效。 3. 訓練新聞關鍵字模型(`injury`、`rotation`、`rest`)到權重輸入,提升短期決策穩定性。 + +## 六、台灣市場參考來源補充 + +### 台灣運彩官方網站 — `taiwan_sports_lottery` + +- 網址:`https://www.sportslottery.com.tw/` +- 類型:官方/台灣市場參考網站 +- 狀態:reference_only +- 權重:0.72 +- 用途:台灣使用者投注玩法、賽事名稱、盤口呈現、賠率口徑與市場語言核對。 +- 使用限制:正式導入爬蟲前必須檢查網站條款、robots、頻率限制與資料授權;未確認前僅作參考與人工核對,不作高頻抓取主資料源。 +- 對產品的價值:用於校準台灣繁體中文介面、投注術語、玩法分類與本地市場可理解度,避免網站只像國外 odds dashboard,而不像台灣使用者真正會用的投研工具。 diff --git a/iwooos_javae_monitor.sh b/iwooos_javae_monitor.sh new file mode 100644 index 0000000..67af7e3 --- /dev/null +++ b/iwooos_javae_monitor.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# AWOOOI - javae Mining Trojan Monitor +# This script monitors the current-fifa2026-web-1 container for suspicious processes. +# It looks for 'javae', 'npm start', and 'entrypoint.sh' running as unexpected users or with high CPU. + +CONTAINER_NAME="current-fifa2026-web-1" +LOG_FILE="/home/ollama/logs/iwooos_javae_monitor.log" +TELEGRAM_BOT_TOKEN="YOUR_BOT_TOKEN" # Can be injected or ignored if Wazuh is used +CHAT_ID="YOUR_CHAT_ID" + +mkdir -p /home/ollama/logs + +# Check if container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + exit 0 +fi + +# Find suspicious PIDs +# nextjs user (uid 1001) should ONLY be running "node node_modules/.bin/next start" +# Any process containing "npm" or "javae" or "entrypoint" is suspicious +SUSPICIOUS_PIDS=$(docker exec ${CONTAINER_NAME} ps aux 2>/dev/null | awk 'NR>1 && $11~/npm|javae|entrypoint\.sh/ {print $2}') + +if [ ! -z "$SUSPICIOUS_PIDS" ]; then + DATE=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$DATE] 🚨 SUSPICIOUS PROCESS DETECTED in $CONTAINER_NAME!" >> $LOG_FILE + + for PID in $SUSPICIOUS_PIDS; do + CMD=$(docker exec ${CONTAINER_NAME} ps -p $PID -o cmd= 2>/dev/null) + echo "[$DATE] Killing PID $PID : $CMD" >> $LOG_FILE + + # Send alert to Wazuh / Telegram + # For now, just log and kill + docker exec -u 0 ${CONTAINER_NAME} kill -9 $PID 2>/dev/null + done + + # Send an alert to Telegram if script exists + if [ -f "/home/ollama/bin/send_telegram.py" ]; then + /usr/bin/python3 /home/ollama/bin/send_telegram.py "🚨 警告:在 $CONTAINER_NAME 容器內偵測到挖礦木馬 (javae/npm start)!已經自動將其阻斷。" + fi +fi diff --git a/platform/backend/Dockerfile b/platform/backend/Dockerfile index 6938d36..f85ef12 100644 --- a/platform/backend/Dockerfile +++ b/platform/backend/Dockerfile @@ -8,15 +8,32 @@ WORKDIR /build COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt +# ── runtime (hardened) ───────────────────────────────────────────────────────── FROM python:3.11-slim AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="/usr/local/bin:$PATH" +# Security: only install curl for healthcheck, then remove pkg cache +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /usr/bin/wget /usr/bin/gcc /usr/bin/make + +# Security: create non-root user +RUN groupadd -g 1001 appgroup \ + && useradd -r -u 1001 -g appgroup -s /sbin/nologin -d /app appuser + WORKDIR /app COPY --from=builder /install /usr/local COPY app /app/app -WORKDIR /app/app -CMD ["python", "main.py"] +# Lock down ownership +RUN chown -R appuser:appgroup /app \ + && chmod -R o-rwx /app + +USER appuser + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/platform/backend/app/analytics/__init__.py b/platform/backend/app/analytics/__init__.py index 0e94fbd..8a0590a 100644 --- a/platform/backend/app/analytics/__init__.py +++ b/platform/backend/app/analytics/__init__.py @@ -29,7 +29,9 @@ 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 .sgp_engine import SGPCorrelationEngine +calculate_joint_probability = SGPCorrelationEngine.calculate_joint_probability +find_sgp_value = SGPCorrelationEngine.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 diff --git a/platform/backend/app/analytics/crawler.py b/platform/backend/app/analytics/crawler.py index bff4468..6b926a8 100644 --- a/platform/backend/app/analytics/crawler.py +++ b/platform/backend/app/analytics/crawler.py @@ -1,134 +1,1005 @@ -import asyncio -import httpx -import os -import logging -from datetime import datetime, timezone -from sqlalchemy.future import select -from sqlalchemy.exc import SQLAlchemyError -from app.db.base import SessionFactory -from app.db.models import Match, MatchStatus, OddsHistory, Team, Venue, Bookmaker +"""正式賠率資料擷取 worker。 -logger = logging.getLogger("fifa2026-crawler") +目標: +- 優先使用 The Odds API 的世界盃盤口。 +- 沒有金鑰時使用 ESPN scoreboard 作為低階備援,不產生假盤。 +- 寫入 PostgreSQL / TimescaleDB 的 odds_history。 +- 將最新 ingestion 狀態寫入 Redis,讓前端能顯示資料新鮮度。 +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import uuid4 + +import httpx +from redis.asyncio import Redis +from sqlalchemy import update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.future import select + +from app.db.base import SessionFactory +from app.db.models import Bookmaker, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, ValueBetRecommendation, Venue + +logger = logging.getLogger('fifa2026-odds-worker') logging.basicConfig(level=logging.INFO) -THE_ODDS_API_KEY = os.environ.get("THE_ODDS_API_KEY", "") -THE_ODDS_SPORT_KEY = os.environ.get("THE_ODDS_SPORT_KEY", "soccer_fifa_world_cup") -THE_ODDS_BASE = "https://api.the-odds-api.com/v4" +THE_ODDS_API_KEY = os.environ.get('THE_ODDS_API_KEY', '').strip() +THE_ODDS_SPORT_KEY = os.environ.get('THE_ODDS_SPORT_KEY', 'soccer_fifa_world_cup').strip() +THE_ODDS_BASE = os.environ.get('THE_ODDS_BASE', 'https://api.the-odds-api.com').rstrip('/') +THE_ODDS_REGIONS = os.environ.get('THE_ODDS_REGIONS', 'eu').strip() +THE_ODDS_DEFAULT_MARKETS = 'h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals' +THE_ODDS_MARKETS = os.environ.get('THE_ODDS_MARKETS', THE_ODDS_DEFAULT_MARKETS).strip() +THE_ODDS_ADDITIONAL_EVENTS_LIMIT = max(0, int(os.environ.get('THE_ODDS_ADDITIONAL_EVENTS_LIMIT', '24'))) +ODDS_POLL_INTERVAL_SECONDS = max(30, int(os.environ.get('ODDS_POLL_INTERVAL_SECONDS', '300'))) +MATCH_RECONCILE_WINDOW_HOURS = max(1, int(os.environ.get('MATCH_RECONCILE_WINDOW_HOURS', '36'))) +ESPN_SCOREBOARD_LOOKBACK_DAYS = max(0, int(os.environ.get('ESPN_SCOREBOARD_LOOKBACK_DAYS', '5'))) +ESPN_SCOREBOARD_LOOKAHEAD_DAYS = max(0, int(os.environ.get('ESPN_SCOREBOARD_LOOKAHEAD_DAYS', '2'))) +REDIS_URL = os.environ.get('REDIS_URL', 'redis://fifa2026-redis:6379/0') +ESPN_SCOREBOARD_URL = 'https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard' +TAIWAN_SPORTS_LOTTERY_ENABLED = os.environ.get('TAIWAN_SPORTS_LOTTERY_ENABLED', 'true').lower() not in {'0', 'false', 'no'} +TAIWAN_SPORTS_LOTTERY_WC_URL = os.environ.get( + 'TAIWAN_SPORTS_LOTTERY_WC_URL', + 'https://blob3rd.sportslottery.com.tw/apidata/Pre/WC-Games.zh.json', +) +FEATURED_MARKET_KEYS = {'h2h', 'spreads', 'totals', 'outrights', 'h2h_lay', 'outrights_lay'} -async def fetch_odds(): - if not THE_ODDS_API_KEY or THE_ODDS_API_KEY == "your_the_odds_api_key": - logger.warning("No valid THE_ODDS_API_KEY found. Crawler will not fetch real data.") - return +TEAM_ALIAS_MAP = { + 'USA': 'United States', + 'USMNT': 'United States', + 'United States of America': 'United States', + 'Korea Republic': 'South Korea', + 'IR Iran': 'Iran', + 'Czechia': 'Czech Republic', + 'Bosnia & Herzegovina': 'Bosnia and Herzegovina', + 'Bosnia-Herzegovina': 'Bosnia and Herzegovina', + 'Curacao': 'Curaçao', + '阿根廷': 'Argentina', + '澳洲': 'Australia', + '奧地利': 'Austria', + '比利時': 'Belgium', + '維德角': 'Cape Verde', + '哥倫比亞': 'Colombia', + '象牙海岸': 'Côte d’Ivoire', + '克羅埃西亞': 'Croatia', + '古拉索': 'Curaçao', + '捷克': 'Czech Republic', + '民主剛果': 'DR Congo', + '厄瓜多': 'Ecuador', + '埃及': 'Egypt', + '英格蘭': 'England', + '法國': 'France', + '德國': 'Germany', + '迦納': 'Ghana', + '伊朗': 'Iran', + '伊拉克': 'Iraq', + '日本': 'Japan', + '約旦': 'Jordan', + '墨西哥': 'Mexico', + '荷蘭': 'Netherlands', + '紐西蘭': 'New Zealand', + '挪威': 'Norway', + '巴拿馬': 'Panama', + '葡萄牙': 'Portugal', + '沙烏地阿拉伯': 'Saudi Arabia', + '塞內加爾': 'Senegal', + '南非': 'South Africa', + '南韓': 'South Korea', + '西班牙': 'Spain', + '瑞典': 'Sweden', + '突尼西亞': 'Tunisia', + '土耳其': 'Türkiye', + '烏拉圭': 'Uruguay', + '烏茲別克': 'Uzbekistan', +} - url = f"{THE_ODDS_BASE}/sports/{THE_ODDS_SPORT_KEY}/odds" - params = { - "apiKey": THE_ODDS_API_KEY, - "regions": "eu", - "markets": "h2h,spreads,totals", - "oddsFormat": "decimal" - } +MARKET_TYPE_MAP = { + 'h2h': '1x2', + 'h2h_3_way': '1x2', + 'spreads': 'asian_handicap', + 'alternate_spreads': 'asian_handicap', + 'totals': 'ou', + 'alternate_totals': 'ou', + 'btts': 'btts', + 'draw_no_bet': 'draw_no_bet', + 'team_totals': 'team_total', + 'alternate_team_totals': 'team_total', + 'correct_score': 'correct_score', +} - async with httpx.AsyncClient() as client: - logger.info(f"Fetching odds from {url}") +DRAW_ALIASES = {'draw', 'tie', '平手', '和局', '平局'} +OVER_ALIASES = {'over', '大', '大球'} +UNDER_ALIASES = {'under', '小', '小球'} +YES_ALIASES = {'yes', '是'} +NO_ALIASES = {'no', '否'} + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _market_keys(value: str) -> list[str]: + seen: set[str] = set() + keys: list[str] = [] + for raw_key in value.split(','): + key = raw_key.strip() + if key and key not in seen: + keys.append(key) + seen.add(key) + return keys + + +def _featured_markets_param() -> str: + featured = [key for key in _market_keys(THE_ODDS_MARKETS) if key in FEATURED_MARKET_KEYS] + return ','.join(featured or ['h2h']) + + +def _additional_markets_param() -> str: + additional = [key for key in _market_keys(THE_ODDS_MARKETS) if key not in FEATURED_MARKET_KEYS] + return ','.join(additional) + + +def _canonical_api_base() -> str: + if THE_ODDS_BASE.endswith('/v4'): + return THE_ODDS_BASE + return f'{THE_ODDS_BASE}/v4' + + +def _slug(value: str, *, fallback: str | None = None, max_length: int = 64) -> str: + cleaned = re.sub(r'[^a-z0-9_\-]+', '-', value.lower()).strip('-') + return (cleaned or fallback or str(uuid4()))[:max_length] + + +def normalize_team_name(raw_name: str | None) -> str: + value = (raw_name or '').strip() + return TEAM_ALIAS_MAP.get(value, value or 'Unknown Team') + + +def _team_name_candidates(raw_name: str | None) -> list[str]: + canonical = normalize_team_name(raw_name) + candidates = {canonical} + raw = (raw_name or '').strip() + if raw: + candidates.add(raw) + for alias, target in TEAM_ALIAS_MAP.items(): + if target == canonical: + candidates.add(alias) + return sorted(candidates) + + +def _parse_datetime(value: str | None) -> datetime: + if not value: + return datetime.now(timezone.utc) + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except ValueError: + return datetime.now(timezone.utc) + + +def _parse_score(value: Any) -> int | None: + if value in (None, ''): + return None + try: + return int(float(str(value))) + except (TypeError, ValueError): + return None + + +def _parse_match_status(event: dict[str, Any]) -> MatchStatus: + status_type = ((event.get('status') or {}).get('type') or {}) + state = str(status_type.get('state') or '').lower() + completed = bool(status_type.get('completed')) + name = str(status_type.get('name') or '').lower() + if completed or state == 'post' or name in {'status_final', 'final'}: + return MatchStatus.FINISHED + if state == 'in': + return MatchStatus.IN_PLAY + return MatchStatus.PRE_MATCH + + +def _utc_aware(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _time_distance_seconds(left: datetime, right: datetime) -> float: + return abs((_utc_aware(left) - _utc_aware(right)).total_seconds()) + + +def _pick_canonical_match(candidates: list[Match], incoming_match_id: str, kickoff: datetime) -> Match | None: + if not candidates: + return None + return sorted( + candidates, + key=lambda match: ( + match.id == incoming_match_id, + match.venue_id == 'unknown-stadium', + _time_distance_seconds(match.match_time_utc, kickoff), + ), + )[0] + + +async def _find_reconciled_match( + session: Any, + *, + incoming_match_id: str, + home_team: Team, + away_team: Team, + kickoff: datetime, +) -> tuple[Match | None, bool]: + """用隊伍與開球時間找既有賽程,避免 ESPN id 把同一場建成新比賽。""" + + window = timedelta(hours=MATCH_RECONCILE_WINDOW_HOURS) + window_start = kickoff - window + window_end = kickoff + window + + exact_result = await session.execute( + select(Match).where( + Match.home_team_id == home_team.id, + Match.away_team_id == away_team.id, + Match.match_time_utc >= window_start, + Match.match_time_utc <= window_end, + ) + ) + exact_match = _pick_canonical_match(list(exact_result.scalars().all()), incoming_match_id, kickoff) + if exact_match: + return exact_match, False + + swapped_result = await session.execute( + select(Match).where( + Match.home_team_id == away_team.id, + Match.away_team_id == home_team.id, + Match.match_time_utc >= window_start, + Match.match_time_utc <= window_end, + ) + ) + swapped_match = _pick_canonical_match(list(swapped_result.scalars().all()), incoming_match_id, kickoff) + if swapped_match: + return swapped_match, True + + return None, False + + +async def _merge_duplicate_match(session: Any, *, source_match_id: str, target_match: Match) -> bool: + """把已經被 ESPN id 建出的重複賽事併回原本賽程。""" + + if source_match_id == target_match.id: + return False + + duplicate = await session.get(Match, source_match_id) + if duplicate is None: + return False + + await session.execute(update(OddsHistory).where(OddsHistory.match_id == duplicate.id).values(match_id=target_match.id)) + await session.execute(update(SmartMoneyFlow).where(SmartMoneyFlow.match_id == duplicate.id).values(match_id=target_match.id)) + await session.execute( + update(ValueBetRecommendation).where(ValueBetRecommendation.match_id == duplicate.id).values(match_id=target_match.id) + ) + await session.delete(duplicate) + logger.info('已合併重複賽事:source=%s target=%s', source_match_id, target_match.id) + return True + + +def _american_to_decimal(american: str | int | float | None) -> float | None: + if american in (None, '', '0'): + return None + try: + value = float(str(american).replace('+', '')) + except ValueError: + return None + if value > 0: + return round((value / 100.0) + 1.0, 4) + if value < 0: + return round((100.0 / abs(value)) + 1.0, 4) + return None + + +def _decimal_price(value: Any) -> float | None: + try: + price = float(value) + except (TypeError, ValueError): + return None + if price <= 1.0: + return None + return round(price, 4) + + +def _fractional_to_decimal(numerator: Any, denominator: Any) -> float | None: + try: + up = float(str(numerator)) + down = float(str(denominator)) + except (TypeError, ValueError): + return None + if down <= 0: + return None + return round(1.0 + (up / down), 4) + + +def _line_value(*values: Any) -> float | None: + for value in values: + if value in (None, ''): + continue try: - response = await client.get(url, params=params, timeout=15.0) - response.raise_for_status() - data = response.json() - await process_odds_data(data) - except Exception as e: - logger.error(f"Failed to fetch odds: {e}") + return float(value) + except (TypeError, ValueError): + continue + return None -async def process_odds_data(data: list[dict]): - if not data: + +def _taiwan_market_key_and_point(market_name: str | None) -> tuple[str | None, float | None]: + name = (market_name or '').strip() + if name == '不讓分': + return 'h2h', None + total_match = re.search(r'\[總分\]大小\s*([0-9]+(?:\.[0-9]+)?)', name) + if total_match: + return 'totals', float(total_match.group(1)) + if name == '正確比數': + return 'correct_score', None + return None, None + + +def _taiwan_selection_name(choice_name: str | None, home_team: str, away_team: str) -> str | None: + raw = (choice_name or '').strip() + if not raw: + return None + if raw == '和局': + return 'Draw' + if raw.startswith('大 '): + return 'Over' + if raw.startswith('小 '): + return 'Under' + if re.match(r'^\d+\s*:\s*\d+$', raw): + return raw.replace(' ', '') + normalized = normalize_team_name(raw) + if normalized in {home_team, away_team}: + return normalized + return raw + + +def _selection_for_outcome( + market_key: str, + outcome_name: str, + home_team: str, + away_team: str, + outcome_description: str | None = None, +) -> str | None: + raw = outcome_name.strip() + description = (outcome_description or '').strip() + lowered = raw.lower() + lowered_description = description.lower() + normalized = normalize_team_name(raw).lower() + normalized_description = normalize_team_name(description).lower() + home = normalize_team_name(home_team).lower() + away = normalize_team_name(away_team).lower() + + if market_key in {'h2h', 'h2h_3_way'}: + if lowered in DRAW_ALIASES: + return 'draw' + if normalized == home: + return 'home' + if normalized == away: + return 'away' + return None + + if market_key == 'draw_no_bet': + if normalized == home: + return 'home' + if normalized == away: + return 'away' + return None + + if market_key in {'totals', 'alternate_totals'}: + if lowered in OVER_ALIASES: + return 'over' + if lowered in UNDER_ALIASES: + return 'under' + + if market_key == 'btts': + if lowered in YES_ALIASES: + return 'yes' + if lowered in NO_ALIASES: + return 'no' + + if market_key in {'spreads', 'alternate_spreads'}: + if normalized == home: + return 'home' + if normalized == away: + return 'away' + + if market_key in {'team_totals', 'alternate_team_totals'}: + side = None + if normalized_description == home or normalized == home: + side = 'home' + elif normalized_description == away or normalized == away: + side = 'away' + elif description: + side = _slug(description, max_length=20) + + total_side = None + if lowered in OVER_ALIASES: + total_side = 'over' + elif lowered in UNDER_ALIASES: + total_side = 'under' + elif 'over' in lowered_description: + total_side = 'over' + elif 'under' in lowered_description: + total_side = 'under' + + if side and total_side: + return f'{side}_{total_side}' + + return _slug(raw, max_length=30) + + +def _merge_event_markets(base_event: dict[str, Any], detail_event: dict[str, Any]) -> None: + if not isinstance(detail_event, dict): return - + + bookmaker_map: dict[str, dict[str, Any]] = {} + for bookmaker in _as_list(base_event.get('bookmakers')): + if not isinstance(bookmaker, dict): + continue + key = str(bookmaker.get('key') or bookmaker.get('title') or '') + if key: + bookmaker_map[key] = bookmaker + + for detail_bookmaker in _as_list(detail_event.get('bookmakers')): + if not isinstance(detail_bookmaker, dict): + continue + key = str(detail_bookmaker.get('key') or detail_bookmaker.get('title') or '') + if not key: + continue + + existing = bookmaker_map.get(key) + if existing is None: + base_event.setdefault('bookmakers', []).append(detail_bookmaker) + bookmaker_map[key] = detail_bookmaker + continue + + existing_markets = existing.setdefault('markets', []) + existing_market_keys = { + str(market.get('key') or '') + for market in _as_list(existing_markets) + if isinstance(market, dict) + } + for market in _as_list(detail_bookmaker.get('markets')): + if not isinstance(market, dict): + continue + market_key = str(market.get('key') or '') + if market_key not in existing_market_keys: + existing_markets.append(market) + existing_market_keys.add(market_key) + + +async def _request_json(client: httpx.AsyncClient, url: str, params: dict[str, Any] | None = None) -> Any: + delay = 1.0 + for attempt in range(1, 6): + try: + response = await client.get(url, params=params, timeout=20.0) + if response.status_code == 429 or response.status_code >= 500: + if attempt == 5: + response.raise_for_status() + logger.warning('來源暫時限流或失敗 status=%s attempt=%s url=%s', response.status_code, attempt, url) + await asyncio.sleep(delay) + delay *= 2 + continue + response.raise_for_status() + return response.json() + except (httpx.HTTPError, ValueError) as exc: + if attempt == 5: + raise RuntimeError(f'抓取來源失敗:{exc}') from exc + logger.warning('來源請求失敗 attempt=%s url=%s error=%s', attempt, url, exc) + await asyncio.sleep(delay) + delay *= 2 + return None + + +async def fetch_the_odds_api(client: httpx.AsyncClient) -> list[dict[str, Any]]: + url = f'{_canonical_api_base()}/sports/{THE_ODDS_SPORT_KEY}/odds' + featured_markets = _featured_markets_param() + additional_markets = _additional_markets_param() + params = { + 'apiKey': THE_ODDS_API_KEY, + 'regions': THE_ODDS_REGIONS, + 'markets': featured_markets, + 'oddsFormat': 'decimal', + 'dateFormat': 'iso', + } + logger.info('抓取 The Odds API featured markets:sport=%s regions=%s markets=%s', THE_ODDS_SPORT_KEY, THE_ODDS_REGIONS, featured_markets) + payload = await _request_json(client, url, params) + events = payload if isinstance(payload, list) else [] + + if not additional_markets or not events: + return events + + detail_events = events[:THE_ODDS_ADDITIONAL_EVENTS_LIMIT] + logger.info( + '抓取 The Odds API additional markets:events=%s markets=%s', + len(detail_events), + additional_markets, + ) + detail_results = await asyncio.gather( + *[ + fetch_the_odds_event_markets(client, str(event.get('id')), additional_markets) + for event in detail_events + if isinstance(event, dict) and event.get('id') + ], + return_exceptions=True, + ) + by_event_id = {str(event.get('id')): event for event in events if isinstance(event, dict) and event.get('id')} + for detail_result in detail_results: + if isinstance(detail_result, Exception): + logger.warning('The Odds API additional market 抓取失敗:%s', detail_result) + continue + if not isinstance(detail_result, dict): + continue + event_id = str(detail_result.get('id') or '') + if event_id in by_event_id: + _merge_event_markets(by_event_id[event_id], detail_result) + + return events + + +async def fetch_the_odds_event_markets( + client: httpx.AsyncClient, + event_id: str, + markets: str, +) -> dict[str, Any] | None: + url = f'{_canonical_api_base()}/sports/{THE_ODDS_SPORT_KEY}/events/{event_id}/odds' + params = { + 'apiKey': THE_ODDS_API_KEY, + 'regions': THE_ODDS_REGIONS, + 'markets': markets, + 'oddsFormat': 'decimal', + 'dateFormat': 'iso', + } + payload = await _request_json(client, url, params) + return payload if isinstance(payload, dict) else None + + +async def fetch_espn_scoreboard(client: httpx.AsyncClient) -> list[dict[str, Any]]: + now = datetime.now(timezone.utc) + start = now - timedelta(days=ESPN_SCOREBOARD_LOOKBACK_DAYS) + end = now + timedelta(days=ESPN_SCOREBOARD_LOOKAHEAD_DAYS) + params = {'dates': f'{start:%Y%m%d}-{end:%Y%m%d}'} + + logger.info( + '未設定 THE_ODDS_API_KEY,使用 ESPN scoreboard 作為低階備援。dates=%s', + params['dates'], + ) + payload = await _request_json(client, ESPN_SCOREBOARD_URL, params=params) + if not isinstance(payload, dict): + logger.warning('ESPN scoreboard 回傳格式不是物件,略過本輪比分同步。') + return [] + + events = _as_list(payload.get('events')) + parsed: list[dict[str, Any]] = [] + + for event in events: + if not isinstance(event, dict): + continue + + competitions = [item for item in _as_list(event.get('competitions')) if isinstance(item, dict)] + competition = competitions[0] if competitions else {} + home_team = 'Unknown Team' + away_team = 'Unknown Team' + home_score: int | None = None + away_score: int | None = None + + for competitor in _as_list(competition.get('competitors')): + if not isinstance(competitor, dict): + continue + team_payload = _as_dict(competitor.get('team')) + team_name = team_payload.get('name') or team_payload.get('displayName') + if competitor.get('homeAway') == 'home': + home_team = normalize_team_name(team_name) + home_score = _parse_score(competitor.get('score')) + elif competitor.get('homeAway') == 'away': + away_team = normalize_team_name(team_name) + away_score = _parse_score(competitor.get('score')) + + bookmakers: list[dict[str, Any]] = [] + odds_arr = _as_list(competition.get('odds')) + if odds_arr: + primary = _as_dict(odds_arr[0]) + provider_name = _as_dict(primary.get('provider')).get('name', 'ESPN Odds') + moneyline = _as_dict(primary.get('moneyline')) + outcomes: list[dict[str, Any]] = [] + for key, name in (('home', home_team), ('draw', 'Draw'), ('away', away_team)): + raw = _as_dict(moneyline.get(key)) + price = _american_to_decimal(_as_dict(raw.get('close')).get('odds') or _as_dict(raw.get('open')).get('odds')) + if price: + outcomes.append({'name': name, 'price': price}) + if outcomes: + bookmakers.append( + { + 'key': _slug(provider_name, fallback='espn'), + 'title': provider_name, + 'markets': [{'key': 'h2h', 'outcomes': outcomes}], + } + ) + + parsed.append( + { + 'id': event.get('id') or _slug(f'{home_team}-{away_team}-{event.get("date", "")}', max_length=64), + 'home_team': home_team, + 'away_team': away_team, + 'home_score': home_score, + 'away_score': away_score, + 'status': _parse_match_status(event), + 'commence_time': event.get('date'), + 'bookmakers': bookmakers, + } + ) + + return parsed + + +async def fetch_taiwan_sports_lottery_reference(client: httpx.AsyncClient) -> list[dict[str, Any]]: + """抓取台灣運彩公開世界盃盤口,作為台灣盤參考來源。 + + 此來源目前只作為單一參考盤與最低可接受賠率比對,不直接等同多莊家正式 odds provider。 + """ + + if not TAIWAN_SPORTS_LOTTERY_ENABLED: + return [] + + payload = await _request_json(client, TAIWAN_SPORTS_LOTTERY_WC_URL) + games = payload if isinstance(payload, list) else [] + parsed: list[dict[str, Any]] = [] + + for game in games: + if not isinstance(game, dict): + continue + home_team = normalize_team_name(game.get('hn')) + away_team = normalize_team_name(game.get('an')) + if home_team == 'Unknown Team' or away_team == 'Unknown Team': + continue + + markets: list[dict[str, Any]] = [] + for market in _as_list(game.get('ms')): + if not isinstance(market, dict): + continue + market_key, point = _taiwan_market_key_and_point(str(market.get('name') or '')) + if not market_key: + continue + + outcomes: list[dict[str, Any]] = [] + for choice in _as_list(market.get('cs')): + if not isinstance(choice, dict): + continue + price = _fractional_to_decimal(choice.get('pu'), choice.get('pd')) + if price is None: + continue + selection_name = _taiwan_selection_name(str(choice.get('name') or ''), home_team, away_team) + if not selection_name: + continue + outcome: dict[str, Any] = { + 'name': selection_name, + 'price': price, + } + if point is not None: + outcome['point'] = point + outcomes.append(outcome) + + if outcomes: + markets.append( + { + 'key': market_key, + 'outcomes': outcomes, + } + ) + + if not markets: + continue + + fallback_event_id = _slug(f'{home_team}-{away_team}-{game.get("kt", "")}') + parsed.append( + { + 'id': f'tsl-{game.get("id") or fallback_event_id}', + 'home_team': home_team, + 'away_team': away_team, + 'status': MatchStatus.PRE_MATCH, + 'commence_time': game.get('kt'), + 'bookmakers': [ + { + 'key': 'taiwan-sports-lottery-reference', + 'title': '台灣運彩參考盤', + 'markets': markets, + } + ], + } + ) + + logger.info('台灣運彩世界盃參考盤抓取完成:events=%s', len(parsed)) + return parsed + + +async def fetch_source_payload() -> tuple[str, list[dict[str, Any]]]: + async with httpx.AsyncClient( + headers={ + 'User-Agent': 'Mozilla/5.0 FIFA2026QuantBot/1.0', + 'Accept': 'application/json,text/plain,*/*', + 'Referer': 'https://www.sportslottery.com.tw/', + } + ) as client: + if THE_ODDS_API_KEY and THE_ODDS_API_KEY != 'your_the_odds_api_key': + return 'the-odds-api', await fetch_the_odds_api(client) + try: + taiwan_reference_events = await fetch_taiwan_sports_lottery_reference(client) + except Exception as exc: + logger.warning('台灣運彩參考盤抓取失敗,本輪僅使用 ESPN 備援:%s', exc) + taiwan_reference_events = [] + if taiwan_reference_events: + return 'taiwan-sports-lottery-reference', taiwan_reference_events + return 'espn-scoreboard', await fetch_espn_scoreboard(client) + + +async def process_odds_data(data: list[dict[str, Any]]) -> dict[str, int]: + if not data: + logger.info('本輪來源沒有可處理賽事。') + return {'events': 0, 'odds_rows': 0, 'bookmakers': 0} + + event_count = 0 + odds_count = 0 + reconciled_count = 0 + deduped_count = 0 + bookmaker_ids: set[str] = set() + async with SessionFactory() as session: try: - # Upsert logic for each event for event in data: - home_team_name = event.get("home_team") - away_team_name = event.get("away_team") - match_id = event.get("id") - commence_time = event.get("commence_time") - - # Fetch or create Teams + if not isinstance(event, dict): + continue + match_id = str(event.get('id') or '').strip() + if not match_id: + continue + + home_team_name = normalize_team_name(event.get('home_team')) + away_team_name = normalize_team_name(event.get('away_team')) + kickoff = _parse_datetime(event.get('commence_time')) + home_team = await get_or_create_team(session, home_team_name) away_team = await get_or_create_team(session, away_team_name) - - # Default Venue - venue = await get_or_create_venue(session, "Unknown Stadium", "Unknown", "Unknown") - - # Upsert Match - dt = datetime.fromisoformat(commence_time.replace("Z", "+00:00")) - match = await session.get(Match, match_id) + venue = await session.get(Venue, 'unknown-stadium') + if venue is None: + venue = await get_or_create_venue(session, '待確認場館', '待確認', '待確認') + + reconciled_match, is_swapped_fixture = await _find_reconciled_match( + session, + incoming_match_id=match_id, + home_team=home_team, + away_team=away_team, + kickoff=kickoff, + ) + if reconciled_match and reconciled_match.id != match_id: + reconciled_count += 1 + if await _merge_duplicate_match(session, source_match_id=match_id, target_match=reconciled_match): + deduped_count += 1 + + match = reconciled_match or await session.get(Match, match_id) + event_status = event.get('status') + has_result_payload = isinstance(event_status, MatchStatus) or 'home_score' in event or 'away_score' in event + parsed_status = event_status if isinstance(event_status, MatchStatus) else MatchStatus.PRE_MATCH + home_score = _parse_score(event.get('home_score')) + away_score = _parse_score(event.get('away_score')) + if not match: match = Match( id=match_id, home_team_id=home_team.id, away_team_id=away_team.id, venue_id=venue.id, - match_time_utc=dt, - status=MatchStatus.PRE_MATCH + match_time_utc=kickoff, + status=parsed_status, + home_score=home_score, + away_score=away_score, + result_synced_at=datetime.now(timezone.utc), ) session.add(match) - - # Upsert Odds History - bookmakers = event.get("bookmakers", []) - for bm in bookmakers: - bm_key = bm.get("key") - bm_title = bm.get("title") - bookmaker = await get_or_create_bookmaker(session, bm_key, bm_title) - - for market in bm.get("markets", []): - market_key = market.get("key") - for outcome in market.get("outcomes", []): - selection = outcome.get("name") - price = outcome.get("price") - - odds_entry = OddsHistory( - match_id=match.id, - bookmaker_id=bookmaker.id, - market_type=market_key, - selection=selection, - decimal_odds=price, - implied_probability=1.0/price if price > 1 else 0, - recorded_at=datetime.now(timezone.utc) - ) - session.add(odds_entry) - - await session.commit() - logger.info("Successfully updated odds in the database.") - except SQLAlchemyError as e: - await session.rollback() - logger.error(f"Database error while saving odds: {e}") + else: + if not is_swapped_fixture: + match.home_team_id = home_team.id + match.away_team_id = away_team.id + if venue.id != 'unknown-stadium' or match.venue_id == 'unknown-stadium': + match.venue_id = venue.id + match.match_time_utc = kickoff + if has_result_payload: + match.status = parsed_status + match.home_score = away_score if is_swapped_fixture else home_score + match.away_score = home_score if is_swapped_fixture else away_score + match.result_synced_at = datetime.now(timezone.utc) -async def get_or_create_team(session, name: str) -> Team: - result = await session.execute(select(Team).where(Team.name == name)) - team = result.scalars().first() - if not team: - import uuid - team = Team(id=str(uuid.uuid4()), name=name) - session.add(team) - await session.flush() + event_count += 1 + recorded_at = datetime.now(timezone.utc) + + for bookmaker_payload in _as_list(event.get('bookmakers')): + if not isinstance(bookmaker_payload, dict): + continue + bookmaker_key = _slug(str(bookmaker_payload.get('key') or bookmaker_payload.get('title') or 'unknown')) + bookmaker_name = str(bookmaker_payload.get('title') or bookmaker_key) + bookmaker = await get_or_create_bookmaker(session, bookmaker_key, bookmaker_name) + bookmaker_ids.add(bookmaker.id) + + for market in _as_list(bookmaker_payload.get('markets')): + if not isinstance(market, dict): + continue + source_market = str(market.get('key') or '').strip() + market_type = MARKET_TYPE_MAP.get(source_market, source_market or 'unknown') + for outcome in _as_list(market.get('outcomes')): + if not isinstance(outcome, dict): + continue + price = _decimal_price(outcome.get('price')) + if not price: + continue + point = _line_value(outcome.get('point'), market.get('point')) + market_line = point if source_market in {'totals', 'alternate_totals', 'team_totals', 'alternate_team_totals'} else None + handicap = point if source_market in {'spreads', 'alternate_spreads'} else None + source_outcome_name = str(outcome.get('name') or '')[:140] + source_outcome_description = str(outcome.get('description') or '')[:140] + selection = _selection_for_outcome( + source_market, + source_outcome_name, + home_team_name, + away_team_name, + source_outcome_description, + ) + if not selection: + continue + + session.add( + OddsHistory( + match_id=match.id, + bookmaker_id=bookmaker.id, + market_type=market_type, + selection=selection, + market_line=market_line, + handicap=handicap, + decimal_odds=price, + implied_probability=1.0 / price, + source_market_key=source_market[:80], + source_outcome_name=source_outcome_name, + recorded_at=recorded_at, + ) + ) + odds_count += 1 + + await session.commit() + logger.info( + '本輪賠率寫入完成:events=%s odds_rows=%s bookmakers=%s reconciled=%s deduped=%s', + event_count, + odds_count, + len(bookmaker_ids), + reconciled_count, + deduped_count, + ) + return { + 'events': event_count, + 'odds_rows': odds_count, + 'bookmakers': len(bookmaker_ids), + 'reconciled_matches': reconciled_count, + 'deduped_matches': deduped_count, + } + except SQLAlchemyError as exc: + await session.rollback() + logger.exception('資料庫寫入賠率失敗:%s', exc) + raise + + +async def get_or_create_team(session: Any, name: str) -> Team: + canonical_name = normalize_team_name(name) + result = await session.execute(select(Team).where(Team.name.in_(_team_name_candidates(name)))) + teams = list(result.scalars().all()) + for team in teams: + if normalize_team_name(team.name) == canonical_name: + return team + if teams: + return teams[0] + + team = Team(id=str(uuid4()), name=canonical_name) + session.add(team) + await session.flush() return team -async def get_or_create_venue(session, name: str, city: str, country: str) -> Venue: + +async def get_or_create_venue(session: Any, name: str, city: str, country: str) -> Venue: result = await session.execute(select(Venue).where(Venue.name == name)) venue = result.scalars().first() if not venue: - import uuid - venue = Venue(id=str(uuid.uuid4()), name=name, city=city, country=country, timezone="UTC") + venue = Venue(id=str(uuid4()), name=name, city=city, country=country, timezone='UTC') session.add(venue) await session.flush() return venue -async def get_or_create_bookmaker(session, id: str, name: str) -> Bookmaker: - bookmaker = await session.get(Bookmaker, id) + +async def get_or_create_bookmaker(session: Any, bookmaker_id: str, name: str) -> Bookmaker: + bookmaker = await session.get(Bookmaker, bookmaker_id) if not bookmaker: - bookmaker = Bookmaker(id=id, name=name) + bookmaker = Bookmaker(id=bookmaker_id, name=name[:120]) session.add(bookmaker) await session.flush() return bookmaker -if __name__ == "__main__": - asyncio.run(fetch_odds()) + +async def publish_status(status: dict[str, Any]) -> None: + try: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + await redis.set( + 'ingestion:odds:last_run', + json.dumps(status, ensure_ascii=False), + ex=max(ODDS_POLL_INTERVAL_SECONDS * 3, 900), + ) + await redis.aclose() + except Exception as exc: # pragma: no cover - Redis 狀態不應阻斷 DB ingestion + logger.warning('寫入 Redis ingestion 狀態失敗:%s', exc) + + +async def run_once() -> dict[str, Any]: + source, payload = await fetch_source_payload() + stats = await process_odds_data(payload) + if source != 'espn-scoreboard': + try: + async with httpx.AsyncClient() as client: + espn_payload = await fetch_espn_scoreboard(client) + score_stats = await process_odds_data(espn_payload) + stats = { + 'events': stats.get('events', 0) + score_stats.get('events', 0), + 'odds_rows': stats.get('odds_rows', 0) + score_stats.get('odds_rows', 0), + 'bookmakers': stats.get('bookmakers', 0) + score_stats.get('bookmakers', 0), + 'reconciled_matches': stats.get('reconciled_matches', 0) + score_stats.get('reconciled_matches', 0), + 'deduped_matches': stats.get('deduped_matches', 0) + score_stats.get('deduped_matches', 0), + 'score_events': score_stats.get('events', 0), + 'score_status': 'ok', + } + except Exception as exc: + logger.warning('比分來源同步失敗,但不阻斷賠率 ingestion:%s', exc) + stats = { + **stats, + 'score_events': 0, + 'score_status': 'error', + 'score_message': str(exc), + } + status = { + 'status': 'ok', + 'source': source, + 'run_at': datetime.now(timezone.utc).isoformat(), + **stats, + } + await publish_status(status) + return status + + +async def run_forever() -> None: + logger.info('啟動賠率 ingestion worker,interval=%ss', ODDS_POLL_INTERVAL_SECONDS) + while True: + try: + status = await run_once() + logger.info('ingestion 狀態:%s', status) + except Exception as exc: + error_status = { + 'status': 'error', + 'source': 'unknown', + 'run_at': datetime.now(timezone.utc).isoformat(), + 'message': str(exc), + } + await publish_status(error_status) + logger.exception('本輪賠率 ingestion 失敗:%s', exc) + await asyncio.sleep(ODDS_POLL_INTERVAL_SECONDS) + + +if __name__ == '__main__': + if os.environ.get('ODDS_WORKER_ONCE') == 'true': + print(asyncio.run(run_once())) + else: + asyncio.run(run_forever()) diff --git a/platform/backend/app/analytics/daily_card_generator.py b/platform/backend/app/analytics/daily_card_generator.py index 299668f..2865727 100644 --- a/platform/backend/app/analytics/daily_card_generator.py +++ b/platform/backend/app/analytics/daily_card_generator.py @@ -1,9 +1,57 @@ -"""每日智能注單生成器(Daily Smart Card)。""" +"""每日智能注單生成器(Daily Smart Card)。 + +專業原則: +- 有實際盤口時輸出可下注賠率;尚未取得盤口時只輸出預掛進場條件。 +- 每個候選都必須同時具備模型勝率、市場隱含機率、edge、EV 與倉位上限。 +- 尚未取得盤口時,只能輸出「預掛進場條件」,不得假裝已有可下注賠率。 +- 串關與同場串關必須套用相關性折減,不能把單關勝率直接相乘就宣稱高勝率。 +- 所有輸出都是量化研究候選,不是保證獲利。 +""" from __future__ import annotations +import math +import os +import unicodedata +from itertools import combinations from typing import Any +MAX_GOALS = 8 +DEFAULT_UNIT_STAKE_TWD = max(100, int(os.environ.get('DEFAULT_UNIT_STAKE_TWD', '1000'))) + +TEAM_ALIASES = { + 'cotedivoire': '象牙海岸', + 'ivorycoast': '象牙海岸', + '象牙海岸': '象牙海岸', + 'ecuador': '厄瓜多', + '厄瓜多': '厄瓜多', + 'germany': '德國', + 'deutschland': '德國', + '德國': '德國', + 'curacao': '古拉索', + 'curaçao': '古拉索', + '古拉索': '古拉索', + 'netherlands': '荷蘭', + 'holland': '荷蘭', + '荷蘭': '荷蘭', + 'japan': '日本', + '日本': '日本', + 'sweden': '瑞典', + '瑞典': '瑞典', + 'tunisia': '突尼西亞', + '突尼西亞': '突尼西亞', + 'haiti': '海地', + '海地': '海地', + 'scotland': '蘇格蘭', + '蘇格蘭': '蘇格蘭', + 'iran': '伊朗', + 'iriran': '伊朗', + 'algeria': '阿爾及利亞', + 'jordan': '約旦', + 'congodr': '剛果民主共和國', + 'drcongo': '剛果民主共和國', +} + def _safe_float(value: Any, default: float = 0.0) -> float: try: @@ -12,177 +60,1323 @@ def _safe_float(value: Any, default: float = 0.0) -> float: return default -def _safe_int(value: Any, default: int = 0) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default +def _compact_team_name(value: Any) -> str: + text = str(value or '').strip() + normalized = unicodedata.normalize('NFKD', text.replace('’', '').replace("'", '')) + ascii_folded = ''.join(ch for ch in normalized if not unicodedata.combining(ch)) + return ''.join(ch.lower() for ch in ascii_folded if ch.isalnum()) + + +def _canonical_team_name(value: Any) -> str: + compact = _compact_team_name(value) + return TEAM_ALIASES.get(compact, str(value or '').strip() or '待確認球隊') + + +def _canonical_match_key(match: dict[str, Any]) -> tuple[str, str, str]: + home = _canonical_team_name(match.get('home_team')) + away = _canonical_team_name(match.get('away_team')) + kickoff = str(match.get('kickoff_utc') or match.get('match_time_utc') or '').strip() + kickoff_bucket = kickoff[:13] if kickoff else 'unknown-kickoff' + return (*tuple(sorted((home, away))), kickoff_bucket) + + +def _data_quality_rank(value: Any) -> int: + return { + 'observed': 4, + 'mixed': 3, + 'rank_elo_prior': 2, + 'fallback_prior': 1, + }.get(str(value or ''), 0) + + +def _match_source_rank(match: dict[str, Any]) -> tuple[int, int, int]: + source_kind = str(match.get('odds_source_kind') or '') + source_rank = { + 'multi_provider_market': 5, + 'single_provider_market': 4, + 'reference_market': 3, + 'scoreboard_fallback': 1, + 'conditional_threshold': 0, + }.get(source_kind, 2 if bool(match.get('has_market_odds')) else 0) + odds_fields = sum(1 for key, value in match.items() if key.startswith('odds_') and _safe_float(value, 0) > 1) + return (1 if bool(match.get('has_market_odds')) else 0, source_rank, odds_fields) + + +def _is_formal_market_source(odds_source_kind: str) -> bool: + """只有多來源可比價盤口才能升級為正式下注前檢查。""" + + return str(odds_source_kind or '') == 'multi_provider_market' + + +def _source_requires_watchlist(odds_source_kind: str) -> bool: + """單一來源、台灣運彩參考盤與比分備援都只能列入賠率監控。""" + + return str(odds_source_kind or '') in { + 'reference_market', + 'single_provider_market', + 'mixed_market_source', + 'scoreboard_fallback', + 'conditional_threshold', + } + + +def _merge_match_snapshots(matches: list[dict[str, Any]]) -> list[dict[str, Any]]: + """合併同場不同來源或不同語系名稱,避免推薦與串關重複。""" + + merged: dict[tuple[str, str], dict[str, Any]] = {} + + for raw_match in matches: + key = _canonical_match_key(raw_match) + home_name = _canonical_team_name(raw_match.get('home_team')) + away_name = _canonical_team_name(raw_match.get('away_team')) + match = dict(raw_match) + match['home_team'] = home_name + match['away_team'] = away_name + match['canonical_match_key'] = '|'.join(key) + + existing = merged.get(key) + if existing is None: + merged[key] = match + continue + + existing_rank = _match_source_rank(existing) + incoming_rank = _match_source_rank(match) + if incoming_rank > existing_rank: + primary = dict(match) + secondary = existing + else: + primary = existing + secondary = match + + for field, value in secondary.items(): + if field in {'home_team', 'away_team', 'canonical_match_key'}: + continue + if value in (None, ''): + continue + if field.startswith('odds_'): + if _safe_float(primary.get(field), 0) <= 1 and _safe_float(value, 0) > 1: + primary[field] = value + continue + if field in {'home_xg', 'away_xg'}: + if _data_quality_rank(secondary.get('xg_quality')) > _data_quality_rank(primary.get('xg_quality')): + primary[field] = value + continue + if field == 'xg_quality': + if _data_quality_rank(value) > _data_quality_rank(primary.get(field)): + primary[field] = value + continue + if field == 'has_market_odds': + primary[field] = bool(primary.get(field)) or bool(value) + continue + if field in {'odds_source_label', 'odds_source_kind'}: + current = str(primary.get(field) or '') + incoming = str(value or '') + if current and incoming and current != incoming: + primary[field] = '混合盤口來源' if field == 'odds_source_label' else 'mixed_market_source' + elif incoming and not current: + primary[field] = incoming + continue + if primary.get(field) in (None, ''): + primary[field] = value + + primary['home_team'] = home_name if incoming_rank > existing_rank else existing.get('home_team', home_name) + primary['away_team'] = away_name if incoming_rank > existing_rank else existing.get('away_team', away_name) + primary['canonical_match_key'] = '|'.join(key) + merged[key] = primary + + return list(merged.values()) 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 _edge_percent(true_prob: float, decimal_odds: float) -> float: + if decimal_odds <= 1: + return 0.0 + return (true_prob - (1.0 / decimal_odds)) * 100 + + +def _market_overround(*odds_values: float) -> float | None: + odds = [value for value in odds_values if value and value > 1] + if len(odds) < 2: + return None + return sum(1.0 / value for value in odds) + + +def _no_vig_probability(decimal_odds: float, overround: float | None) -> float | None: + if decimal_odds <= 1 or not overround or overround <= 0: + return None + return (1.0 / decimal_odds) / overround + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _poisson_pmf(mean: float, goals: int) -> float: + mean = max(mean, 0.05) + return math.exp(-mean) * (mean ** goals) / math.factorial(goals) + + +def _score_matrix(home_xg: float, away_xg: float, max_goals: int = MAX_GOALS) -> list[list[float]]: + return [ + [_poisson_pmf(home_xg, h) * _poisson_pmf(away_xg, a) for a in range(max_goals + 1)] + for h in range(max_goals + 1) + ] + + +def _top_exact_scores(home_xg: float, away_xg: float, limit: int = 3) -> list[tuple[int, int, float]]: + matrix = _score_matrix(max(home_xg, 0.15), max(away_xg, 0.15)) + scores: list[tuple[int, int, float]] = [] + for h, row in enumerate(matrix): + for a, prob in enumerate(row): + scores.append((h, a, prob)) + return sorted(scores, key=lambda item: item[2], reverse=True)[:limit] + + +def _match_probabilities(home_xg: float, away_xg: float) -> dict[str, float]: + matrix = _score_matrix(max(home_xg, 0.15), max(away_xg, 0.15)) + home_win = 0.0 + draw = 0.0 + away_win = 0.0 + over_15 = 0.0 + over_25 = 0.0 + over_35 = 0.0 + btts_yes = 0.0 + home_over_05 = 0.0 + away_over_05 = 0.0 + home_under_15 = 0.0 + away_under_15 = 0.0 + + for h, row in enumerate(matrix): + for a, prob in enumerate(row): + if h > a: + home_win += prob + elif h == a: + draw += prob + else: + away_win += prob + if h + a > 1.5: + over_15 += prob + if h + a > 2.5: + over_25 += prob + if h + a > 3.5: + over_35 += prob + if h > 0 and a > 0: + btts_yes += prob + if h > 0: + home_over_05 += prob + if a > 0: + away_over_05 += prob + if h < 1.5: + home_under_15 += prob + if a < 1.5: + away_under_15 += prob + + # Tail probability beyond MAX_GOALS is small but normalize core 1X2 to avoid leakage. + total_1x2 = home_win + draw + away_win + if total_1x2 > 0: + home_win /= total_1x2 + draw /= total_1x2 + away_win /= total_1x2 + + over_15 = _clamp(over_15, 0, 1) + over_25 = _clamp(over_25, 0, 1) + over_35 = _clamp(over_35, 0, 1) + btts_yes = _clamp(btts_yes, 0, 1) + home_over_05 = _clamp(home_over_05, 0, 1) + away_over_05 = _clamp(away_over_05, 0, 1) + home_under_15 = _clamp(home_under_15, 0, 1) + away_under_15 = _clamp(away_under_15, 0, 1) + return { + 'home_win': home_win, + 'draw': draw, + 'away_win': away_win, + 'over_15': over_15, + 'under_15': 1.0 - over_15, + 'over_25': over_25, + 'under_25': 1.0 - over_25, + 'over_35': over_35, + 'under_35': 1.0 - over_35, + 'btts_yes': btts_yes, + 'btts_no': 1.0 - btts_yes, + 'home_or_draw': home_win + draw, + 'away_or_draw': away_win + draw, + 'home_dnb': home_win / max(home_win + away_win, 0.01), + 'away_dnb': away_win / max(home_win + away_win, 0.01), + 'home_over_05': home_over_05, + 'away_over_05': away_over_05, + 'home_under_15': home_under_15, + 'away_under_15': away_under_15, + } + + +def _confidence_score( + true_prob: float, + ev_percent: float, + edge_percent: float, + market_tier: str, + *, + decimal_odds: float | None = None, + market_overround: float | None = None, +) -> float: + """用勝率、價值、賠率波動與玩法風險產生更細緻的信心分數。 + + 信心不是單純勝率,也不是保證命中;它代表「這張候選目前資料條件下有多值得進一步檢查」。 + """ + + baseline = { + 'core': 59.0, + 'low_odds': 58.0, + 'totals': 57.0, + 'btts': 55.0, + 'team_total': 54.0, + 'speculative': 46.0, + 'exact_score': 36.0, + 'parlay': 43.0, + 'sgp': 37.0, + }.get(market_tier, 50.0) + probability_component = _clamp((true_prob - 0.50) * 42.0, -12.0, 24.0) + value_component = _clamp(math.log1p(max(ev_percent, 0.0)) * 7.5, 0.0, 22.0) + edge_component = _clamp(edge_percent * 1.15, 0.0, 18.0) + odds_penalty = 0.0 + if decimal_odds and decimal_odds > 1: + odds_penalty = _clamp((decimal_odds - 2.4) * 2.8, 0.0, 13.0) + overround_penalty = 0.0 + if market_overround: + overround_penalty = _clamp((market_overround - 1.07) * 42.0, 0.0, 9.0) + low_sample_penalty = 8.0 if true_prob < 0.18 else 4.0 if true_prob < 0.28 else 0.0 + + raw = baseline + probability_component + value_component + edge_component - odds_penalty - overround_penalty - low_sample_penalty + return round(_clamp(raw, 0, 96), 1) + + +def _confidence_band(score: float, has_market_odds: bool, risk_level: str) -> str: + if not has_market_odds: + if score >= 64: + return '高優先監控' + if score >= 52: + return '中度監控' + return '低信心觀察' + if risk_level in {'speculative', 'sgp'}: + if score >= 68: + return '小注高波動' + return '低倉位觀察' + if score >= 82: + return '高信心檢查' + if score >= 70: + return '中高信心檢查' + if score >= 58: + return '一般信心檢查' + return '低信心觀察' + + +def _stake_amount_twd(stake_units: float) -> int: + return int(round(stake_units * DEFAULT_UNIT_STAKE_TWD)) + + +def _confidence_factors( + *, + true_prob: float, + ev_percent: float, + edge_percent: float, + data_quality: str, + has_market_odds: bool, + market_tier: str, + market_overround: float | None = None, +) -> list[str]: + factors = [ + f'模型勝率 {true_prob * 100:.2f}%', + f'期望值 {ev_percent:.2f}%', + f'模型優勢 {edge_percent:.2f} 百分點', + ] + if has_market_odds: + factors.append('已取得可比對盤口') + else: + factors.append('尚未取得完整盤口,僅能監控') + if market_overround is not None: + factors.append(f'平台抽成水位 {market_overround * 100:.2f}%') + if data_quality == 'rank_elo_prior': + factors.append('資料以國際排名與實力分數估計') + elif data_quality == 'fallback_prior': + factors.append('基礎資料估計,信心折扣較大') + if market_tier in {'parlay', 'sgp'}: + factors.append('串關已做相關性折減') + elif market_tier in {'speculative', 'exact_score'}: + factors.append('高波動玩法,僅適合小注') + return factors + + +def _quality_checks(data_quality: str, has_market_odds: bool) -> list[str]: + checks: list[str] = [] + if data_quality == 'rank_elo_prior': + checks.append('FIFA/Elo 賽前先驗') + checks.append('xG 資料折扣') + elif data_quality == 'fallback_prior': + checks.append('基礎先驗模型') + checks.append('高資料不確定性') + if not has_market_odds: + checks.append('等待即時盤口') + checks.append('信心降權') + return checks + + +def _quality_note(data_quality: str, has_market_odds: bool) -> str: + notes: list[str] = [] + if data_quality == 'rank_elo_prior': + notes.append('目前 xG 不是完整即時賽事資料,已改用 FIFA 排名/Elo 作賽前先驗並降低信心。') + elif data_quality == 'fallback_prior': + notes.append('目前僅有基礎賽程資料,模型信心與倉位已大幅降權。') + if not has_market_odds: + notes.append('尚未取得完整實盤賠率,這張只能作預掛條件,不是立即下注訊號。') + return ''.join(notes) + + +def _adjust_for_data_quality( + *, + confidence: float, + stake: float, + data_quality: str, + has_market_odds: bool, + market_tier: str, +) -> tuple[float, float]: + penalty = 0.0 + stake_cap = 1.25 + confidence_cap = 92.0 + if data_quality == 'rank_elo_prior': + penalty += 8.0 + stake_cap = min(stake_cap, 0.65) + confidence_cap = min(confidence_cap, 76.0) + elif data_quality == 'fallback_prior': + penalty += 18.0 + stake_cap = min(stake_cap, 0.35) + confidence_cap = min(confidence_cap, 62.0) + + if not has_market_odds: + penalty += 12.0 + stake_cap = min(stake_cap, 0.45) + confidence_cap = min(confidence_cap, 68.0) + + if market_tier in {'exact_score', 'speculative'}: + penalty += 6.0 + stake_cap = min(stake_cap, 0.22) + confidence_cap = min(confidence_cap, 58.0) + elif market_tier in {'parlay', 'sgp'}: + penalty += 5.0 + stake_cap = min(stake_cap, 0.35) + confidence_cap = min(confidence_cap, 64.0 if not has_market_odds else 72.0) + + return round(_clamp(confidence - penalty, 0, confidence_cap), 1), round(_clamp(stake, 0.12, stake_cap), 2) + + +def _stake_units(true_prob: float, ev_percent: float, risk_level: str, cap: float) -> float: + base = 0.2 + (_clamp(ev_percent, 0, 22) / 14.0) + (_clamp(true_prob - 0.5, 0, 0.25) * 3.5) + if risk_level == 'core': + multiplier = 1.0 + elif risk_level == 'parlay': + multiplier = 0.55 + elif risk_level == 'sgp': + multiplier = 0.35 + else: + multiplier = 0.42 + return round(_clamp(base * multiplier, 0.18, cap), 2) + + +def _is_weak_or_reference_source(odds_source_kind: str, data_quality: str) -> bool: + return odds_source_kind in { + 'reference_market', + 'single_provider_market', + 'mixed_market_source', + 'scoreboard_fallback', + 'conditional_threshold', + } or data_quality in {'rank_elo_prior', 'fallback_prior'} + + +def _apply_longshot_guard( + *, + true_prob: float, + odds: float, + data_checks: list[str], + odds_source_kind: str, + data_quality: str, +) -> tuple[float, list[str], bool]: + """高賠爆冷若只有參考盤/弱資料,不能讓模型勝率遠離市場太多。""" + + if odds <= 1: + return true_prob, data_checks, False + market_prob = 1.0 / odds + weak_source = _is_weak_or_reference_source(odds_source_kind, data_quality) + if odds < 5.0 or market_prob >= 0.15 or not weak_source: + return true_prob, data_checks, False + + capped_prob = min(true_prob, market_prob + 0.08) + if capped_prob >= true_prob: + return true_prob, data_checks, False + + guarded_checks = data_checks + [ + '高賠爆冷防呆', + '資料源不足,模型勝率已按市場上限降權', + ] + return capped_prob, guarded_checks, True + + +def _build_pick( + *, + match_id: str, + match_label: str, + market_type: str, + selection: str, + odds: float, + true_prob: float, + recommendation: str, + risk_level: str, + market_tier: str, + min_win_prob: float, + min_ev: float, + min_edge: float, + stake_cap: float, + data_checks: list[str], + fair_implied_prob: float | None = None, + market_overround: float | None = None, + data_quality: str = 'observed', + has_market_odds: bool = True, + odds_source_label: str = '實盤盤口', + odds_source_kind: str = 'market', +) -> dict[str, Any] | None: + if odds <= 1 or true_prob <= 0: + return None + + if market_overround is not None and market_overround > 1.22: + return None + + true_prob, data_checks, longshot_guarded = _apply_longshot_guard( + true_prob=true_prob, + odds=odds, + data_checks=data_checks, + odds_source_kind=odds_source_kind, + data_quality=data_quality, + ) + if longshot_guarded: + risk_level = 'speculative' + market_tier = 'speculative' + + ev = _ev_percent(true_prob, odds) + edge = _edge_percent(true_prob, odds) + if true_prob < min_win_prob or ev < min_ev or edge < min_edge: + return None + + stake = _stake_units(true_prob, ev, risk_level, stake_cap) + confidence = _confidence_score( + true_prob, + ev, + edge, + market_tier, + decimal_odds=odds, + market_overround=market_overround, + ) + confidence, stake = _adjust_for_data_quality( + confidence=confidence, + stake=stake, + data_quality=data_quality, + has_market_odds=has_market_odds, + market_tier=market_tier, + ) + implied = (1.0 / odds) * 100 + fair_note = '' + enriched_checks = list(data_checks) + if odds_source_label: + enriched_checks.append(odds_source_label) + if not has_market_odds and _source_requires_watchlist(odds_source_kind): + enriched_checks.extend(['非正式多來源盤', '只可賠率監控']) + if fair_implied_prob is not None and market_overround is not None: + fair_note = f' 去水後公平機率約 {fair_implied_prob * 100:.2f}%,市場水位 {market_overround * 100:.2f}%。' + enriched_checks = enriched_checks + ['去水公平機率', '莊家水位檢查'] + enriched_checks = enriched_checks + _quality_checks(data_quality, has_market_odds) + quality_note = _quality_note(data_quality, has_market_odds) + longshot_note = '此選項屬於高賠爆冷觀察,資料源不足時已自動降權,不可當核心下注。' if longshot_guarded else '' + confidence_band = _confidence_band(confidence, has_market_odds, risk_level) + confidence_factors = _confidence_factors( + true_prob=true_prob, + ev_percent=ev, + edge_percent=edge, + data_quality=data_quality, + has_market_odds=has_market_odds, + market_tier=market_tier, + market_overround=market_overround, + ) + + return { + 'match_id': match_id, + 'match_label': match_label, + 'market_type': market_type, + 'selection': selection if has_market_odds or not _source_requires_watchlist(odds_source_kind) else f'{selection}|參考盤監控', + 'target_odds': round(odds, 2), + 'win_prob': round(true_prob * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': stake, + 'stake_amount_twd': _stake_amount_twd(stake), + 'unit_size_twd': DEFAULT_UNIT_STAKE_TWD, + 'recommendation': recommendation, + 'risk_level': risk_level, + 'confidence_score': confidence, + 'confidence_band': confidence_band, + 'confidence_factors': confidence_factors, + 'data_quality': data_quality, + 'has_market_odds': has_market_odds, + 'odds_source_label': odds_source_label, + 'odds_source_kind': odds_source_kind, + 'market_implied_prob': round(implied, 2), + 'edge_percent': round(edge, 2), + 'rationale': ( + f'模型勝率 {true_prob * 100:.2f}% 對比市場隱含 {implied:.2f}%,' + f'模型優勢 {edge:.2f} 百分點、預期期望值 {ev:.2f}%。' + f'{fair_note}{quality_note}{longshot_note}建議上限約新台幣 {_stake_amount_twd(stake):,} 元({stake:.2f}u),且需等待最新傷停、先發與盤口刷新一致後才進入檢驗單。' + ), + 'data_checks': enriched_checks, + } + + +def _minimum_value_odds(true_prob: float, min_ev_percent: float) -> float: + if true_prob <= 0: + return 0.0 + return (1.0 + (min_ev_percent / 100.0)) / true_prob + + +def _build_conditional_pick( + *, + match_id: str, + match_label: str, + market_type: str, + selection: str, + true_prob: float, + recommendation: str, + risk_level: str, + market_tier: str, + min_win_prob: float, + min_ev: float, + stake_cap: float, + max_target_odds: float, + data_checks: list[str], + data_quality: str = 'observed', + has_market_odds: bool = False, + odds_source_label: str = '模型推算最低門檻', + odds_source_kind: str = 'conditional_threshold', +) -> dict[str, Any] | None: + """尚無即時盤口時,產生可執行的預掛進場條件,而不是假裝已有市場賠率。""" + + if true_prob < min_win_prob: + return None + + target_odds = _minimum_value_odds(true_prob, min_ev) + if target_odds <= 1 or target_odds > max_target_odds: + return None + + implied = (1.0 / target_odds) * 100 + edge = (true_prob * 100) - implied + stake = _stake_units(true_prob, min_ev, risk_level, stake_cap) + confidence = _confidence_score( + true_prob, + min_ev, + edge, + market_tier, + decimal_odds=target_odds, + ) + confidence, stake = _adjust_for_data_quality( + confidence=confidence, + stake=stake, + data_quality=data_quality, + has_market_odds=has_market_odds, + market_tier=market_tier, + ) + quality_note = _quality_note(data_quality, has_market_odds) + confidence_band = _confidence_band(confidence, has_market_odds, risk_level) + confidence_factors = _confidence_factors( + true_prob=true_prob, + ev_percent=min_ev, + edge_percent=edge, + data_quality=data_quality, + has_market_odds=has_market_odds, + market_tier=market_tier, + ) + + return { + 'match_id': match_id, + 'match_label': match_label, + 'market_type': market_type, + 'selection': f'{selection}|預掛條件', + 'target_odds': round(target_odds, 2), + 'win_prob': round(true_prob * 100, 2), + 'ev_percent': round(min_ev, 2), + 'stake_units': stake, + 'stake_amount_twd': _stake_amount_twd(stake), + 'unit_size_twd': DEFAULT_UNIT_STAKE_TWD, + 'recommendation': 'WATCHLIST_HIGH_RISK' if recommendation == 'HIGH_RISK_SINGLE' else 'WATCHLIST_SINGLE', + 'risk_level': risk_level, + 'confidence_score': confidence, + 'confidence_band': confidence_band, + 'confidence_factors': confidence_factors, + 'data_quality': data_quality, + 'has_market_odds': has_market_odds, + 'odds_source_label': odds_source_label, + 'odds_source_kind': odds_source_kind, + 'market_implied_prob': round(implied, 2), + 'edge_percent': round(edge, 2), + 'rationale': ( + f'此場尚未取得可用即時盤口,因此先給「預掛進場條件」。模型勝率 {true_prob * 100:.2f}%,' + f'只有當平台賠率達到 {target_odds:.2f} 以上,才符合至少 {min_ev:.2f}% 的正期望值門檻;' + f'若實際賠率低於此數字,直接跳過,不追價。建議監控上限約新台幣 {_stake_amount_twd(stake):,} 元({stake:.2f}u)。{quality_note}' + ), + 'data_checks': data_checks + [odds_source_label, '尚待盤口確認', '預掛最低賠率', '不得低於目標賠率下注'] + _quality_checks(data_quality, has_market_odds), + } + + +def _sort_picks(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted(items, key=lambda item: (item['confidence_score'], item['ev_percent'], item['win_prob']), reverse=True) + + +def _market_conflict_key(item: dict[str, Any]) -> tuple[str, str, str]: + """同一場同一互斥市場只能保留一個推薦,避免主勝和平局同時被當成獨立買點。""" + + match_id = str(item.get('match_id') or '') + market_type = str(item.get('market_type') or '') + selection = str(item.get('selection') or '').split('|', 1)[0] + + if market_type == '隊伍總進球': + team = selection.split(' 大 ', 1)[0].split(' 小 ', 1)[0] + return (match_id, market_type, team) + + if market_type.startswith('大小球'): + return (match_id, market_type, 'total-line') + + if market_type in {'勝平負', '雙重機會', '平手退回', '雙方進球', '正確比分'}: + return (match_id, market_type, 'mutually-exclusive') + + return (match_id, market_type, selection) + + +def _dedupe_market_conflicts(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + winners: dict[tuple[str, str, str], dict[str, Any]] = {} + for item in _sort_picks(items): + key = _market_conflict_key(item) + if key in winners: + continue + winners[key] = item + return _sort_picks(list(winners.values())) + + +def _build_cross_match_parlays(safe_singles: list[dict[str, Any]]) -> list[dict[str, Any]]: + ranked = _sort_picks(safe_singles) + used_matches: set[str] = set() + unique_legs: list[dict[str, Any]] = [] + for pick in ranked: + if pick['match_id'] in used_matches: + continue + unique_legs.append(pick) + used_matches.add(pick['match_id']) + if len(unique_legs) == 8: + break + + if len(unique_legs) < 2: + return [] + + parlays: list[dict[str, Any]] = [] + for size in (2, 3): + for legs_tuple in combinations(unique_legs, size): + legs = list(legs_tuple) + combined_odds = 1.0 + combined_prob = 1.0 + for leg in legs: + combined_odds *= leg['target_odds'] + combined_prob *= leg['win_prob'] / 100 + + # 串關腿數越多,執行風險與盤口相關風險越高,必須額外折減。 + discount = 0.92 if size == 2 else 0.84 + combined_prob *= discount + min_prob = 0.22 if size == 2 else 0.12 + min_ev = 2.5 if size == 2 else 5.0 + required_combo_odds = _minimum_value_odds(combined_prob, min_ev) + target_combo_odds = max(combined_odds, required_combo_odds) + max_combo_odds = 8.5 if size == 2 else 18.0 + ev = _ev_percent(combined_prob, target_combo_odds) + edge = _edge_percent(combined_prob, target_combo_odds) + if combined_prob < min_prob or target_combo_odds > max_combo_odds or ev < min_ev or edge <= 0: + continue + + combo_has_market_odds = all(bool(item.get('has_market_odds')) for item in legs) + stake_units = round(_clamp(0.25 + ev / 30.0, 0.25, 0.9 if size == 2 else 0.55), 2) + is_conditional_combo = (not combo_has_market_odds) or target_combo_odds > combined_odds + combo_data_quality = 'mixed' if len({str(item.get('data_quality', 'observed')) for item in legs}) > 1 else str(legs[0].get('data_quality', 'observed')) + source_labels = {str(item.get('odds_source_label') or '模型推算最低門檻') for item in legs} + source_kinds = {str(item.get('odds_source_kind') or 'conditional_threshold') for item in legs} + combo_source_label = next(iter(source_labels)) if len(source_labels) == 1 else '混合盤口來源' + combo_source_kind = next(iter(source_kinds)) if len(source_kinds) == 1 else 'mixed_market_source' + confidence = round(min( + _confidence_score( + combined_prob, + ev, + edge, + 'parlay', + decimal_odds=target_combo_odds, + ), + (sum(_safe_float(item.get('confidence_score'), 0) for item in legs) / len(legs)) - (7 if size == 2 else 10), + ), 1) + confidence, stake_units = _adjust_for_data_quality( + confidence=confidence, + stake=stake_units, + data_quality=combo_data_quality, + has_market_odds=combo_has_market_odds, + market_tier='parlay', + ) + parlays.append( + { + 'match_id': f'PARLAY-CROSS-MATCH-{size}-{len(parlays) + 1}', + 'match_label': ' + '.join(item['match_label'] for item in legs), + 'market_type': '跨場串關', + 'selection': f'{size} 串 1 {"預掛" if is_conditional_combo else "嚴選"}組合', + 'legs': [ + {'match_id': item['match_id'], 'selection': f"{item['market_type']}|{item['selection']}", 'odds': item['target_odds']} + for item in legs + ], + 'target_odds': round(target_combo_odds, 2), + 'win_prob': round(combined_prob * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': stake_units, + 'stake_amount_twd': _stake_amount_twd(stake_units), + 'unit_size_twd': DEFAULT_UNIT_STAKE_TWD, + 'recommendation': 'WATCHLIST_PARLAY' if is_conditional_combo else 'SAFE_PARLAY', + 'risk_level': 'parlay', + 'confidence_score': confidence, + 'confidence_band': _confidence_band(confidence, combo_has_market_odds, 'parlay'), + 'confidence_factors': _confidence_factors( + true_prob=combined_prob, + ev_percent=ev, + edge_percent=edge, + data_quality=combo_data_quality, + has_market_odds=combo_has_market_odds, + market_tier='parlay', + ), + 'data_quality': combo_data_quality, + 'has_market_odds': combo_has_market_odds, + 'odds_source_label': combo_source_label, + 'odds_source_kind': combo_source_kind, + 'market_implied_prob': round((1.0 / target_combo_odds) * 100, 2), + 'edge_percent': round(edge, 2), + 'rationale': ( + f'{size} 腿皆通過單關正 EV 門檻,跨場相關性折減後勝率 {combined_prob * 100:.2f}%,' + f'整張串關總賠率需達到 {target_combo_odds:.2f} 以上才進場,預期期望值 {ev:.2f}%。' + f'{"任一腿尚未取得實盤,因此只能加入監控清單,不能直接下注。" if is_conditional_combo else ""}' + f'建議上限約新台幣 {_stake_amount_twd(stake_units):,} 元({stake_units:.2f}u)。串關只作輔助倉位,不能取代單關主策略。' + ), + 'data_checks': ['單關門檻通過', combo_source_label, '跨場去相關', f'相關性 {discount:.2f} 折減', '串關總賠率門檻', '串關 EV 重新計算'] + _quality_checks(combo_data_quality, combo_has_market_odds), + } + ) + + return _sort_picks(parlays)[:6] + + +def _is_same_game_parlay_compatible(first: dict[str, Any], second: dict[str, Any]) -> bool: + """阻擋明顯互斥的同場串關組合;未來有真實 SGP 報價時再升級正式盤口檢查。""" + + first_market = str(first.get('market_type') or '') + second_market = str(second.get('market_type') or '') + if first_market == second_market: + return False + + joined = f"{first.get('selection', '')} {second.get('selection', '')}" + if '小 1.5' in joined and '雙方都進球:是' in joined: + return False + if '0-0' in joined and ('大 1.5' in joined or '大 2.5' in joined or '雙方都進球:是' in joined): + return False + if '雙方都進球:否' in joined and '雙方都進球:是' in joined: + return False + return True + + +def _build_same_game_parlays(picks_by_match: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: + sgps: list[dict[str, Any]] = [] + for match_id, picks in picks_by_match.items(): + ranked = _sort_picks(picks) + if len(ranked) < 2: + continue + + first = ranked[0] + second = next((item for item in ranked[1:] if _is_same_game_parlay_compatible(first, item)), None) + if second is None: + continue + + combo_odds = first['target_odds'] * second['target_odds'] + combo_prob = (first['win_prob'] / 100) * (second['win_prob'] / 100) + combo_prob *= 0.68 + required_combo_odds = _minimum_value_odds(combo_prob, 8.0) + target_combo_odds = max(combo_odds, required_combo_odds) + ev = _ev_percent(combo_prob, target_combo_odds) + edge = _edge_percent(combo_prob, target_combo_odds) + if combo_prob < 0.16 or target_combo_odds > 12.0 or ev < 8.0 or edge <= 0: + continue + + sgp_has_market_odds = False + sgp_data_quality = 'mixed' if str(first.get('data_quality')) != str(second.get('data_quality')) else str(first.get('data_quality', 'observed')) + sgp_source_label = '尚無同場串關實際報價' + sgp_source_kind = 'conditional_threshold' + sgp_stake_units = 0.25 + confidence = round(min( + _confidence_score( + combo_prob, + ev, + edge, + 'sgp', + decimal_odds=target_combo_odds, + ), + ((_safe_float(first.get('confidence_score'), 0) + _safe_float(second.get('confidence_score'), 0)) / 2) - 10, + ), 1) + confidence, sgp_stake_units = _adjust_for_data_quality( + confidence=confidence, + stake=sgp_stake_units, + data_quality=sgp_data_quality, + has_market_odds=sgp_has_market_odds, + market_tier='sgp', + ) + + sgps.append( + { + 'match_id': match_id, + 'match_label': f"{first['match_label']}【同場】", + 'market_type': '同場串關', + 'selection': f"{first['selection']} + {second['selection']}|預掛總賠率", + 'legs': [ + {'match_id': match_id, 'selection': f"{first['market_type']}|{first['selection']}", 'odds': first['target_odds']}, + {'match_id': match_id, 'selection': f"{second['market_type']}|{second['selection']}", 'odds': second['target_odds']}, + ], + 'target_odds': round(target_combo_odds, 2), + 'win_prob': round(combo_prob * 100, 2), + 'ev_percent': round(ev, 2), + 'stake_units': sgp_stake_units, + 'stake_amount_twd': _stake_amount_twd(sgp_stake_units), + 'unit_size_twd': DEFAULT_UNIT_STAKE_TWD, + 'recommendation': 'SGP_LOTTERY', + 'risk_level': 'sgp', + 'confidence_score': confidence, + 'confidence_band': _confidence_band(confidence, sgp_has_market_odds, 'sgp'), + 'confidence_factors': _confidence_factors( + true_prob=combo_prob, + ev_percent=ev, + edge_percent=edge, + data_quality=sgp_data_quality, + has_market_odds=sgp_has_market_odds, + market_tier='sgp', + ), + 'data_quality': sgp_data_quality, + 'has_market_odds': sgp_has_market_odds, + 'odds_source_label': sgp_source_label or '模型推算最低門檻', + 'odds_source_kind': sgp_source_kind or 'conditional_threshold', + 'sgp_price_status': 'unavailable', + 'market_implied_prob': round((1.0 / target_combo_odds) * 100, 2), + 'edge_percent': round(edge, 2), + 'rationale': ( + f'同場兩腿經相關性 0.68 折減後勝率 {combo_prob * 100:.2f}%,' + f'整張同場串關總賠率需達到 {target_combo_odds:.2f} 以上,EV {ev:.2f}%。目前尚無同場串關實際報價,僅能監控,不可直接下注。' + f'建議上限約新台幣 {_stake_amount_twd(sgp_stake_units):,} 元({sgp_stake_units:.2f}u)。此類組合波動極大,只允許娛樂型小注,不可當核心下注。' + ), + 'data_checks': ['同場不同市場', sgp_source_label or '模型推算最低門檻', '相關性 0.68 折減', '預掛總賠率門檻', '高 EV 門檻', '小倉位上限'] + _quality_checks(sgp_data_quality, sgp_has_market_odds), + } + ) + + return _sort_picks(sgps)[:4] def generate_daily_card(target_date: str, matches: list[dict[str, Any]]) -> dict[str, Any]: - """ - 依賽事快照回傳 4 大區塊策略建議(安全單關、搏冷、高勝率串關、同場串關)。 + """依賽事快照回傳單關、搏冷、跨場串關與同場串關研究候選。""" - 回傳的格式會被前端 /daily-card 與手機版報表一致消化。 - """ + raw_match_count = len(matches) + matches = _merge_match_snapshots(matches) + merged_duplicate_count = max(0, raw_match_count - len(matches)) 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]] = [] - + picks_by_match: dict[str, 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}')) + match_id = str(match.get('match_id', f'match-{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 + match_label = f'{home_team} vs {away_team}' 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 + data_quality = str(match.get('xg_quality') or 'fallback_prior') + has_market_odds = bool(match.get('has_market_odds')) + odds_source_label = str(match.get('odds_source_label') or ('實盤盤口' if has_market_odds else '模型推算最低門檻')) + odds_source_kind = str(match.get('odds_source_kind') or ('market' if has_market_odds else 'conditional_threshold')) + probs = _match_probabilities(home_xg, away_xg) + match_risk: list[dict[str, Any]] = [] + overround_1x2 = _market_overround( + _safe_float(match.get('odds_home'), default=0), + _safe_float(match.get('odds_draw'), default=0), + _safe_float(match.get('odds_away'), default=0), + ) + overround_15 = _market_overround( + _safe_float(match.get('odds_over_15'), default=0), + _safe_float(match.get('odds_under_15'), default=0), + ) + overround_25 = _market_overround( + _safe_float(match.get('odds_over_25'), default=0), + _safe_float(match.get('odds_under_25'), default=0), + ) + overround_35 = _market_overround( + _safe_float(match.get('odds_over_35'), default=0), + _safe_float(match.get('odds_under_35'), default=0), + ) + overround_btts = _market_overround( + _safe_float(match.get('odds_btts_yes'), default=0), + _safe_float(match.get('odds_btts_no'), default=0), + ) - stage = _guess_stage(idx + 1) + market_candidates = [ + { + 'market_type': '雙重機會', + 'selection': f'{home_team} 不敗(主勝或平)', + 'odds': 0, + 'prob': probs['home_or_draw'], + 'tier': 'low_odds', + 'checks': ['勝平負合成', '雙重機會', '低賠率保護', '預掛最低賠率'], + }, + { + 'market_type': '雙重機會', + 'selection': f'{away_team} 不敗(客勝或平)', + 'odds': 0, + 'prob': probs['away_or_draw'], + 'tier': 'low_odds', + 'checks': ['勝平負合成', '雙重機會', '低賠率保護', '預掛最低賠率'], + }, + { + 'market_type': '平手退回', + 'selection': f'{home_team} 平手退回', + 'odds': 0, + 'prob': probs['home_dnb'], + 'tier': 'core', + 'checks': ['去除平局風險', '勝負機率重算', 'DNB 玩法', '預掛最低賠率'], + }, + { + 'market_type': '平手退回', + 'selection': f'{away_team} 平手退回', + 'odds': 0, + 'prob': probs['away_dnb'], + 'tier': 'core', + 'checks': ['去除平局風險', '勝負機率重算', 'DNB 玩法', '預掛最低賠率'], + }, + { + 'market_type': '大小球 1.5', + 'selection': '大 1.5 球', + 'odds': _safe_float(match.get('odds_over_15'), default=0), + 'prob': probs['over_15'], + 'tier': 'totals', + 'checks': ['總進球 Poisson', '1.5 球門檻', '進球下限檢查', '預掛最低賠率'], + }, + { + 'market_type': '大小球 1.5', + 'selection': '小 1.5 球', + 'odds': _safe_float(match.get('odds_under_15'), default=0), + 'prob': probs['under_15'], + 'tier': 'totals', + 'checks': ['總進球 Poisson', '1.5 球門檻', '低比分劇本', '預掛最低賠率'], + }, + { + 'market_type': '勝平負', + 'selection': f'{home_team} 主勝', + 'odds': _safe_float(match.get('odds_home'), default=0), + 'prob': probs['home_win'], + 'tier': 'core', + 'checks': ['Poisson 勝平負', 'xG 差距校準', '市場隱含機率', '正 EV 過濾'], + }, + { + 'market_type': '勝平負', + 'selection': '平局', + 'odds': _safe_float(match.get('odds_draw'), default=0), + 'prob': probs['draw'], + 'tier': 'speculative', + 'checks': ['Poisson 平局機率', '節奏收斂檢查', '高 EV 門檻', '小倉位限制'], + }, + { + 'market_type': '勝平負', + 'selection': f'{away_team} 客勝', + 'odds': _safe_float(match.get('odds_away'), default=0), + 'prob': probs['away_win'], + 'tier': 'core', + 'checks': ['Poisson 勝平負', 'xG 差距校準', '市場隱含機率', '正 EV 過濾'], + }, + { + 'market_type': '大小球 2.5', + 'selection': '大 2.5 球', + 'odds': _safe_float(match.get('odds_over_25'), default=0), + 'prob': probs['over_25'], + 'tier': 'totals', + 'checks': ['總進球 Poisson', '2.5 球門檻', '攻守 xG 合成', '正 EV 過濾'], + }, + { + 'market_type': '大小球 2.5', + 'selection': '小 2.5 球', + 'odds': _safe_float(match.get('odds_under_25'), default=0), + 'prob': probs['under_25'], + 'tier': 'totals', + 'checks': ['總進球 Poisson', '2.5 球門檻', '節奏偏慢檢查', '正 EV 過濾'], + }, + { + 'market_type': '大小球 3.5', + 'selection': '大 3.5 球', + 'odds': _safe_float(match.get('odds_over_35'), default=0), + 'prob': probs['over_35'], + 'tier': 'totals', + 'checks': ['總進球 Poisson', '3.5 球門檻', '高比分劇本', '預掛最低賠率'], + }, + { + 'market_type': '大小球 3.5', + 'selection': '小 3.5 球', + 'odds': _safe_float(match.get('odds_under_35'), default=0), + 'prob': probs['under_35'], + 'tier': 'totals', + 'checks': ['總進球 Poisson', '3.5 球門檻', '節奏風險控管', '預掛最低賠率'], + }, + { + 'market_type': '雙方進球', + 'selection': '雙方都進球:是', + 'odds': _safe_float(match.get('odds_btts_yes'), default=0), + 'prob': probs['btts_yes'], + 'tier': 'btts', + 'checks': ['雙隊進球分布', '雙方 xG 下限', '市場隱含機率', '正 EV 過濾'], + }, + { + 'market_type': '雙方進球', + 'selection': '雙方都進球:否', + 'odds': _safe_float(match.get('odds_btts_no'), default=0), + 'prob': probs['btts_no'], + 'tier': 'btts', + 'checks': ['零封機率估計', '雙方 xG 下限', '市場隱含機率', '正 EV 過濾'], + }, + { + 'market_type': '隊伍總進球', + 'selection': f'{home_team} 大 0.5 球', + 'odds': 0, + 'prob': probs['home_over_05'], + 'tier': 'team_total', + 'checks': ['單隊 Poisson', '主隊進球下限', '隊伍總進球玩法', '預掛最低賠率'], + }, + { + 'market_type': '隊伍總進球', + 'selection': f'{away_team} 大 0.5 球', + 'odds': 0, + 'prob': probs['away_over_05'], + 'tier': 'team_total', + 'checks': ['單隊 Poisson', '客隊進球下限', '隊伍總進球玩法', '預掛最低賠率'], + }, + { + 'market_type': '隊伍總進球', + 'selection': f'{home_team} 小 1.5 球', + 'odds': 0, + 'prob': probs['home_under_15'], + 'tier': 'team_total', + 'checks': ['單隊 Poisson', '主隊進球上限', '隊伍總進球玩法', '預掛最低賠率'], + }, + { + 'market_type': '隊伍總進球', + 'selection': f'{away_team} 小 1.5 球', + 'odds': 0, + 'prob': probs['away_under_15'], + 'tier': 'team_total', + 'checks': ['單隊 Poisson', '客隊進球上限', '隊伍總進球玩法', '預掛最低賠率'], + }, + ] - # 安全單關:偏向高勝率市場 - 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( + for h_score, a_score, score_prob in _top_exact_scores(home_xg, away_xg, limit=2): + market_candidates.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), - }, + 'market_type': '正確比分', + 'selection': f'{home_team} {h_score}-{a_score} {away_team}', + 'odds': 0, + 'prob': score_prob, + 'tier': 'exact_score', + 'checks': ['比分矩陣排序', 'Poisson 精準比分', '高波動玩法', '小倉位上限'], + } ) + for candidate in market_candidates: + overround = None + if candidate['market_type'] == '勝平負': + overround = overround_1x2 + elif candidate['market_type'] == '大小球 1.5': + overround = overround_15 + elif candidate['market_type'] == '大小球 2.5': + overround = overround_25 + elif candidate['market_type'] == '大小球 3.5': + overround = overround_35 + elif candidate['market_type'] == '雙方進球': + overround = overround_btts + + if overround is not None and candidate['odds'] > 1: + candidate['market_overround'] = overround + candidate['fair_implied_prob'] = _no_vig_probability(candidate['odds'], overround) + + match_safe: list[dict[str, Any]] = [] + for candidate in market_candidates: + safe_rules = { + 'core': {'min_win_prob': 0.48, 'min_ev': 2.0, 'min_edge': 0.7, 'stake_cap': 1.5}, + 'low_odds': {'min_win_prob': 0.68, 'min_ev': 1.2, 'min_edge': 0.4, 'stake_cap': 1.1}, + 'totals': {'min_win_prob': 0.50, 'min_ev': 1.8, 'min_edge': 0.6, 'stake_cap': 1.25}, + 'btts': {'min_win_prob': 0.48, 'min_ev': 2.0, 'min_edge': 0.7, 'stake_cap': 1.1}, + 'team_total': {'min_win_prob': 0.56, 'min_ev': 1.8, 'min_edge': 0.6, 'stake_cap': 1.0}, + 'speculative': {'min_win_prob': 0.34, 'min_ev': 7.5, 'min_edge': 2.0, 'stake_cap': 0.75}, + 'exact_score': {'min_win_prob': 0.08, 'min_ev': 12.0, 'min_edge': 3.0, 'stake_cap': 0.25}, + }.get(candidate['tier'], {'min_win_prob': 0.52, 'min_ev': 3.0, 'min_edge': 1.0, 'stake_cap': 1.2}) + + if candidate['odds'] <= 1: + is_speculative = candidate['tier'] in {'speculative', 'exact_score'} + conditional_min_ev = 12.0 if candidate['tier'] == 'exact_score' else 9.0 if is_speculative else safe_rules['min_ev'] + conditional_max_odds = 18.0 if candidate['tier'] == 'exact_score' else 6.5 if is_speculative else 3.4 + conditional_pick = _build_conditional_pick( + match_id=match_id, + match_label=match_label, + market_type=candidate['market_type'], + selection=candidate['selection'], + true_prob=candidate['prob'], + recommendation='HIGH_RISK_SINGLE' if is_speculative else 'SAFE_SINGLE', + risk_level='speculative' if is_speculative else 'core', + market_tier='speculative' if is_speculative else candidate['tier'], + min_win_prob=safe_rules['min_win_prob'], + min_ev=conditional_min_ev, + stake_cap=0.25 if candidate['tier'] == 'exact_score' else 0.45 if is_speculative else safe_rules['stake_cap'], + max_target_odds=conditional_max_odds, + data_checks=candidate['checks'], + data_quality=data_quality, + has_market_odds=False, + odds_source_label='模型推算最低門檻', + odds_source_kind='conditional_threshold', + ) + if conditional_pick: + if is_speculative: + match_risk.append(conditional_pick) + else: + match_safe.append(conditional_pick) + continue + + formal_market_odds = _is_formal_market_source(odds_source_kind) + safe_pick = _build_pick( + match_id=match_id, + match_label=match_label, + market_type=candidate['market_type'], + selection=candidate['selection'], + odds=candidate['odds'], + true_prob=candidate['prob'], + recommendation='SAFE_SINGLE', + risk_level='core', + market_tier=candidate['tier'], + min_win_prob=safe_rules['min_win_prob'], + min_ev=safe_rules['min_ev'], + min_edge=safe_rules['min_edge'], + stake_cap=safe_rules['stake_cap'], + data_checks=candidate['checks'], + fair_implied_prob=candidate.get('fair_implied_prob'), + market_overround=candidate.get('market_overround'), + data_quality=data_quality, + has_market_odds=formal_market_odds, + odds_source_label=odds_source_label, + odds_source_kind=odds_source_kind, + ) + if safe_pick: + match_safe.append(safe_pick) + continue + + risk_pick = _build_pick( + match_id=match_id, + match_label=match_label, + market_type=candidate['market_type'], + selection=f"{candidate['selection']} 小倉", + odds=candidate['odds'], + true_prob=candidate['prob'], + recommendation='HIGH_RISK_SINGLE', + risk_level='speculative', + market_tier='speculative', + min_win_prob=0.20, + min_ev=9.0, + min_edge=2.5, + stake_cap=0.55, + data_checks=candidate['checks'] + ['高波動玩法', '小倉位上限'], + fair_implied_prob=candidate.get('fair_implied_prob'), + market_overround=candidate.get('market_overround'), + data_quality=data_quality, + has_market_odds=formal_market_odds, + odds_source_label=odds_source_label, + odds_source_kind=odds_source_kind, + ) + if risk_pick: + match_risk.append(risk_pick) + + if match_safe: + # Keep multiple markets per match but avoid flooding one game. + for item in _dedupe_market_conflicts(match_safe)[:3]: + safe_singles.append(item) + picks_by_match.setdefault(match_id, []).append(item) + + if match_risk: + high_risk_singles.extend(_dedupe_market_conflicts(match_risk)[:2]) + + safe_singles = _dedupe_market_conflicts(safe_singles)[:16] + safe_conflict_keys = {_market_conflict_key(item) for item in safe_singles} + high_risk_singles = _dedupe_market_conflicts( + [item for item in high_risk_singles if _market_conflict_key(item) not in safe_conflict_keys] + )[:10] + safe_parlays = _build_cross_match_parlays(safe_singles) + sgp_lotteries = _build_same_game_parlays(picks_by_match) + + for bucket in (safe_singles, high_risk_singles, safe_parlays, sgp_lotteries): + total_unit += sum(_safe_float(item.get('stake_units'), 0) for item in bucket) + + if safe_singles or high_risk_singles or safe_parlays or sgp_lotteries: + merge_note = f' 已合併 {merged_duplicate_count} 筆同場重複來源,避免別名賽事洗版。' if merged_duplicate_count else '' + summary = ( + f'掃描 {len(matches)} 場賽事後,產出 {len(safe_singles)} 組單關、' + f'{len(high_risk_singles)} 組小倉高賠、{len(safe_parlays)} 組跨場串關、' + f'{len(sgp_lotteries)} 組同場小倉候選。所有候選均重新計算勝率、期望值、模型優勢、資料折扣與新台幣參考上限。{merge_note}' + ) + else: + merge_note = f' 已合併 {merged_duplicate_count} 筆同場重複來源。' if merged_duplicate_count else '' + summary = ( + f'掃描 {len(matches)} 場賽事後,尚未出現同時滿足勝率、正期望值、' + f'市場隱含機率偏差與倉位上限的多玩法推薦。此時應等待盤口更新,不硬推單。{merge_note}' + ) + + all_items = [*safe_singles, *high_risk_singles, *safe_parlays, *sgp_lotteries] + live_market_count = sum(1 for item in all_items if bool(item.get('has_market_odds'))) + pre_market_count = len(all_items) - live_market_count + quality_counts: dict[str, int] = {} + for item in all_items: + quality = str(item.get('data_quality') or 'unknown') + quality_counts[quality] = quality_counts.get(quality, 0) + 1 + return { 'date': target_date, 'total_daily_unit_recommendation': round(total_unit, 2), - 'summary': ( - '系統以當日賽程、赔率變動、xG 進攻強度與場次權重回填,' - '優先輸出高穩定性單關與可控風險的串關建議。' + 'total_daily_amount_twd': _stake_amount_twd(total_unit), + 'unit_size_twd': DEFAULT_UNIT_STAKE_TWD, + 'summary': summary, + 'market_data_status': 'live_market_available' if live_market_count > 0 else 'pre_market_watchlist', + 'data_quality_summary': { + **quality_counts, + 'live_market_count': live_market_count, + 'pre_market_count': pre_market_count, + }, + 'execution_policy': ( + '已有實盤盤口的候選可進入下注前檢查;尚未取得實盤者只能加入賠率監控,' + '必須等平台賠率達到最低可接受賠率、且先發/傷停/盤口分線一致後才可下注。' ), + 'auto_refresh_seconds': 60, '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), - }, + 'raw_match_count': raw_match_count, + 'merged_duplicate_match_count': merged_duplicate_count, + 'stage_distribution': {'賽前': len(matches)}, } diff --git a/platform/backend/app/analytics/portfolio_analyzer.py b/platform/backend/app/analytics/portfolio_analyzer.py index a2a539b..2e25fc0 100644 --- a/platform/backend/app/analytics/portfolio_analyzer.py +++ b/platform/backend/app/analytics/portfolio_analyzer.py @@ -138,7 +138,7 @@ def analyze_user_leaks(user_bets: list[dict[str, Any]]) -> dict[str, Any]: 'closed_count': 0, 'win_count': 0, 'total_pnl': 0.0, - 'clv_values': [] as list[float], + 'clv_values': [], }, ) diff --git a/platform/backend/app/db/models.py b/platform/backend/app/db/models.py index f3d55c0..9aba814 100644 --- a/platform/backend/app/db/models.py +++ b/platform/backend/app/db/models.py @@ -3,7 +3,7 @@ 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 Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, func from sqlalchemy import Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -82,6 +82,9 @@ class Match(Base): ) home_xg: Mapped[float | None] = mapped_column(Float, nullable=True) away_xg: Mapped[float | None] = mapped_column(Float, nullable=True) + home_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + away_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + result_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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') @@ -104,8 +107,12 @@ class OddsHistory(Base): 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) + market_line: Mapped[float | None] = mapped_column(Float, nullable=True) + handicap: Mapped[float | None] = mapped_column(Float, nullable=True) decimal_odds: Mapped[float] = mapped_column(Float, nullable=False) implied_probability: Mapped[float] = mapped_column(Float, nullable=False) + source_market_key: Mapped[str | None] = mapped_column(String(80), nullable=True) + source_outcome_name: Mapped[str | None] = mapped_column(String(140), nullable=True) recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) match: Mapped[Match] = relationship('Match', back_populates='odds_history') @@ -149,6 +156,22 @@ class ValueBetRecommendation(Base): match: Mapped[Match] = relationship('Match', back_populates='recommendations') +class DailyRecommendationSnapshot(Base): + """每日作戰室賽前推薦快照,用於賽後真實稽核。""" + + __tablename__ = 'daily_recommendation_snapshots' + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + target_date: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + live_market_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + pre_market_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + payload: Mapped[dict] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), onupdate=func.now()) + + class AffiliateBookmaker(Base): """聯盟行銷博彩公司追蹤碼設定。""" diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py index f6e4693..e2045f7 100644 --- a/platform/backend/app/main.py +++ b/platform/backend/app/main.py @@ -2,12 +2,14 @@ import asyncio import json import logging import os +import re import contextlib from collections import defaultdict -from datetime import datetime +from datetime import datetime, time, timedelta, timezone from typing import Any, Mapping from uuid import uuid4 +import httpx from sqlalchemy import asc, desc, select, func from sqlalchemy.orm import aliased from sqlalchemy.exc import SQLAlchemyError @@ -17,7 +19,17 @@ 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 .db.models import Bookmaker, DailyRecommendationSnapshot, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue +from .analytics.daily_card_generator import generate_daily_card +from .analytics.localization import ( + localize_city, + localize_country, + localize_market_type, + localize_selection, + localize_status, + localize_team_name, + localize_venue_name, +) from .analytics import ( BacktestTradeRecord, @@ -47,6 +59,8 @@ from .analytics import ( ) logging.basicConfig(level=logging.INFO) +logging.getLogger('httpx').setLevel(logging.WARNING) +logging.getLogger('httpcore').setLevel(logging.WARNING) logger = logging.getLogger('fifa2026-ws') @@ -96,6 +110,50 @@ 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') +AGENT_DAILY_REVIEW_STATUS_KEY = 'agent:daily_review:last_run' +AGENT_DAILY_REVIEW_CACHE_TTL_SECONDS = max(3600, int(os.environ.get('AGENT_REVIEW_CACHE_TTL_SECONDS', str(72 * 3600)))) +DAILY_CARD_CALENDAR_STATUS_KEY = 'daily-card-calendar:last_run' +DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS = max( + 60, + int(os.environ.get('DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS', '420')), +) + +TEAM_STRENGTH_PRIORS: dict[str, tuple[int, float]] = { + 'argentina': (1, 1995), + 'france': (2, 1985), + 'spain': (3, 1950), + 'england': (4, 1940), + 'brazil': (5, 1935), + 'portugal': (6, 1905), + 'netherlands': (7, 1885), + 'belgium': (8, 1875), + 'germany': (9, 1865), + 'italy': (10, 1845), + 'uruguay': (11, 1830), + 'croatia': (12, 1815), + 'mexico': (13, 1795), + 'usa': (14, 1785), + 'united states': (14, 1785), + 'usmnt': (14, 1785), + 'colombia': (15, 1780), + 'morocco': (16, 1775), + 'switzerland': (17, 1765), + 'japan': (18, 1760), + 'senegal': (19, 1745), + 'denmark': (20, 1740), + 'iran': (21, 1725), + 'sweden': (22, 1720), + 'australia': (25, 1695), + 'turkiye': (26, 1690), + 'turkey': (26, 1690), + 'ecuador': (27, 1685), + "cote d'ivoire": (28, 1680), + 'cote d’ivoire': (28, 1680), + 'ivory coast': (28, 1680), + 'tunisia': (34, 1625), + 'curacao': (86, 1450), + 'curaçao': (86, 1450), +} class PlayerPropsRequest(BaseModel): @@ -416,6 +474,9 @@ class DailyCardLeg(BaseModel): match_id: str selection: str odds: float = Field(..., gt=1) + market_type: str | None = None + has_market_odds: bool | None = None + odds_source_label: str | None = None class DailyCardItem(BaseModel): @@ -427,27 +488,162 @@ class DailyCardItem(BaseModel): win_prob: float ev_percent: float stake_units: float = Field(..., ge=0) + stake_amount_twd: int | None = None + unit_size_twd: int | None = None recommendation: str rationale: str + confidence_score: float | None = None + confidence_band: str | None = None + confidence_factors: list[str] | None = None + data_quality: str | None = None + has_market_odds: bool | None = None + odds_source_label: str | None = None + odds_source_kind: str | None = None + risk_level: str | None = None + market_implied_prob: float | None = None + edge_percent: float | None = None + data_checks: list[str] | None = None legs: list[DailyCardLeg] | None = None + sgp_price_status: str | None = None class DailyCardResponse(BaseModel): date: str total_daily_unit_recommendation: float + total_daily_amount_twd: int | None = None + unit_size_twd: int | None = None summary: str + market_data_status: str | None = None + data_quality_summary: dict[str, int] = Field(default_factory=dict) + execution_policy: str | None = None + auto_refresh_seconds: int = 60 safe_singles: list[DailyCardItem] high_risk_singles: list[DailyCardItem] safe_parlays: list[DailyCardItem] sgp_lotteries: list[DailyCardItem] matched_matches: int + raw_match_count: int | None = None + merged_duplicate_match_count: int | None = None + dedupe_notes: list[str] | None = None stage_distribution: dict[str, int] +class RecommendationPerformanceItem(BaseModel): + match_id: str + match_label: str + market_type: str + selection: str + recommendation: str + result_score: str + outcome: str + outcome_label: str + target_odds: float + win_prob: float + ev_percent: float + stake_units: float + stake_amount_twd: int | None = None + confidence_score: float | None = None + confidence_band: str | None = None + has_market_odds: bool | None = None + odds_source_label: str | None = None + odds_source_kind: str | None = None + lesson: str + + +class RecommendationPerformanceBucket(BaseModel): + market_type: str + recommendation_count: int + settled_count: int + hit_count: int + miss_count: int + push_count: int + hit_rate_percent: float + + +class RecommendationPerformanceSourceBucket(BaseModel): + source_label: str + source_kind: str + recommendation_count: int + settled_count: int + hit_count: int + miss_count: int + push_count: int + hit_rate_percent: float + + +class RecommendationPerformanceResponse(BaseModel): + generated_at: str + days_back: int + finished_match_count: int + rebuilt_recommendation_count: int + settled_recommendation_count: int + hit_count: int + miss_count: int + push_count: int + hit_rate_percent: float + summary: str + methodology_note: str + improvement_actions: list[str] + by_market_type: list[RecommendationPerformanceBucket] + by_odds_source: list[RecommendationPerformanceSourceBucket] + items: list[RecommendationPerformanceItem] + + +class AgentVerificationCheck(BaseModel): + agent: str + role: str + status: str + status_label: str + evidence: list[str] + next_action: str | None = None + last_checked_at: str + + +class AgentVerificationResponse(BaseModel): + generated_at: str + overall_status: str + overall_label: str + production_ready: bool + decision_policy: str + calibration_summary: dict[str, Any] = Field(default_factory=dict) + checks: list[AgentVerificationCheck] + + +class GeminiUsageResponse(BaseModel): + generated_at: str + month: str + status: str + status_label: str + paused: bool + cap_usd: float + estimated_cost_usd: float + remaining_usd: float + request_count: int + input_tokens: int + output_tokens: int + grounded_query_count: int + pricing_note: str + next_action: str | None = None + + +class AgentDailyReviewResponse(BaseModel): + generated_at: str + date: str + status: str + status_label: str + model: str + reviewed_count: int + summary: str + raw_response: str | None = None + guardrails: list[str] + + class MatchListItem(BaseModel): match_id: str home_team: str away_team: str + home_score: int | None = None + away_score: int | None = None kickoff_utc: datetime status: str venue_name: str | None = None @@ -487,6 +683,8 @@ class MatchDetailResponse(BaseModel): match_id: str home_team: str away_team: str + home_score: int | None = None + away_score: int | None = None home_xg: float away_xg: float match_time_utc: str @@ -496,11 +694,32 @@ class MatchDetailResponse(BaseModel): venue_country: str venue_altitude_meters: int | None odds_series: list[MatchOddsPoint] + odds_quality: str = 'missing_market' + xg_quality: str = 'observed' poisson: MatchPoissonOutput conditions: MatchConditionReadout quant_summary: str +class SourceHealthResponse(BaseModel): + status: str + odds_coverage_status: str = 'unknown' + upcoming_odds_matches: int = 0 + stale_unsettled_matches: int = 0 + stale_unsettled_threshold_hours: int = 3 + odds_rows: int + matches: int + finished_matches: int + venues: int + high_altitude_venues: int + latest_odds_recorded_at: str | None = None + latest_result_synced_at: str | None = None + ingestion_status: dict[str, Any] | None = None + fixtures_status: dict[str, Any] | None = None + news_status: dict[str, Any] | None = None + provider_requirements: dict[str, Any] = Field(default_factory=dict) + + @app.get('/health') async def health() -> dict[str, str]: return { @@ -509,6 +728,430 @@ async def health() -> dict[str, str]: } +@app.get('/analytics/source-health', response_model=SourceHealthResponse) +async def analytics_source_health() -> SourceHealthResponse: + """回傳資料源新鮮度,讓前端能明確標示盤口是否仍在更新。""" + + now = datetime.now(timezone.utc) + stale_threshold_hours = 3 + + async with SessionFactory() as session: + match_count_result = await session.execute(select(func.count()).select_from(Match)) + finished_count_result = await session.execute( + select(func.count()).select_from(Match).where(Match.status == MatchStatus.FINISHED) + ) + odds_count_result = await session.execute(select(func.count()).select_from(OddsHistory)) + venue_count_result = await session.execute(select(func.count()).select_from(Venue)) + high_altitude_result = await session.execute( + select(func.count()).select_from(Venue).where(Venue.altitude_meters >= 1500) + ) + latest_odds_result = await session.execute(select(func.max(OddsHistory.recorded_at))) + latest_result_result = await session.execute(select(func.max(Match.result_synced_at))) + stale_unsettled_result = await session.execute( + select(func.count()) + .select_from(Match) + .where( + Match.status != MatchStatus.FINISHED, + Match.match_time_utc < now - timedelta(hours=stale_threshold_hours), + ) + ) + upcoming_odds_result = await session.execute( + select(func.count(func.distinct(OddsHistory.match_id))) + .join(Match, OddsHistory.match_id == Match.id) + .where( + Match.match_time_utc >= now - timedelta(minutes=20), + Match.status != MatchStatus.FINISHED, + ) + ) + + ingestion_status: dict[str, Any] | None = None + fixtures_status: dict[str, Any] | None = None + news_status: dict[str, Any] | None = None + try: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + raw_status = await redis.get('ingestion:odds:last_run') + raw_fixtures_status = await redis.get('ingestion:fixtures:last_run') + raw_news_status = await redis.get('ingestion:news:last_run') + await redis.aclose() + if raw_status: + parsed = json.loads(raw_status) + if isinstance(parsed, dict): + ingestion_status = parsed + if raw_fixtures_status: + parsed_fixtures = json.loads(raw_fixtures_status) + if isinstance(parsed_fixtures, dict): + fixtures_status = parsed_fixtures + if raw_news_status: + parsed_news = json.loads(raw_news_status) + if isinstance(parsed_news, dict): + news_status = parsed_news + except Exception as exc: + ingestion_status = { + 'status': 'unknown', + 'message': f'Redis ingestion 狀態暫時無法讀取:{exc}', + } + + latest_odds = latest_odds_result.scalar_one_or_none() + odds_rows = int(odds_count_result.scalar_one() or 0) + matches = int(match_count_result.scalar_one() or 0) + finished_matches = int(finished_count_result.scalar_one() or 0) + stale_unsettled_matches = int(stale_unsettled_result.scalar_one() or 0) + try: + logical_matches = await _query_match_list(limit=5000) + if logical_matches: + matches = len(logical_matches) + finished_matches = sum(1 for item in logical_matches if item.get('status') == '已完賽') + logical_stale = 0 + for item in logical_matches: + if item.get('status') == '已完賽': + continue + kickoff_raw = str(item.get('kickoff_utc') or '').replace('Z', '+00:00') + try: + kickoff_at = datetime.fromisoformat(kickoff_raw) + except ValueError: + continue + if kickoff_at.tzinfo is None: + kickoff_at = kickoff_at.replace(tzinfo=timezone.utc) + if kickoff_at < now - timedelta(hours=stale_threshold_hours): + logical_stale += 1 + stale_unsettled_matches = logical_stale + except Exception: + pass + venues = int(venue_count_result.scalar_one() or 0) + high_altitude_venues = int(high_altitude_result.scalar_one() or 0) + latest_result = latest_result_result.scalar_one_or_none() + upcoming_odds_matches = int(upcoming_odds_result.scalar_one() or 0) + worker_status = str((ingestion_status or {}).get('status') or 'unknown') + source_name = str((ingestion_status or {}).get('source') or 'unknown') + if odds_rows <= 0 or latest_odds is None or worker_status == 'error': + odds_coverage_status = 'stale' + elif 'taiwan-sports-lottery-reference' in source_name: + odds_coverage_status = 'reference_market' + elif source_name != 'the-odds-api': + odds_coverage_status = 'limited_scoreboard_fallback' + elif upcoming_odds_matches <= 0: + odds_coverage_status = 'no_upcoming_market' + else: + odds_coverage_status = 'full_market' + if stale_unsettled_matches > 0: + freshness_status = 'stale' + else: + freshness_status = 'ok' if odds_coverage_status == 'full_market' else 'limited' if odds_coverage_status != 'stale' else 'stale' + + return SourceHealthResponse( + status=freshness_status, + odds_coverage_status=odds_coverage_status, + upcoming_odds_matches=upcoming_odds_matches, + stale_unsettled_matches=stale_unsettled_matches, + stale_unsettled_threshold_hours=stale_threshold_hours, + odds_rows=odds_rows, + matches=matches, + finished_matches=finished_matches, + venues=venues, + high_altitude_venues=high_altitude_venues, + latest_odds_recorded_at=latest_odds.isoformat() if latest_odds else None, + latest_result_synced_at=latest_result.isoformat() if latest_result else None, + ingestion_status=ingestion_status, + fixtures_status=fixtures_status, + news_status=news_status, + provider_requirements={ + 'primary_odds_provider': 'THE_ODDS_API_KEY / The Odds API' if source_name != 'the-odds-api' else 'the-odds-api', + 'odds_feed_markets': [ + 'h2h', + 'spreads', + 'totals', + 'btts', + 'draw_no_bet', + 'h2h_3_way', + 'alternate_spreads', + 'alternate_totals', + 'team_totals', + 'alternate_team_totals', + ], + 'derived_recommendation_markets': ['double_chance'], + 'taiwan_sports_lottery': '已確認公開世界盃參考盤端點 Pre/WC-Games.zh.json;目前定位為台灣盤比對與最低可接受賠率參考,不等同多莊家正式 provider。', + 'taiwan_sports_lottery_status': 'reference_market_enabled' if 'taiwan-sports-lottery-reference' in source_name else 'reference_market_waiting', + 'current_limitation': '目前若未接入多莊家正式 odds provider,未來賽事只能產生台灣盤參考與預掛條件,不能包裝成保證高勝率。', + }, + ) + + +@app.get('/analytics/market-coverage') +async def analytics_market_coverage(days_ahead: int = 2) -> dict[str, Any]: + """回傳未來賽事的實際盤口覆蓋率,避免把未接入市場包裝成正式推薦。""" + + safe_days_ahead = max(1, min(days_ahead, 14)) + now = datetime.now(timezone.utc) + window_start = now - timedelta(hours=2) + window_end = now + timedelta(days=safe_days_ahead) + odds_feed_markets = [ + '1x2', + 'asian_handicap', + 'ou', + 'btts', + 'draw_no_bet', + 'team_total', + ] + core_markets = {'1x2', 'asian_handicap', 'ou'} + minimum_bookmakers = 2 + + async with SessionFactory() as session: + market_rows_result = await session.execute( + select( + OddsHistory.market_type, + func.count(OddsHistory.id), + func.count(func.distinct(OddsHistory.match_id)), + func.count(func.distinct(OddsHistory.bookmaker_id)), + func.max(OddsHistory.recorded_at), + ) + .join(Match, OddsHistory.match_id == Match.id) + .where( + Match.match_time_utc >= window_start, + Match.match_time_utc <= window_end, + Match.status != MatchStatus.FINISHED, + ) + .group_by(OddsHistory.market_type) + .order_by(OddsHistory.market_type.asc()) + ) + match_count_result = await session.execute( + select(func.count()) + .select_from(Match) + .where( + Match.match_time_utc >= window_start, + Match.match_time_utc <= window_end, + Match.status != MatchStatus.FINISHED, + ) + ) + market_rows = market_rows_result.all() + upcoming_matches = int(match_count_result.scalar_one() or 0) + + coverage = [] + covered_market_keys = set() + formal_market_keys = set() + for market_type, rows, matches, bookmakers, latest_recorded_at in market_rows: + market_key = str(market_type) + covered_market_keys.add(market_key) + usable_for_watchlist = int(matches or 0) > 0 and int(bookmakers or 0) >= 1 + passes_formal_minimum = int(matches or 0) > 0 and int(bookmakers or 0) >= minimum_bookmakers + if passes_formal_minimum: + formal_market_keys.add(market_key) + coverage.append( + { + 'market_type': market_key, + 'label': localize_market_type(market_key), + 'odds_rows': int(rows or 0), + 'match_count': int(matches or 0), + 'bookmaker_count': int(bookmakers or 0), + 'latest_recorded_at': latest_recorded_at.isoformat() if latest_recorded_at else None, + 'usable_for_watchlist': usable_for_watchlist, + 'passes_formal_minimum': passes_formal_minimum, + 'is_usable_for_recommendations': passes_formal_minimum, + } + ) + + missing_markets = [market for market in odds_feed_markets if market not in covered_market_keys] + core_formal_ready = core_markets.issubset(formal_market_keys) + source_status = 'full_market' if upcoming_matches > 0 and core_formal_ready else 'limited_market' + + return { + 'status': source_status, + 'window': { + 'from': window_start.isoformat(), + 'to': window_end.isoformat(), + 'days_ahead': safe_days_ahead, + }, + 'upcoming_match_count': upcoming_matches, + 'coverage': coverage, + 'missing_markets': [ + { + 'market_type': market, + 'label': localize_market_type(market), + 'reason': '未來賽事在此玩法尚無可驗證賠率列', + } + for market in missing_markets + ], + 'derived_markets': [ + { + 'market_type': 'double_chance', + 'label': '雙重機會', + 'source': '由勝平負公平機率推導,不是獨立 odds feed key', + } + ], + 'recommendation_policy': '只有 passes_formal_minimum=true 的玩法,才可升級為正式實盤推薦;只有 1 家來源時只能列入監控與賠率比對,不能包裝成正式高信心下注。', + } + + +@app.get('/analytics/recommendation-readiness') +async def analytics_recommendation_readiness(days_ahead: int = 2) -> dict[str, Any]: + """判斷目前是否具備發布正式實盤投注推薦的最低資料條件。""" + + safe_days_ahead = max(1, min(days_ahead, 14)) + now = datetime.now(timezone.utc) + window_start = now - timedelta(hours=2) + window_end = now + timedelta(days=safe_days_ahead) + core_markets = ['1x2', 'asian_handicap', 'ou'] + secondary_markets = ['btts', 'draw_no_bet', 'team_total'] + minimum_bookmakers = 2 + minimum_core_market_matches = 1 + + ingestion_status: dict[str, Any] | None = None + try: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + raw_status = await redis.get('ingestion:odds:last_run') + await redis.aclose() + if raw_status: + parsed_status = json.loads(raw_status) + if isinstance(parsed_status, dict): + ingestion_status = parsed_status + except Exception as exc: + ingestion_status = { + 'status': 'unknown', + 'message': f'Redis ingestion 狀態暫時無法讀取:{exc}', + } + + async with SessionFactory() as session: + upcoming_match_count_result = await session.execute( + select(func.count()) + .select_from(Match) + .where( + Match.match_time_utc >= window_start, + Match.match_time_utc <= window_end, + Match.status != MatchStatus.FINISHED, + ) + ) + market_rows_result = await session.execute( + select( + OddsHistory.market_type, + func.count(func.distinct(OddsHistory.match_id)), + func.count(func.distinct(OddsHistory.bookmaker_id)), + func.count(OddsHistory.id), + func.max(OddsHistory.recorded_at), + ) + .join(Match, OddsHistory.match_id == Match.id) + .where( + Match.match_time_utc >= window_start, + Match.match_time_utc <= window_end, + Match.status != MatchStatus.FINISHED, + ) + .group_by(OddsHistory.market_type) + ) + + upcoming_match_count = int(upcoming_match_count_result.scalar_one() or 0) + market_status: dict[str, dict[str, Any]] = {} + for market_type, match_count, bookmaker_count, odds_rows, latest_recorded_at in market_rows_result.all(): + key = str(market_type) + market_status[key] = { + 'market_type': key, + 'label': localize_market_type(key), + 'match_count': int(match_count or 0), + 'bookmaker_count': int(bookmaker_count or 0), + 'odds_rows': int(odds_rows or 0), + 'latest_recorded_at': latest_recorded_at.isoformat() if latest_recorded_at else None, + 'passes_minimum': int(match_count or 0) >= minimum_core_market_matches and int(bookmaker_count or 0) >= minimum_bookmakers, + } + + source_name = str((ingestion_status or {}).get('source') or 'unknown') + worker_status = str((ingestion_status or {}).get('status') or 'unknown') + blocking_reasons: list[str] = [] + warnings: list[str] = [] + + if upcoming_match_count <= 0: + blocking_reasons.append('未來視窗內沒有可分析賽事。') + if source_name != 'the-odds-api': + if 'taiwan-sports-lottery-reference' in source_name: + blocking_reasons.append('目前包含台灣運彩參考盤,但仍不是多莊家正式 odds provider;只能作為最低賠率比對與預掛觀察。') + else: + blocking_reasons.append('目前賠率來源不是正式 odds provider,只能使用比分備援與預掛觀察。') + if worker_status == 'error': + blocking_reasons.append('賠率 worker 最近一次執行失敗。') + + for market in core_markets: + status = market_status.get(market) + if not status: + blocking_reasons.append(f'{localize_market_type(market)} 尚無可驗證賠率列。') + continue + if int(status['bookmaker_count']) < minimum_bookmakers: + blocking_reasons.append(f'{localize_market_type(market)} 莊家數不足,至少需要 {minimum_bookmakers} 家可比價來源。') + if int(status['match_count']) < minimum_core_market_matches: + blocking_reasons.append(f'{localize_market_type(market)} 尚未覆蓋未來賽事。') + + for market in secondary_markets: + if market not in market_status: + warnings.append(f'{localize_market_type(market)} 尚未覆蓋;可保留為預掛或模型推導,不升級為正式推薦。') + + formal_recommendations_allowed = len(blocking_reasons) == 0 + if formal_recommendations_allowed: + status = 'ready_for_market_review' + mode = 'formal_market_recommendations' + headline = '已具備正式實盤推薦的最低資料條件,仍需逐筆檢查 EV、去水機率與風險上限。' + elif upcoming_match_count > 0: + status = 'pre_market_watchlist_only' + mode = 'watchlist_only' + headline = '目前只能發布預掛觀察,不能標示為正式高勝率下注推薦。' + else: + status = 'blocked_no_upcoming_matches' + mode = 'blocked' + headline = '目前沒有未來賽事可供推薦。' + + return { + 'status': status, + 'mode': mode, + 'headline': headline, + 'formal_recommendations_allowed': formal_recommendations_allowed, + 'window': { + 'from': window_start.isoformat(), + 'to': window_end.isoformat(), + 'days_ahead': safe_days_ahead, + }, + 'source': { + 'name': source_name, + 'worker_status': worker_status, + 'last_run': ingestion_status, + }, + 'thresholds': { + 'minimum_bookmakers': minimum_bookmakers, + 'minimum_core_market_matches': minimum_core_market_matches, + 'core_markets': core_markets, + 'secondary_markets': secondary_markets, + }, + 'upcoming_match_count': upcoming_match_count, + 'market_status': [market_status[key] for key in sorted(market_status.keys())], + 'blocking_reasons': blocking_reasons, + 'warnings': warnings, + 'required_actions': [ + '接入正式 THE_ODDS_API_KEY 或等效合法賠率來源。', + '確認未來賽事至少覆蓋勝平負、讓球盤、大小球三個核心玩法。', + f'每個核心玩法至少需要 {minimum_bookmakers} 家莊家可比價,才可標記為正式實盤推薦。', + '缺盤玩法只能出現在預掛觀察,不得用高勝率口吻包裝。', + ], + } + + +@app.get('/analytics/news-snapshot') +async def news_snapshot() -> dict[str, Any]: + """回傳新聞排程 worker 的最新快照。""" + + try: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + raw = await redis.get('news:worldcup:latest') + await redis.aclose() + except Exception as exc: + raise HTTPException(status_code=503, detail=f'新聞快照暫時無法讀取:{exc}') from exc + + if not raw: + raise HTTPException(status_code=404, detail='尚無新聞排程快照') + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=500, detail='新聞快照格式錯誤') from exc + + if not isinstance(parsed, dict): + raise HTTPException(status_code=500, detail='新聞快照格式錯誤') + + return parsed + + def _odds_to_prob(odds: float) -> float: return 1.0 / odds @@ -579,38 +1222,80 @@ async def _query_match_list(limit: int = 200) -> list[dict[str, Any]]: 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, + Match.id, + home_team.name, + away_team.name, + Match.match_time_utc, + Match.status, + Match.home_score, + Match.away_score, + Match.result_synced_at, + 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) ) - .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 - ] + deduped: dict[tuple[str, str, str], dict[str, Any]] = {} + + def quality_rank(payload: dict[str, Any]) -> tuple[int, int, int, int]: + raw_status = str(payload.get('_raw_status') or '').upper() + has_score = int(payload.get('home_score') is not None and payload.get('away_score') is not None) + is_finished = int('FINISHED' in raw_status or payload.get('status') == '已完賽') + has_result_sync = int(payload.get('_result_synced_at') is not None) + stable_provider_id = int(len(str(payload.get('match_id') or '')) >= 6) + return has_score, is_finished, has_result_sync, stable_provider_id + + for ( + match_id, + home_name, + away_name, + kickoff_utc, + status, + home_score, + away_score, + result_synced_at, + venue_name, + venue_city, + venue_country, + ) in rows: + home_label = localize_team_name(home_name) + away_label = localize_team_name(away_name) + payload = { + 'match_id': match_id, + 'home_team': home_label, + 'away_team': away_label, + 'home_score': home_score, + 'away_score': away_score, + 'kickoff_utc': kickoff_utc, + 'status': localize_status(str(status.value if hasattr(status, 'value') else status)), + 'venue_name': localize_venue_name(venue_name), + 'venue_city': localize_city(venue_city), + 'venue_country': localize_country(venue_country), + '_raw_status': str(status.value if hasattr(status, 'value') else status), + '_result_synced_at': result_synced_at, + } + key = ( + home_label.strip().lower(), + away_label.strip().lower(), + kickoff_utc.isoformat() if hasattr(kickoff_utc, 'isoformat') else str(kickoff_utc), + ) + current = deduped.get(key) + if current is None or quality_rank(payload) > quality_rank(current): + deduped[key] = payload + + return [ + {key: value for key, value in payload.items() if not key.startswith('_')} + for payload in deduped.values() + ] def _build_over_under_payload(under: float, over: float) -> dict[str, float]: @@ -643,8 +1328,8 @@ async def _query_match_preview(match_id: str) -> dict[str, Any] | None: away = row[2] venue = row[3] - home_xg = _safe_float(match.home_xg, 1.1) - away_xg = _safe_float(match.away_xg, 1.0) + home_xg = _safe_float(match.home_xg, default=1.1) + away_xg = _safe_float(match.away_xg, default=1.0) odds_stmt = ( select( @@ -669,27 +1354,25 @@ async def _query_match_preview(match_id: str) -> dict[str, Any] | None: 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, + market_type=localize_market_type(str(market_type)), + selection=localize_selection(str(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, - ), - ] + home_xg, away_xg, preview_xg_quality = _estimate_daily_xg( + match.home_xg, + match.away_xg, + home.name, + away.name, + home.fifa_rank, + away.fifa_rank, + home.current_elo_rating, + away.current_elo_rating, + bool(odds_points), + ) predictor = PoissonMatchPredictor( home_attack_strength=max(0.35, home_xg), @@ -715,8 +1398,8 @@ async def _query_match_preview(match_id: str) -> dict[str, Any] | None: ) quant_summary = _build_quant_summary( - home_team=home.name, - away_team=away.name, + home_team=localize_team_name(home.name), + away_team=localize_team_name(away.name), home_xg=expected_home_xg, away_xg=expected_away_xg, one_x_two=one_x_two, @@ -725,17 +1408,21 @@ async def _query_match_preview(match_id: str) -> dict[str, Any] | None: return { 'match_id': match.id, - 'home_team': home.name, - 'away_team': away.name, + 'home_team': localize_team_name(home.name), + 'away_team': localize_team_name(away.name), + 'home_score': match.home_score, + 'away_score': match.away_score, '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, + 'status': localize_status(str(match.status.value if hasattr(match.status, 'value') else match.status)), + 'venue_name': localize_venue_name(venue.name), + 'venue_city': localize_city(venue.city), + 'venue_country': localize_country(venue.country), 'venue_altitude_meters': venue.altitude_meters, 'odds_series': odds_points, + 'odds_quality': 'live_market' if odds_points else 'missing_market', + 'xg_quality': preview_xg_quality, 'poisson': { 'expected_home_goals': expected_home_xg, 'expected_away_goals': expected_away_xg, @@ -757,34 +1444,93 @@ async def _query_match_preview(match_id: str) -> dict[str, Any] | None: 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 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, - }, - ] + +def _clamp_model_value(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, value)) + + +def _team_strength_prior(team_name: Any) -> tuple[int | None, float | None]: + normalized = str(team_name or '').strip().lower() + normalized = normalized.replace('é', 'e').replace('ã', 'a').replace('ö', 'o') + prior = TEAM_STRENGTH_PRIORS.get(normalized) + if prior is None: + return None, None + return prior + + +def _estimate_daily_xg( + home_xg: Any, + away_xg: Any, + home_name: Any, + away_name: Any, + home_rank: Any, + away_rank: Any, + home_elo: Any, + away_elo: Any, + has_market_odds: bool, +) -> tuple[float, float, str]: + """用實際 xG 優先;若只有賽程資料,改用 FIFA ranking / Elo 做賽前先驗,不再套同一組 1.0/1.0。""" + + parsed_home_xg = _safe_float(home_xg) + parsed_away_xg = _safe_float(away_xg) + seeded_xg = ( + parsed_home_xg is not None + and parsed_away_xg is not None + and abs(parsed_home_xg - 1.0) < 0.0001 + and abs(parsed_away_xg - 1.0) < 0.0001 + and not has_market_odds + ) + if parsed_home_xg is not None and parsed_away_xg is not None and not seeded_xg: + return parsed_home_xg, parsed_away_xg, 'observed' + + parsed_home_elo = _safe_float(home_elo) + parsed_away_elo = _safe_float(away_elo) + parsed_home_rank = _safe_float(home_rank) + parsed_away_rank = _safe_float(away_rank) + home_prior_rank, home_prior_elo = _team_strength_prior(home_name) + away_prior_rank, away_prior_elo = _team_strength_prior(away_name) + parsed_home_rank = parsed_home_rank if parsed_home_rank is not None else _safe_float(home_prior_rank) + parsed_away_rank = parsed_away_rank if parsed_away_rank is not None else _safe_float(away_prior_rank) + parsed_home_elo = parsed_home_elo if parsed_home_elo is not None else _safe_float(home_prior_elo) + parsed_away_elo = parsed_away_elo if parsed_away_elo is not None else _safe_float(away_prior_elo) + has_strength_prior = ( + parsed_home_elo is not None + or parsed_away_elo is not None + or parsed_home_rank is not None + or parsed_away_rank is not None + ) + + elo_signal = 0.0 + if parsed_home_elo is not None and parsed_away_elo is not None: + elo_signal = (parsed_home_elo - parsed_away_elo) / 420.0 + + rank_signal = 0.0 + if parsed_home_rank is not None and parsed_away_rank is not None: + rank_signal = (parsed_away_rank - parsed_home_rank) / 120.0 + + strength_signal = _clamp_model_value((elo_signal * 0.34) + (rank_signal * 0.26), -0.45, 0.45) + estimated_home = parsed_home_xg if parsed_home_xg is not None and not seeded_xg else 1.32 + strength_signal + estimated_away = parsed_away_xg if parsed_away_xg is not None and not seeded_xg else 1.08 - (strength_signal * 0.78) + + quality = 'rank_elo_prior' if has_strength_prior else 'fallback_prior' + return ( + round(_clamp_model_value(float(estimated_home), 0.45, 2.65), 4), + round(_clamp_model_value(float(estimated_away), 0.35, 2.45), 4), + quality, + ) async def _query_match_day_snapshot(target_date: datetime.date) -> list[dict[str, Any]]: home_team = aliased(Team) away_team = aliased(Team) + taipei_tz = timezone(timedelta(hours=8)) + day_start_utc = datetime.combine(target_date, time.min, tzinfo=taipei_tz).astimezone(timezone.utc) + day_end_utc = day_start_utc + timedelta(days=1) + now_utc = datetime.now(timezone.utc) + betting_cutoff_utc = now_utc - timedelta(minutes=20) async with SessionFactory() as session: stmt = ( @@ -793,12 +1539,22 @@ async def _query_match_day_snapshot(target_date: datetime.date) -> list[dict[str Match.home_xg, Match.away_xg, Match.match_time_utc, + Match.status, home_team.name, away_team.name, + home_team.fifa_rank, + away_team.fifa_rank, + home_team.current_elo_rating, + away_team.current_elo_rating, ) .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) + .where( + Match.match_time_utc >= day_start_utc, + Match.match_time_utc < day_end_utc, + Match.match_time_utc >= betting_cutoff_utc, + Match.status != MatchStatus.FINISHED, + ) .order_by(Match.match_time_utc.asc()) ) @@ -807,27 +1563,1658 @@ async def _query_match_day_snapshot(target_date: datetime.date) -> list[dict[str matches_payload: list[dict[str, Any]] = [] for row in rows: - match_id, home_xg, away_xg, match_time_utc, home_name, away_name = row + ( + match_id, + home_xg, + away_xg, + match_time_utc, + match_status, + home_name, + away_name, + home_rank, + away_rank, + home_elo, + away_elo, + ) = 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') + opening_draw, current_draw = await _query_opening_odds(session, match_id, '1x2', 'draw') + _, current_over_15 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=1.5) + _, current_under_15 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=1.5) + _, current_over_25 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=2.5) + _, current_under_25 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=2.5) + _, current_over_35 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=3.5) + _, current_under_35 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=3.5) + _, current_btts_yes = await _query_opening_odds(session, match_id, 'btts', 'yes') + _, current_btts_no = await _query_opening_odds(session, match_id, 'btts', 'no') + odds_source_meta = await _query_match_odds_source_meta(session, match_id) 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 + current_draw = current_draw if current_draw is not None else opening_draw + has_market_odds = current_home is not None and current_away is not None and current_draw is not None + estimated_home_xg, estimated_away_xg, xg_quality = _estimate_daily_xg( + home_xg, + away_xg, + home_name, + away_name, + home_rank, + away_rank, + home_elo, + away_elo, + has_market_odds, + ) - 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, - }, + matches_payload.append( + { + 'match_id': match_id, + 'home_team': localize_team_name(home_name), + 'away_team': localize_team_name(away_name), + 'home_xg': estimated_home_xg, + 'away_xg': estimated_away_xg, + 'xg_quality': xg_quality, + 'home_fifa_rank': home_rank, + 'away_fifa_rank': away_rank, + 'home_elo': home_elo, + 'away_elo': away_elo, + 'odds_home': float(current_home) if current_home is not None else 0.0, + 'odds_away': float(current_away) if current_away is not None else 0.0, + 'odds_draw': float(current_draw) if current_draw is not None else 0.0, + 'odds_over_15': float(current_over_15) if current_over_15 is not None else 0.0, + 'odds_under_15': float(current_under_15) if current_under_15 is not None else 0.0, + 'odds_over_25': float(current_over_25) if current_over_25 is not None else 0.0, + 'odds_under_25': float(current_under_25) if current_under_25 is not None else 0.0, + 'odds_over_35': float(current_over_35) if current_over_35 is not None else 0.0, + 'odds_under_35': float(current_under_35) if current_under_35 is not None else 0.0, + 'odds_btts_yes': float(current_btts_yes) if current_btts_yes is not None else 0.0, + 'odds_btts_no': float(current_btts_no) if current_btts_no is not None else 0.0, + 'kickoff_utc': match_time_utc, + 'status': match_status.value if hasattr(match_status, 'value') else str(match_status), + 'has_market_odds': has_market_odds, + 'odds_quality': 'live_market' if has_market_odds else 'missing_market', + 'odds_source_label': odds_source_meta['label'], + 'odds_source_kind': odds_source_meta['kind'], + }, + ) + + deduped: dict[tuple[str, str, str], dict[str, Any]] = {} + for item in matches_payload: + kickoff_key = item['kickoff_utc'].isoformat() if hasattr(item['kickoff_utc'], 'isoformat') else str(item['kickoff_utc']) + key = (str(item['home_team']), str(item['away_team']), kickoff_key) + existing = deduped.get(key) + if existing is None or (item.get('has_market_odds') and not existing.get('has_market_odds')): + deduped[key] = item + + return _build_daily_card_fallback(list(deduped.values())) + + +async def _query_finished_recommendation_snapshots(days_back: int) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + home_team = aliased(Team) + away_team = aliased(Team) + end_utc = datetime.now(timezone.utc) + start_utc = end_utc - timedelta(days=days_back) + + async with SessionFactory() as session: + stmt = ( + select( + Match.id, + Match.home_xg, + Match.away_xg, + Match.match_time_utc, + Match.status, + Match.home_score, + Match.away_score, + home_team.name, + away_team.name, + home_team.fifa_rank, + away_team.fifa_rank, + home_team.current_elo_rating, + away_team.current_elo_rating, + ) + .join(home_team, Match.home_team_id == home_team.id) + .join(away_team, Match.away_team_id == away_team.id) + .where( + Match.match_time_utc >= start_utc, + Match.match_time_utc <= end_utc, + Match.status == MatchStatus.FINISHED, + Match.home_score.is_not(None), + Match.away_score.is_not(None), + ) + .order_by(Match.match_time_utc.desc()) + .limit(120) + ) + + result = await session.execute(stmt) + rows = result.all() + + matches_payload: list[dict[str, Any]] = [] + result_lookup: dict[str, dict[str, Any]] = {} + + for row in rows: + ( + match_id, + home_xg, + away_xg, + match_time_utc, + match_status, + home_score, + away_score, + home_name, + away_name, + home_rank, + away_rank, + home_elo, + away_elo, + ) = 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') + opening_draw, current_draw = await _query_opening_odds(session, match_id, '1x2', 'draw') + _, current_over_15 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=1.5) + _, current_under_15 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=1.5) + _, current_over_25 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=2.5) + _, current_under_25 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=2.5) + _, current_over_35 = await _query_opening_odds(session, match_id, 'ou', 'over', market_line=3.5) + _, current_under_35 = await _query_opening_odds(session, match_id, 'ou', 'under', market_line=3.5) + _, current_btts_yes = await _query_opening_odds(session, match_id, 'btts', 'yes') + _, current_btts_no = await _query_opening_odds(session, match_id, 'btts', 'no') + odds_source_meta = await _query_match_odds_source_meta(session, match_id) + 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 + current_draw = current_draw if current_draw is not None else opening_draw + has_market_odds = current_home is not None and current_away is not None and current_draw is not None + estimated_home_xg, estimated_away_xg, xg_quality = _estimate_daily_xg( + home_xg, + away_xg, + home_name, + away_name, + home_rank, + away_rank, + home_elo, + away_elo, + has_market_odds, + ) + localized_home = localize_team_name(home_name) + localized_away = localize_team_name(away_name) + + result_lookup[str(match_id)] = { + 'match_id': str(match_id), + 'home_team': localized_home, + 'away_team': localized_away, + 'home_score': int(home_score), + 'away_score': int(away_score), + } + matches_payload.append( + { + 'match_id': match_id, + 'home_team': localized_home, + 'away_team': localized_away, + 'home_xg': estimated_home_xg, + 'away_xg': estimated_away_xg, + 'xg_quality': xg_quality, + 'home_fifa_rank': home_rank, + 'away_fifa_rank': away_rank, + 'home_elo': home_elo, + 'away_elo': away_elo, + 'odds_home': float(current_home) if current_home is not None else 0.0, + 'odds_away': float(current_away) if current_away is not None else 0.0, + 'odds_draw': float(current_draw) if current_draw is not None else 0.0, + 'odds_over_15': float(current_over_15) if current_over_15 is not None else 0.0, + 'odds_under_15': float(current_under_15) if current_under_15 is not None else 0.0, + 'odds_over_25': float(current_over_25) if current_over_25 is not None else 0.0, + 'odds_under_25': float(current_under_25) if current_under_25 is not None else 0.0, + 'odds_over_35': float(current_over_35) if current_over_35 is not None else 0.0, + 'odds_under_35': float(current_under_35) if current_under_35 is not None else 0.0, + 'odds_btts_yes': float(current_btts_yes) if current_btts_yes is not None else 0.0, + 'odds_btts_no': float(current_btts_no) if current_btts_no is not None else 0.0, + 'kickoff_utc': match_time_utc, + 'status': match_status.value if hasattr(match_status, 'value') else str(match_status), + 'has_market_odds': has_market_odds, + 'odds_quality': 'settled_market' if has_market_odds else 'settled_without_market', + 'odds_source_label': odds_source_meta['label'], + 'odds_source_kind': odds_source_meta['kind'], + }, + ) + + return _build_daily_card_fallback(matches_payload), result_lookup + + +def _all_daily_card_items(card_payload: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for group_name in ('safe_singles', 'high_risk_singles', 'safe_parlays', 'sgp_lotteries'): + raw_group = card_payload.get(group_name, []) + if isinstance(raw_group, list): + items.extend([item for item in raw_group if isinstance(item, dict)]) + return items + + +def _taipei_today_date(): + return datetime.now(timezone(timedelta(hours=8))).date() + + +async def _read_daily_recommendation_snapshot_payload(target_date: str) -> dict[str, Any] | None: + snapshot_id = f'daily-card:{target_date}' + async with SessionFactory() as session: + snapshot = await session.get(DailyRecommendationSnapshot, snapshot_id) + if snapshot is None or not snapshot.payload: + return None + payload = dict(snapshot.payload) + payload.setdefault('snapshot_status', 'saved_snapshot') + return payload + + +def _with_missing_snapshot_notice(target_date: str, card_payload: dict[str, Any], match_count: int = 0) -> dict[str, Any]: + """避免已開賽/完賽日期被空推薦誤解為資料正常。""" + + payload = dict(card_payload) + quality_summary = dict(payload.get('data_quality_summary') or {}) + quality_summary['snapshot_missing_after_kickoff'] = match_count + payload['data_quality_summary'] = quality_summary + payload['market_data_status'] = 'snapshot_missing_after_kickoff' + payload['execution_policy'] = '此日期已開賽或完賽,但缺少可用賽前推薦快照;系統不得事後補造投注推薦,只能等待賽後校準或下一個賽事日。' + payload['summary'] = ( + f'{target_date} 的賽事已開賽或完賽,且目前沒有可用的賽前投注快照。' + '為避免事後看答案補造高勝率推薦,此頁不產生新的下注建議;請改看賽後校準或下一個有盤口的日期。' + ) + return payload + + +def _daily_snapshot_item_key(item: dict[str, Any]) -> tuple[str, str, str, str]: + return ( + str(item.get('match_id') or ''), + str(item.get('market_type') or ''), + str(item.get('selection') or ''), + str(item.get('recommendation') or ''), + ) + + +def _merge_daily_recommendation_snapshot(existing_payload: dict[str, Any], next_payload: dict[str, Any]) -> dict[str, Any]: + """保留同日較早快照中的候選,避免開賽 cutoff 後被較短清單覆蓋。""" + + merged = dict(next_payload) + bucket_names = ('safe_singles', 'high_risk_singles', 'safe_parlays', 'sgp_lotteries') + preserved_count = 0 + + for bucket_name in bucket_names: + next_items = [item for item in next_payload.get(bucket_name, []) if isinstance(item, dict)] + existing_items = [item for item in existing_payload.get(bucket_name, []) if isinstance(item, dict)] + seen = {_daily_snapshot_item_key(item) for item in next_items} + preserved_items: list[dict[str, Any]] = [] + for item in existing_items: + key = _daily_snapshot_item_key(item) + if key in seen: + continue + preserved_items.append(item) + seen.add(key) + preserved_count += len(preserved_items) + merged[bucket_name] = [*next_items, *preserved_items] + + merged_items = _all_daily_card_items(merged) + total_units = round(sum(float(item.get('stake_units') or 0) for item in merged_items), 2) + total_amount = sum( + int(item.get('stake_amount_twd') or round(float(item.get('stake_units') or 0) * float(item.get('unit_size_twd') or 1000))) + for item in merged_items + ) + live_count = sum(1 for item in merged_items if item.get('has_market_odds') is True) + quality_counts: dict[str, int] = {} + for item in merged_items: + quality = str(item.get('data_quality') or 'unknown') + quality_counts[quality] = quality_counts.get(quality, 0) + 1 + + merged['total_daily_unit_recommendation'] = total_units + merged['total_daily_amount_twd'] = total_amount + merged['data_quality_summary'] = { + **quality_counts, + 'live_market_count': live_count, + 'pre_market_count': len(merged_items) - live_count, + 'preserved_snapshot_items': preserved_count, + } + merged['matched_matches'] = max( + int(next_payload.get('matched_matches') or 0), + int(existing_payload.get('matched_matches') or 0), + ) + if merged_items: + merged['market_data_status'] = 'live_market_available' if live_count > 0 else 'pre_market_watchlist' + if preserved_count: + base_summary = str(merged.get('summary') or '') + preserve_note = f' 已保留較早賽前快照中的 {preserved_count} 組候選,避免開賽後被短清單覆蓋。' + if preserve_note.strip() not in base_summary: + merged['summary'] = f'{base_summary}{preserve_note}' + return merged + + +async def _persist_daily_recommendation_snapshot(target_date: str, card_payload: dict[str, Any]) -> None: + """保存每日作戰室賽前快照,供賽後校準使用。""" + + items = _all_daily_card_items(card_payload) + live_market_count = sum(1 for item in items if item.get('has_market_odds') is True) + snapshot_id = f'daily-card:{target_date}' + generated_at = datetime.now(timezone.utc) + try: + target_day = _to_date(target_date) + except HTTPException: + target_day = _taipei_today_date() + if target_day < _taipei_today_date(): + return + if not items and target_day <= _taipei_today_date(): + async with SessionFactory() as session: + existing = await session.get(DailyRecommendationSnapshot, snapshot_id) + if existing is not None and existing.payload and _all_daily_card_items(dict(existing.payload)): + return + + async with SessionFactory() as session: + existing = await session.get(DailyRecommendationSnapshot, snapshot_id) + if existing is not None and existing.payload and target_day <= _taipei_today_date(): + existing_payload = dict(existing.payload) + if _all_daily_card_items(existing_payload): + card_payload = _merge_daily_recommendation_snapshot(existing_payload, card_payload) + items = _all_daily_card_items(card_payload) + live_market_count = sum(1 for item in items if item.get('has_market_odds') is True) + + if existing is None: + session.add( + DailyRecommendationSnapshot( + id=snapshot_id, + target_date=target_date, + generated_at=generated_at, + items_count=len(items), + live_market_count=live_market_count, + pre_market_count=len(items) - live_market_count, + payload=card_payload, ) + ) + else: + existing.generated_at = generated_at + existing.items_count = len(items) + existing.live_market_count = live_market_count + existing.pre_market_count = len(items) - live_market_count + existing.payload = card_payload + existing.updated_at = generated_at + await session.commit() - return _build_daily_card_fallback(matches_payload) + +async def _query_daily_recommendation_snapshot_payloads(days_back: int) -> list[dict[str, Any]]: + """讀取最近一段期間保存過的每日推薦快照。""" + + taipei_today = datetime.now(timezone(timedelta(hours=8))).date() + start_date = (taipei_today - timedelta(days=days_back + 2)).isoformat() + end_date = taipei_today.isoformat() + + async with SessionFactory() as session: + result = await session.execute( + select(DailyRecommendationSnapshot) + .where(DailyRecommendationSnapshot.target_date >= start_date) + .where(DailyRecommendationSnapshot.target_date <= end_date) + .order_by(DailyRecommendationSnapshot.target_date.asc(), DailyRecommendationSnapshot.generated_at.asc()) + ) + return [snapshot.payload for snapshot in result.scalars().all() if snapshot.payload] + + +def _item_references_finished_match(item: dict[str, Any], finished_match_ids: set[str]) -> bool: + """確認推薦項目是否對應到已完賽場次。""" + + match_id = item.get('match_id') + if match_id is not None and str(match_id) in finished_match_ids: + return True + + for leg in item.get('legs') or []: + if not isinstance(leg, dict): + continue + leg_match_id = leg.get('match_id') + if leg_match_id is not None and str(leg_match_id) in finished_match_ids: + return True + + return False + + +def _snapshot_items_for_finished_matches( + snapshots: list[dict[str, Any]], + result_lookup: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """從賽前快照取出已能結算的推薦項目,並避免重複計算。""" + + finished_match_ids = {str(match_id) for match_id in result_lookup.keys()} + seen: set[str] = set() + items: list[dict[str, Any]] = [] + + for snapshot in snapshots: + for item in _all_daily_card_items(snapshot): + if not _item_references_finished_match(item, finished_match_ids): + continue + dedupe_key = '|'.join( + [ + str(item.get('match_id') or ''), + str(item.get('market_type') or item.get('portfolio_type') or ''), + str(item.get('selection') or item.get('title') or ''), + str(item.get('minimum_acceptable_odds') or item.get('target_odds') or ''), + str(item.get('recommended_stake_units') or item.get('suggested_units') or ''), + ] + ) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + item['snapshot_based'] = True + items.append(item) + + return items + + +def _parse_market_line(*texts: str, default: float | None = None) -> float | None: + for text in texts: + match = re.search(r'(\d+(?:\.\d+)?)', str(text or '')) + if match: + return float(match.group(1)) + return default + + +def _selection_side(selection: str, result: dict[str, Any]) -> str | None: + text = str(selection or '') + home_team = str(result.get('home_team') or '') + away_team = str(result.get('away_team') or '') + if '主勝' in text or '主隊' in text or '主不敗' in text: + return 'home' + if '客勝' in text or '客隊' in text or '客不敗' in text: + return 'away' + if home_team and home_team in text: + return 'home' + if away_team and away_team in text: + return 'away' + return None + + +def _outcome_payload(outcome: str, label: str, lesson: str) -> dict[str, str]: + return { + 'outcome': outcome, + 'outcome_label': label, + 'lesson': lesson, + } + + +def _evaluate_market_selection(market_type: str, selection: str, result: dict[str, Any]) -> dict[str, str]: + market = str(market_type or '') + selected = str(selection or '') + home_score = int(result.get('home_score', 0)) + away_score = int(result.get('away_score', 0)) + total_goals = home_score + away_score + side = _selection_side(selected, result) + + if '正確比分' in market or re.search(r'\b\d+\s*[-::]\s*\d+\b', selected): + score_match = re.search(r'\b(\d+)\s*[-::]\s*(\d+)\b', selected) + if score_match: + predicted_home = int(score_match.group(1)) + predicted_away = int(score_match.group(2)) + is_hit = predicted_home == home_score and predicted_away == away_score + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '正確比分屬於高波動玩法,命中可保留小注策略,未命中則不應放大注碼。', + ) + + if '大小球' in market or market.lower() in {'ou', 'over_under'}: + line = _parse_market_line(market, selected, default=2.5) + wants_over = '大' in selected or 'over' in selected.lower() + wants_under = '小' in selected or 'under' in selected.lower() + if line is not None and (wants_over or wants_under): + if abs(total_goals - line) < 0.0001: + return _outcome_payload('push', '退回', '總進球剛好落在盤口,視為走盤,不納入命中率分母。') + is_hit = total_goals > line if wants_over else total_goals < line + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '大小球會直接回饋進球分布模型;連續失準時要調整節奏、傷停與賽事強度權重。', + ) + + if '雙方進球' in market or 'btts' in market.lower() or '雙方進球' in selected: + wants_yes = '是' in selected or 'yes' in selected.lower() or ('否' not in selected and 'no' not in selected.lower()) + both_scored = home_score > 0 and away_score > 0 + is_hit = both_scored if wants_yes else not both_scored + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '雙方進球會檢查兩隊攻守平衡;若失準,後續要降低單靠總進球推導出的信心。', + ) + + if '隊伍總進球' in market or 'team total' in market.lower(): + line = _parse_market_line(market, selected, default=0.5) + target_goals = home_score if side == 'home' else away_score if side == 'away' else None + wants_over = '大' in selected or '超過' in selected or 'over' in selected.lower() + wants_under = '小' in selected or '低於' in selected or 'under' in selected.lower() + if target_goals is not None and line is not None and (wants_over or wants_under): + if abs(target_goals - line) < 0.0001: + return _outcome_payload('push', '退回', '隊伍進球數剛好落在盤口,視為走盤。') + is_hit = target_goals > line if wants_over else target_goals < line + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '隊伍總進球會回饋單隊攻擊與對手防守估計,連續失準時要修正單隊 xG 權重。', + ) + + if '平手退回' in market or '平手退回' in selected or 'draw no bet' in market.lower(): + if home_score == away_score: + return _outcome_payload('push', '退回', '平手退回遇到和局不算輸,這類結果不納入命中率分母。') + if side == 'home': + is_hit = home_score > away_score + elif side == 'away': + is_hit = away_score > home_score + else: + return _outcome_payload('not_evaluable', '待人工檢查', '這張平手退回缺少清楚主客隊選項,先不納入自動命中率。') + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '平手退回主要驗證勝負方向,未命中時要檢查是否高估強隊下限。', + ) + + if '雙重機會' in market or '不敗' in selected: + if side == 'home': + is_hit = home_score >= away_score + elif side == 'away': + is_hit = away_score >= home_score + else: + return _outcome_payload('not_evaluable', '待人工檢查', '雙重機會選項缺少清楚主客隊方向,先不納入自動命中率。') + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '雙重機會命中率應高於單勝;若未達標,後續要下修保守玩法信心。', + ) + + if '勝平負' in market or market.lower() in {'1x2', 'moneyline'} or '主勝' in selected or '客勝' in selected or '平局' in selected: + if '平局' in selected or '和局' in selected: + is_hit = home_score == away_score + elif side == 'home': + is_hit = home_score > away_score + elif side == 'away': + is_hit = away_score > home_score + else: + return _outcome_payload('not_evaluable', '待人工檢查', '勝平負選項缺少明確方向,先不納入自動命中率。') + return _outcome_payload( + 'hit' if is_hit else 'miss', + '命中' if is_hit else '未命中', + '勝平負會直接校準基本勝率模型;未命中會降低相似信心分數與建議注碼。', + ) + + return _outcome_payload('not_evaluable', '待人工檢查', '此玩法目前沒有完整自動判定規則,需人工補規則後再納入命中率。') + + +def _split_leg_market_selection(raw_market: str, raw_selection: str) -> tuple[str, str]: + selected = str(raw_selection or '') + market = str(raw_market or '') + if '|' in selected: + prefix, suffix = selected.split('|', 1) + if prefix.strip(): + market = prefix.strip() + if suffix.strip(): + selected = suffix.strip() + return market, selected + + +def _evaluate_recommendation_item(item: dict[str, Any], result_lookup: dict[str, dict[str, Any]]) -> dict[str, str]: + legs = item.get('legs') if isinstance(item.get('legs'), list) else [] + if legs: + leg_outcomes = [] + for leg in legs: + if not isinstance(leg, dict): + continue + leg_match_id = str(leg.get('match_id') or item.get('match_id') or '') + leg_result = result_lookup.get(leg_match_id) + if not leg_result: + leg_outcomes.append('not_evaluable') + continue + leg_market, leg_selection = _split_leg_market_selection( + str(leg.get('market_type') or item.get('market_type') or ''), + str(leg.get('selection') or ''), + ) + evaluated = _evaluate_market_selection(leg_market, leg_selection, leg_result) + leg_outcomes.append(evaluated['outcome']) + + if not leg_outcomes or 'not_evaluable' in leg_outcomes: + return _outcome_payload('not_evaluable', '待人工檢查', '串關腿數中有玩法無法自動判定,先不納入命中率。') + if 'miss' in leg_outcomes: + return _outcome_payload('miss', '未命中', '串關只要任一腿未中就失敗;後續會降低同類組合與高相關腿數的權重。') + if all(outcome == 'push' for outcome in leg_outcomes): + return _outcome_payload('push', '退回', '串關所有腿都走盤,視為退回,不納入命中率分母。') + return _outcome_payload('hit', '命中', '串關全部可判定腿數通過,可保留但仍需限制注碼,避免高相關風險。') + + match_id = str(item.get('match_id') or '') + result = result_lookup.get(match_id) + if not result: + return _outcome_payload('not_evaluable', '待人工檢查', '找不到對應賽果,先不納入命中率。') + return _evaluate_market_selection(str(item.get('market_type') or ''), str(item.get('selection') or ''), result) + + +def _result_score_label(item: dict[str, Any], result_lookup: dict[str, dict[str, Any]]) -> str: + match_id = str(item.get('match_id') or '') + result = result_lookup.get(match_id) + if not result: + return '無賽果' + return f"{result['home_team']} {result['home_score']} - {result['away_score']} {result['away_team']}" + + +def _performance_actions(hit_rate: float, buckets: list[dict[str, Any]], items: list[RecommendationPerformanceItem]) -> list[str]: + actions: list[str] = [] + if not items: + return ['近端期間沒有可重建的推薦清單;下一步要先累積賽前推薦快照,才能做更嚴格的長期校準。'] + + no_market_count = sum(1 for item in items if item.has_market_odds is False) + if hit_rate < 52: + actions.append('整體命中率未達穩定門檻時,低資料品質與沒有實盤賠率的候選只能放入監控,不應標成正式下注推薦。') + if no_market_count: + actions.append(f'有 {no_market_count} 組候選缺少完整實盤賠率,後續會持續壓低信心與新台幣上限,直到盤口資料補齊。') + + weak_markets = [ + bucket for bucket in buckets + if int(bucket.get('settled_count', 0)) >= 3 and float(bucket.get('hit_rate_percent', 0.0)) < 45.0 + ] + for bucket in weak_markets[:3]: + actions.append(f"{bucket['market_type']} 近端命中率偏低,接下來要降低同玩法權重並提高最低可接受賠率門檻。") + + if not actions: + actions.append('近端命中率尚可,下一步重點是保存賽前推薦快照,追蹤是否真的拿到建議賠率與收盤價差。') + return actions + + +async def _build_recommendation_performance(days_back: int) -> RecommendationPerformanceResponse: + match_payload, result_lookup = await _query_finished_recommendation_snapshots(days_back) + generated_at = datetime.now(timezone.utc).isoformat() + snapshot_payloads = await _query_daily_recommendation_snapshot_payloads(days_back) + snapshot_items = _snapshot_items_for_finished_matches(snapshot_payloads, result_lookup) + snapshot_mode = len(snapshot_items) > 0 + + if snapshot_mode: + raw_items = snapshot_items + else: + card = generate_daily_card(datetime.now(timezone(timedelta(hours=8))).date().isoformat(), match_payload) + raw_items = _all_daily_card_items(card) + + performance_items: list[RecommendationPerformanceItem] = [] + bucket_state: dict[str, dict[str, int]] = defaultdict(lambda: { + 'recommendation_count': 0, + 'settled_count': 0, + 'hit_count': 0, + 'miss_count': 0, + 'push_count': 0, + }) + source_bucket_state: dict[tuple[str, str], dict[str, int]] = defaultdict(lambda: { + 'recommendation_count': 0, + 'settled_count': 0, + 'hit_count': 0, + 'miss_count': 0, + 'push_count': 0, + }) + + hit_count = 0 + miss_count = 0 + push_count = 0 + settled_count = 0 + + for item in raw_items: + evaluated = _evaluate_recommendation_item(item, result_lookup) + outcome = evaluated['outcome'] + market_type = str(item.get('market_type') or '未分類玩法') + source_label = str(item.get('odds_source_label') or ('實盤盤口' if item.get('has_market_odds') else '模型推算最低門檻')) + source_kind = str(item.get('odds_source_kind') or ('market' if item.get('has_market_odds') else 'conditional_threshold')) + source_key = (source_label, source_kind) + bucket_state[market_type]['recommendation_count'] += 1 + source_bucket_state[source_key]['recommendation_count'] += 1 + if outcome in {'hit', 'miss', 'push'}: + settled_count += 1 + bucket_state[market_type]['settled_count'] += 1 + source_bucket_state[source_key]['settled_count'] += 1 + if outcome == 'hit': + hit_count += 1 + bucket_state[market_type]['hit_count'] += 1 + source_bucket_state[source_key]['hit_count'] += 1 + elif outcome == 'miss': + miss_count += 1 + bucket_state[market_type]['miss_count'] += 1 + source_bucket_state[source_key]['miss_count'] += 1 + elif outcome == 'push': + push_count += 1 + bucket_state[market_type]['push_count'] += 1 + source_bucket_state[source_key]['push_count'] += 1 + + performance_items.append( + RecommendationPerformanceItem( + match_id=str(item.get('match_id') or ''), + match_label=str(item.get('match_label') or ''), + market_type=market_type, + selection=str(item.get('selection') or ''), + recommendation=str(item.get('recommendation') or '研究候選'), + result_score=_result_score_label(item, result_lookup), + outcome=outcome, + outcome_label=evaluated['outcome_label'], + target_odds=float(item.get('target_odds') or 1.01), + win_prob=float(item.get('win_prob') or 0.0), + ev_percent=float(item.get('ev_percent') or 0.0), + stake_units=float(item.get('stake_units') or 0.0), + stake_amount_twd=int(item['stake_amount_twd']) if item.get('stake_amount_twd') is not None else None, + confidence_score=float(item['confidence_score']) if item.get('confidence_score') is not None else None, + confidence_band=str(item.get('confidence_band')) if item.get('confidence_band') else None, + has_market_odds=bool(item.get('has_market_odds')) if item.get('has_market_odds') is not None else None, + odds_source_label=source_label, + odds_source_kind=source_kind, + lesson=evaluated['lesson'], + ) + ) + + denominator = hit_count + miss_count + hit_rate = round((hit_count / denominator) * 100, 2) if denominator else 0.0 + buckets: list[dict[str, Any]] = [] + for market_type, state in bucket_state.items(): + market_denominator = state['hit_count'] + state['miss_count'] + market_hit_rate = round((state['hit_count'] / market_denominator) * 100, 2) if market_denominator else 0.0 + buckets.append({ + 'market_type': market_type, + 'recommendation_count': state['recommendation_count'], + 'settled_count': state['settled_count'], + 'hit_count': state['hit_count'], + 'miss_count': state['miss_count'], + 'push_count': state['push_count'], + 'hit_rate_percent': market_hit_rate, + }) + + buckets.sort(key=lambda row: (int(row['settled_count']), float(row['hit_rate_percent'])), reverse=True) + source_buckets: list[dict[str, Any]] = [] + for (source_label, source_kind), state in source_bucket_state.items(): + source_denominator = state['hit_count'] + state['miss_count'] + source_hit_rate = round((state['hit_count'] / source_denominator) * 100, 2) if source_denominator else 0.0 + source_buckets.append({ + 'source_label': source_label, + 'source_kind': source_kind, + 'recommendation_count': state['recommendation_count'], + 'settled_count': state['settled_count'], + 'hit_count': state['hit_count'], + 'miss_count': state['miss_count'], + 'push_count': state['push_count'], + 'hit_rate_percent': source_hit_rate, + }) + + source_buckets.sort(key=lambda row: (int(row['settled_count']), float(row['hit_rate_percent'])), reverse=True) + summary = ( + f'近 {days_back} 天已完賽 {len(result_lookup)} 場,系統用目前模型重建 {len(raw_items)} 組推薦,' + f'其中 {settled_count} 組可自動判定,命中 {hit_count} 組、未中 {miss_count} 組、退回 {push_count} 組,' + f'命中率 {hit_rate:.2f}%。' + ) + + return RecommendationPerformanceResponse( + generated_at=generated_at, + days_back=days_back, + finished_match_count=len(result_lookup), + rebuilt_recommendation_count=len(raw_items), + settled_recommendation_count=settled_count, + hit_count=hit_count, + miss_count=miss_count, + push_count=push_count, + hit_rate_percent=hit_rate, + summary=summary, + methodology_note=( + '本頁優先使用每日作戰室正式產生並保存的賽前推薦快照,等比賽結束後再回頭核對命中率、玩法表現與盤口來源;這比單純賽後重建更接近正式量化站的稽核方式。' + if snapshot_mode + else '目前尚未累積足夠的賽前推薦快照,因此暫時以目前模型對已完賽場次重建校準;新的每日推薦會開始保存快照,後續命中率會逐步改用真實賽前樣本。' + ), + improvement_actions=_performance_actions(hit_rate, buckets, performance_items), + by_market_type=[RecommendationPerformanceBucket(**bucket) for bucket in buckets], + by_odds_source=[RecommendationPerformanceSourceBucket(**bucket) for bucket in source_buckets], + items=performance_items[:200], + ) + + +def _env_present(*names: str) -> list[str]: + return [name for name in names if os.environ.get(name)] + + +def _gemini_usage_month(now: datetime | None = None) -> str: + source = now or datetime.now(timezone.utc) + return source.strftime('%Y-%m') + + +def _gemini_usage_prefix() -> str: + return os.environ.get('GEMINI_USAGE_REDIS_PREFIX', 'usage:gemini') + + +def _gemini_cap_usd() -> float: + return max(0.01, float(os.environ.get('GEMINI_COST_CAP_USD', '5'))) + + +def _gemini_input_price_per_1m() -> float: + return max(0.0, float(os.environ.get('GEMINI_INPUT_PRICE_PER_1M_USD', '0.50'))) + + +def _gemini_output_price_per_1m() -> float: + return max(0.0, float(os.environ.get('GEMINI_OUTPUT_PRICE_PER_1M_USD', '3.00'))) + + +def _gemini_grounding_price_per_1k() -> float: + return max(0.0, float(os.environ.get('GEMINI_GROUNDING_PRICE_PER_1K_USD', '14.00'))) + + +def _gemini_usage_cost(input_tokens: int, output_tokens: int, grounded_query_count: int = 0) -> float: + token_cost = ( + (max(0, input_tokens) / 1_000_000) * _gemini_input_price_per_1m() + + (max(0, output_tokens) / 1_000_000) * _gemini_output_price_per_1m() + ) + grounding_cost = (max(0, grounded_query_count) / 1_000) * _gemini_grounding_price_per_1k() + fallback_cost = float(os.environ.get('GEMINI_FALLBACK_REQUEST_COST_USD', '0.01')) + estimated = token_cost + grounding_cost + return round(max(estimated, fallback_cost if input_tokens <= 0 and output_tokens <= 0 else 0.0), 6) + + +async def _read_gemini_usage() -> GeminiUsageResponse: + generated_at = datetime.now(timezone.utc).isoformat() + month = _gemini_usage_month() + prefix = _gemini_usage_prefix() + cap = _gemini_cap_usd() + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + raw = await redis.hgetall(f'{prefix}:{month}') + paused_reason = await redis.get(f'{prefix}:paused') + finally: + await redis.aclose() + + estimated_cost = float(raw.get('estimated_cost_usd', 0.0) or 0.0) + request_count = int(float(raw.get('request_count', 0) or 0)) + input_tokens = int(float(raw.get('input_tokens', 0) or 0)) + output_tokens = int(float(raw.get('output_tokens', 0) or 0)) + grounded_query_count = int(float(raw.get('grounded_query_count', 0) or 0)) + paused = bool(paused_reason) or estimated_cost >= cap + remaining = max(0.0, cap - estimated_cost) + if paused: + status = 'paused_budget' + status_label = '已達費用上限,Gemini 暫停' + next_action = '請先檢查 Google 帳單與用量;若確認安全,再提高 GEMINI_COST_CAP_USD 或重置 Redis 暫停旗標。' + elif estimated_cost >= cap * 0.8: + status = 'near_limit' + status_label = '接近費用上限' + next_action = '建議暫時降低 Gemini 呼叫頻率,只保留重大新聞與傷停檢查。' + else: + status = 'ok' + status_label = '費用在安全範圍' + next_action = None + + return GeminiUsageResponse( + generated_at=generated_at, + month=month, + status=status, + status_label=status_label, + paused=paused, + cap_usd=round(cap, 4), + estimated_cost_usd=round(estimated_cost, 6), + remaining_usd=round(remaining, 6), + request_count=request_count, + input_tokens=input_tokens, + output_tokens=output_tokens, + grounded_query_count=grounded_query_count, + pricing_note='以 Gemini API usageMetadata 估算 token 成本,並額外估算 Google Search grounding 查詢成本;實際帳單仍以 Google Cloud/AI Studio 為準。預設採 Gemini 3 Flash Preview Standard 價格,可用環境變數覆寫。', + next_action=next_action, + ) + + +async def _record_gemini_usage( + *, + input_tokens: int, + output_tokens: int, + grounded_query_count: int = 0, +) -> GeminiUsageResponse: + usage_before = await _read_gemini_usage() + if usage_before.paused: + return usage_before + + month = usage_before.month + prefix = _gemini_usage_prefix() + cost = _gemini_usage_cost(input_tokens, output_tokens, grounded_query_count) + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + key = f'{prefix}:{month}' + pipe = redis.pipeline() + pipe.hincrby(key, 'request_count', 1) + pipe.hincrby(key, 'input_tokens', max(0, input_tokens)) + pipe.hincrby(key, 'output_tokens', max(0, output_tokens)) + pipe.hincrby(key, 'grounded_query_count', max(0, grounded_query_count)) + pipe.hincrbyfloat(key, 'estimated_cost_usd', cost) + pipe.expire(key, 60 * 60 * 24 * 62) + await pipe.execute() + finally: + await redis.aclose() + + usage_after = await _read_gemini_usage() + if usage_after.estimated_cost_usd >= usage_after.cap_usd: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.set( + f'{prefix}:paused', + json.dumps( + { + 'reason': 'monthly_cost_cap_reached', + 'month': month, + 'estimated_cost_usd': usage_after.estimated_cost_usd, + 'cap_usd': usage_after.cap_usd, + 'paused_at': datetime.now(timezone.utc).isoformat(), + }, + ensure_ascii=False, + ), + ) + finally: + await redis.aclose() + return await _read_gemini_usage() + return usage_after + + +async def _read_ingestion_status_snapshot() -> dict[str, Any]: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + raw_values = { + 'odds': await redis.get('ingestion:odds:last_run'), + 'fixtures': await redis.get('ingestion:fixtures:last_run'), + 'news': await redis.get('ingestion:news:last_run'), + } + finally: + await redis.aclose() + + parsed: dict[str, Any] = {} + for key, raw in raw_values.items(): + if not raw: + parsed[key] = {'status': 'missing'} + continue + try: + parsed[key] = json.loads(raw) + except json.JSONDecodeError: + parsed[key] = {'status': 'unreadable', 'raw': str(raw)[:120]} + return parsed + + +async def _probe_json_endpoint( + url: str, + *, + timeout_seconds: float = 8.0, + headers: Mapping[str, str] | None = None, +) -> tuple[bool, str]: + try: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: + response = await client.get(url, headers=headers) + if 200 <= response.status_code < 300: + return True, f'探測成功,HTTP {response.status_code}' + return False, f'探測回應 HTTP {response.status_code}' + except Exception as exc: + return False, f'探測失敗:{type(exc).__name__}' + + +async def _probe_ollama_model(base_url: str, model_name: str, *, timeout_seconds: float = 8.0) -> tuple[bool, str]: + tags_url = f"{base_url.rstrip('/')}/api/tags" + try: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: + response = await client.get(tags_url) + if not 200 <= response.status_code < 300: + return False, f'Ollama tags 回應 HTTP {response.status_code}' + payload = response.json() + except Exception as exc: + return False, f'探測失敗:{type(exc).__name__}' + + models = payload.get('models', []) if isinstance(payload, dict) else [] + installed_names = [ + str(model.get('name') or model.get('model') or '') + for model in models + if isinstance(model, Mapping) + ] + normalized_target = model_name.split(':', 1)[0].lower() + matched = [ + name for name in installed_names + if name.lower() == model_name.lower() or name.split(':', 1)[0].lower() == normalized_target + ] + if matched: + return True, f'Ollama 可連,已安裝模型:{", ".join(matched[:3])}' + if installed_names: + return False, f'Ollama 可連,但找不到模型 {model_name};目前模型:{", ".join(installed_names[:5])}' + return False, f'Ollama 可連,但尚未安裝任何模型;需要先 pull {model_name}' + + +async def _build_agent_verification() -> AgentVerificationResponse: + checked_at = datetime.now(timezone.utc).isoformat() + checks: list[AgentVerificationCheck] = [] + + try: + ingestion_status = await _read_ingestion_status_snapshot() + except Exception as exc: + ingestion_status = {'error': str(exc)} + + codex_evidence = [ + '正式後端 API 已能回應此驗證端點', + f"賠率 worker 狀態:{ingestion_status.get('odds', {}).get('status', 'unknown')}", + f"賽程 worker 狀態:{ingestion_status.get('fixtures', {}).get('status', 'unknown')}", + f"新聞 worker 狀態:{ingestion_status.get('news', {}).get('status', 'unknown')}", + ] + checks.append( + AgentVerificationCheck( + agent='Codex 工程主控', + role='負責資料流、API、前端、部署與正式環境驗證', + status='active', + status_label='已啟用', + evidence=codex_evidence, + next_action='持續把 Gemini/NemoTron 的輸出接進正式推薦閘門,不讓 AI 文案直接繞過量化規則。', + last_checked_at=checked_at, + ) + ) + + gemini_keys = _env_present('GEMINI_API_KEY', 'GOOGLE_API_KEY') + gemini_usage = await _read_gemini_usage() + if gemini_keys: + gemini_model = os.environ.get('GEMINI_MODEL', 'gemini-3-flash-preview') + if gemini_usage.paused: + ok = False + message = f'本月估算費用 ${gemini_usage.estimated_cost_usd:.4f} 已達或接近上限 ${gemini_usage.cap_usd:.2f},暫停 Gemini。' + gemini_status = 'paused_budget' + gemini_status_label = '因費用上限暫停' + else: + gemini_probe_url = os.environ.get('GEMINI_HEALTHCHECK_URL') + gemini_probe_headers: dict[str, str] | None = None + if not gemini_probe_url: + gemini_probe_url = 'https://generativelanguage.googleapis.com/v1beta/models' + gemini_probe_headers = {'x-goog-api-key': os.environ.get(gemini_keys[0], '')} + ok, message = await _probe_json_endpoint(gemini_probe_url, headers=gemini_probe_headers) + gemini_status = 'active' if ok else 'degraded' + gemini_status_label = '已設定,可探測' if ok else '已設定,但探測異常' + checks.append( + AgentVerificationCheck( + agent='付費 Gemini 即時情報', + role='負責 Google Search grounding、URL 情報、傷停新聞與外部事件交叉查證', + status=gemini_status, + status_label=gemini_status_label, + evidence=[ + f'偵測到金鑰環境變數:{", ".join(gemini_keys)}', + f'目標模型:{gemini_model}', + f'本月 Gemini 估算費用:${gemini_usage.estimated_cost_usd:.4f} / ${gemini_usage.cap_usd:.2f}', + message, + ], + next_action=None if ok else (gemini_usage.next_action or '檢查 Gemini API 金鑰權限、付費專案、網路出口與 healthcheck URL。'), + last_checked_at=checked_at, + ) + ) + else: + checks.append( + AgentVerificationCheck( + agent='付費 Gemini 即時情報', + role='負責 Google Search grounding、URL 情報、傷停新聞與外部事件交叉查證', + status='pending_config', + status_label='待設定', + evidence=[ + '正式後端未偵測到 GEMINI_API_KEY 或 GOOGLE_API_KEY。', + f'費用上限已設定為 ${gemini_usage.cap_usd:.2f},目前估算 ${gemini_usage.estimated_cost_usd:.4f}。', + ], + next_action='在正式環境加入 GEMINI_API_KEY 與 GEMINI_MODEL,然後讓新聞/傷停摘要進入推薦前置驗證。', + last_checked_at=checked_at, + ) + ) + + nemotron_base = ( + os.environ.get('NEMOTRON_API_BASE') + or os.environ.get('NEMOTRON_BASE_URL') + or os.environ.get('OLLAMA_BASE_URL') + ) + nemotron_model = os.environ.get('NEMOTRON_MODEL') or os.environ.get('OLLAMA_NEMOTRON_MODEL') or 'nvidia/nemotron' + if nemotron_base: + is_ollama_endpoint = bool(os.environ.get('OLLAMA_BASE_URL')) or 'ollama' in nemotron_base.lower() or '1143' in nemotron_base + ok, message = ( + await _probe_ollama_model(nemotron_base, nemotron_model) + if is_ollama_endpoint + else await _probe_json_endpoint(f"{nemotron_base.rstrip('/')}/v1/models") + ) + checks.append( + AgentVerificationCheck( + agent='NemoTron 本地交叉驗證', + role='負責大量候選玩法批次復核、風險分層、反方稽核與低成本長任務', + status='active' if ok else 'degraded', + status_label='已設定,可探測' if ok else '已設定,但探測異常', + evidence=[ + f'Endpoint:{nemotron_base}', + f'模型:{nemotron_model}', + message, + ], + next_action=None if ok else '確認 NemoTron/Ollama 服務是否啟動、容器網路是否可連、模型是否已 pull。', + last_checked_at=checked_at, + ) + ) + else: + checks.append( + AgentVerificationCheck( + agent='NemoTron 本地交叉驗證', + role='負責大量候選玩法批次復核、風險分層、反方稽核與低成本長任務', + status='pending_config', + status_label='待設定', + evidence=['正式後端未偵測到 NEMOTRON_API_BASE、NEMOTRON_BASE_URL 或 OLLAMA_BASE_URL。'], + next_action='在正式環境加入 NemoTron/Ollama endpoint 與模型名稱,讓候選推薦進入第二模型復核。', + last_checked_at=checked_at, + ) + ) + + calibration_summary: dict[str, Any] = {} + try: + performance = await _build_recommendation_performance(7) + calibration_summary = { + 'finished_match_count': performance.finished_match_count, + 'rebuilt_recommendation_count': performance.rebuilt_recommendation_count, + 'settled_recommendation_count': performance.settled_recommendation_count, + 'hit_rate_percent': performance.hit_rate_percent, + } + quant_status = 'active' if performance.settled_recommendation_count > 0 else 'degraded' + checks.append( + AgentVerificationCheck( + agent='量化校準引擎', + role='負責勝率、期望值、注碼上限、賽後命中率與玩法權重修正', + status=quant_status, + status_label='已啟用' if quant_status == 'active' else '已啟用,樣本不足', + evidence=[ + f"近 7 天已完賽:{performance.finished_match_count} 場", + f"重建推薦:{performance.rebuilt_recommendation_count} 組", + f"可判定推薦:{performance.settled_recommendation_count} 組", + f"目前命中率:{performance.hit_rate_percent:.2f}%", + ], + next_action='下一階段保存每一次賽前推薦快照,讓校準從重建分析升級為完整歷史稽核。', + last_checked_at=checked_at, + ) + ) + except Exception as exc: + checks.append( + AgentVerificationCheck( + agent='量化校準引擎', + role='負責勝率、期望值、注碼上限、賽後命中率與玩法權重修正', + status='blocked', + status_label='異常', + evidence=[f'賽後校準分析失敗:{type(exc).__name__}'], + next_action='檢查 matches、odds_history 與推薦產生器資料結構。', + last_checked_at=checked_at, + ) + ) + + try: + readiness = await analytics_recommendation_readiness(days_ahead=2) + formal_allowed = bool(readiness.get('formal_recommendations_allowed')) + checks.append( + AgentVerificationCheck( + agent='正式推薦資料閘門', + role='確認盤口覆蓋、資料來源與正式下注推薦門檻是否足夠', + status='active' if formal_allowed else 'blocked', + status_label='正式推薦可用' if formal_allowed else '僅能監控,暫停正式推薦', + evidence=[ + f"閘門狀態:{readiness.get('status_label') or readiness.get('status')}", + f"正式推薦允許:{'是' if formal_allowed else '否'}", + ], + next_action=None if formal_allowed else '等待多來源盤口、核心玩法覆蓋與資料新鮮度通過後,才可升級為正式下注推薦。', + last_checked_at=checked_at, + ) + ) + except Exception as exc: + checks.append( + AgentVerificationCheck( + agent='正式推薦資料閘門', + role='確認盤口覆蓋、資料來源與正式下注推薦門檻是否足夠', + status='blocked', + status_label='推薦閘門讀取失敗', + evidence=[f'推薦閘門檢查失敗:{type(exc).__name__}'], + next_action='先修復 recommendation-readiness API,再允許 AI 驗證室顯示 production ready。', + last_checked_at=checked_at, + ) + ) + + status_map = {check.agent: check.status for check in checks} + production_ready = all( + status_map.get(agent_name) == 'active' + for agent_name in ('Codex 工程主控', '付費 Gemini 即時情報', 'NemoTron 本地交叉驗證', '量化校準引擎', '正式推薦資料閘門') + ) + if production_ready: + overall_status = 'active' + overall_label = '四層驗證已全部啟用' + elif status_map.get('正式推薦資料閘門') == 'blocked': + overall_status = 'blocked' + overall_label = 'AI 可用但推薦閘門未通過,暫停正式下注推薦' + elif status_map.get('量化校準引擎') == 'blocked': + overall_status = 'blocked' + overall_label = '量化校準異常,暫停正式推薦' + else: + overall_status = 'partial' + overall_label = '核心量化已啟用,外部 AI 驗證待補齊' + + return AgentVerificationResponse( + generated_at=checked_at, + overall_status=overall_status, + overall_label=overall_label, + production_ready=production_ready, + decision_policy='AI Agent 只做情報蒐集、交叉驗證與反方稽核;正式投注推薦必須通過量化勝率、期望值、賠率門檻、資料新鮮度與賽後校準,不允許單一 LLM 直接發單。', + calibration_summary=calibration_summary, + checks=checks, + ) + + +def _agent_recommendation_label(code: Any) -> str: + labels = { + 'SAFE_SINGLE': '單關保守候選', + 'HIGH_RISK_SINGLE': '高波動單關候選', + 'CONDITIONAL_ENTRY': '預掛條件單', + 'SAFE_PARLAY': '跨場串關候選', + 'SGP_LOTTERY': '同場串關小注候選', + } + return labels.get(str(code or ''), str(code or '未標示策略')) + + +def _compact_agent_review_items(card_payload: dict[str, Any], limit: int = 10) -> list[dict[str, Any]]: + items = _all_daily_card_items(card_payload) + items.sort( + key=lambda item: ( + float(item.get('confidence_score') or 0.0), + float(item.get('ev_percent') or 0.0), + -float(item.get('stake_units') or 0.0), + ), + reverse=True, + ) + compact: list[dict[str, Any]] = [] + for item in items[:limit]: + compact.append( + { + 'match': item.get('match_label'), + 'market': item.get('market_type'), + 'selection': item.get('selection'), + 'recommendation': _agent_recommendation_label(item.get('recommendation')), + 'win_prob': item.get('win_prob'), + 'ev_percent': item.get('ev_percent'), + 'confidence_score': item.get('confidence_score'), + 'confidence_band': item.get('confidence_band'), + 'stake_amount_twd': item.get('stake_amount_twd'), + 'has_market_odds': item.get('has_market_odds'), + 'data_quality': item.get('data_quality'), + 'risk_level': item.get('risk_level'), + } + ) + return compact + + +def _agent_review_timeout_seconds() -> float: + raw_value = os.environ.get('NEMOTRON_REVIEW_TIMEOUT_SECONDS', '6') + try: + return max(4.0, min(float(raw_value), 60.0)) + except ValueError: + return 6.0 + + +def _agent_review_num_predict() -> int: + raw_value = os.environ.get('NEMOTRON_REVIEW_NUM_PREDICT', '120') + try: + return max(48, min(int(raw_value), 300)) + except ValueError: + return 120 + + +def _agent_daily_review_cache_key(target_date: datetime.date) -> str: + return f'agent:daily_review:{target_date.isoformat()}' + + +def _model_payload(model: BaseModel) -> dict[str, Any]: + if hasattr(model, 'model_dump'): + return model.model_dump() + return model.dict() + + +async def _read_cached_agent_daily_review(target_date: datetime.date) -> AgentDailyReviewResponse | None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + raw_value = await redis.get(_agent_daily_review_cache_key(target_date)) + if not raw_value: + return None + payload = json.loads(raw_value) + return AgentDailyReviewResponse(**payload) + except Exception: + logger.exception('NemoTron 每日稽核快取讀取失敗:%s', target_date.isoformat()) + return None + finally: + await redis.aclose() + + +async def _write_cached_agent_daily_review(review: AgentDailyReviewResponse) -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.set( + _agent_daily_review_cache_key(_to_date(review.date)), + json.dumps(_model_payload(review), ensure_ascii=False), + ex=AGENT_DAILY_REVIEW_CACHE_TTL_SECONDS, + ) + finally: + await redis.aclose() + + +def _build_agent_review_fallback(compact_items: list[dict[str, Any]], reason: str) -> str: + ranked_items = sorted( + compact_items, + key=lambda item: ( + item.get('has_market_odds') is True, + float(item.get('confidence_score') or 0), + float(item.get('ev_percent') or 0), + ), + ) + risk_lines: list[str] = [] + keep_lines: list[str] = [] + + for item in ranked_items[:3]: + match = item.get('match') or '未標示賽事' + market = item.get('market') or '未標示玩法' + recommendation = item.get('selection') or item.get('recommendation') or '未標示選項' + confidence = float(item.get('confidence_score') or 0) + ev_percent = float(item.get('ev_percent') or 0) + data_quality = item.get('data_quality') or 'unknown' + has_odds = item.get('has_market_odds') is True + risk_note = '缺少即時盤口' if not has_odds else f'資料品質 {data_quality}' + risk_lines.append( + f'{match} 的 {market}「{recommendation}」信心 {confidence:.1f}、EV {ev_percent:.2f}%,{risk_note},需等盤口更新再放大倉位。' + ) + + for item in sorted(compact_items, key=lambda entry: float(entry.get('confidence_score') or 0), reverse=True)[:2]: + match = item.get('match') or '未標示賽事' + market = item.get('market') or '未標示玩法' + recommendation = item.get('selection') or item.get('recommendation') or '未標示選項' + keep_lines.append(f'{match} 的 {market}「{recommendation}」可保留在觀察清單,但仍要套用賠率門檻與新台幣下注上限。') + + return ( + f'總結:{reason},系統已改用量化降級稽核,避免頁面逾時或空白。' + f'\n最高風險:{";".join(risk_lines) if risk_lines else "目前沒有足夠候選可判讀風險。"}' + f'\n可保留:{";".join(keep_lines) if keep_lines else "暫不放大任何推薦。"}' + '\n需要等待的資料:最新盤口、實際先發、傷停、臨場水位變化與賽果回填。' + ) + + +async def _run_ollama_review(prompt: str, model: str, base_url: str, timeout_seconds: float) -> str: + timeout = httpx.Timeout(timeout_seconds, connect=min(5.0, timeout_seconds), read=timeout_seconds, write=5.0, pool=5.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + f"{base_url.rstrip('/')}/api/generate", + json={ + 'model': model, + 'prompt': prompt, + 'stream': False, + 'options': { + 'temperature': 0.2, + 'num_predict': _agent_review_num_predict(), + }, + }, + ) + response.raise_for_status() + payload = response.json() + text = payload.get('response') + return text.strip() if isinstance(text, str) else '' + + +def _extract_gemini_text(payload: Mapping[str, Any]) -> str: + parts: list[str] = [] + for candidate in payload.get('candidates') or []: + content = candidate.get('content') if isinstance(candidate, Mapping) else None + if not isinstance(content, Mapping): + continue + for part in content.get('parts') or []: + if isinstance(part, Mapping) and isinstance(part.get('text'), str): + parts.append(part['text']) + return '\n'.join(part.strip() for part in parts if part.strip()).strip() + + +def _extract_gemini_token_usage(payload: Mapping[str, Any], prompt: str, text: str) -> tuple[int, int]: + usage = payload.get('usageMetadata') + if isinstance(usage, Mapping): + input_tokens = int(float(usage.get('promptTokenCount') or usage.get('inputTokenCount') or 0)) + output_tokens = int(float(usage.get('candidatesTokenCount') or usage.get('outputTokenCount') or 0)) + if input_tokens or output_tokens: + return max(0, input_tokens), max(0, output_tokens) + return max(1, len(prompt) // 4), max(1, len(text) // 4) + + +async def _run_gemini_agent_review( + *, + target_date: datetime.date, + prompt: str, + compact_items: list[dict[str, Any]], + guardrails: list[str], + nemotron_model: str, + failure_reason: str, +) -> AgentDailyReviewResponse | None: + api_key = os.environ.get('GEMINI_API_KEY', '').strip() + if not api_key: + return None + + usage_before = await _read_gemini_usage() + if usage_before.paused or usage_before.remaining_usd <= 0.02: + return None + + model = os.environ.get('GEMINI_REVIEW_MODEL') or os.environ.get('GEMINI_MODEL', 'gemini-3-flash-preview') + gemini_items = [ + { + '賽事': item.get('match'), + '玩法': item.get('market'), + '選項': item.get('selection'), + '信心分數': item.get('confidence_score'), + 'EV百分比': item.get('ev_percent'), + '盤口狀態': '已有盤口' if item.get('has_market_odds') else '等待盤口', + '資料品質': item.get('data_quality'), + '風險': item.get('risk_level'), + } + for item in compact_items + ] + gemini_prompt = ( + '你是世界盃投注量化系統的付費 AI 備援稽核員。' + '請根據候選清單做「反方稽核」,目標是指出哪些投注要降權、等待或小注。' + '只使用候選清單裡的資訊,不得新增不存在的賽事、賠率、傷停或新聞。' + '不得承諾獲利,不得鼓吹重倉。' + '請用繁體中文,完全照以下四行格式輸出,每行 30 到 70 字,總字數至少 120 字:' + '\n總結:' + '\n最高風險:' + '\n可保留:' + '\n等待資料:' + f'\nNemoTron 狀態:{failure_reason}' + f'\n日期:{target_date.isoformat()}' + f'\n候選清單:{json.dumps(gemini_items, ensure_ascii=False)}' + ) + try: + timeout_seconds = max(8.0, min(float(os.environ.get('GEMINI_REVIEW_TIMEOUT_SECONDS', '18')), 30.0)) + except ValueError: + timeout_seconds = 18.0 + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds, connect=5.0)) as client: + response = await client.post( + f'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent', + headers={'x-goog-api-key': api_key}, + json={ + 'contents': [ + { + 'role': 'user', + 'parts': [{'text': gemini_prompt}], + } + ], + 'generationConfig': { + 'temperature': 0.15, + 'maxOutputTokens': 260, + }, + }, + ) + response.raise_for_status() + payload = response.json() + except Exception: + logger.exception('Gemini 備援稽核呼叫失敗') + return None + + text = _extract_gemini_text(payload) + input_tokens, output_tokens = _extract_gemini_token_usage(payload, gemini_prompt, text) + usage_after = await _record_gemini_usage(input_tokens=input_tokens, output_tokens=output_tokens, grounded_query_count=0) + if usage_after.paused and not text: + return None + + required_labels = ('總結', '最高風險', '可保留', '等待資料') + if len(text) < 80 or not all(label in text for label in required_labels): + summary = _build_agent_review_fallback(compact_items, 'Gemini 備援回覆過短,改用量化降級稽核') + status = 'degraded' + status_label = 'Gemini 備援回覆過短,已使用量化降級稽核' + raw_response = text or None + else: + summary = text + status = 'gemini_fallback' + status_label = 'Gemini 備援稽核已完成' + raw_response = text + return AgentDailyReviewResponse( + generated_at=datetime.now(timezone.utc).isoformat(), + date=target_date.isoformat(), + status=status, + status_label=status_label, + model=f'Gemini:{model};NemoTron:{nemotron_model}', + reviewed_count=len(compact_items), + summary=summary, + raw_response=raw_response, + guardrails=guardrails + + [ + f'Gemini 備援已納入費用監控:本月估算 ${usage_after.estimated_cost_usd:.6f} / ${usage_after.cap_usd:.2f}。', + 'Gemini 只產生反方稽核文字,不直接改寫正式下注推薦。', + ], + ) + + +async def _build_agent_daily_review(target_date: datetime.date, allow_live_model: bool = True) -> AgentDailyReviewResponse: + generated_at = datetime.now(timezone.utc).isoformat() + base_url = os.environ.get('OLLAMA_BASE_URL') or os.environ.get('NEMOTRON_API_BASE') or os.environ.get('NEMOTRON_BASE_URL') or '' + model = os.environ.get('OLLAMA_NEMOTRON_MODEL') or os.environ.get('NEMOTRON_MODEL') or 'nemotron-mini' + guardrails = [ + 'NemoTron 只做反方稽核與風險提示,不直接產生正式下注推薦。', + '若量化勝率、期望值、賠率門檻或資料新鮮度不通過,AI 意見不能覆蓋風控。', + '此流程不呼叫付費 Gemini,因此不會增加 Gemini 費用。', + ] + + if allow_live_model: + if not base_url: + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='pending_config', + status_label='NemoTron endpoint 尚未設定', + model=model, + reviewed_count=0, + summary='尚未設定 NemoTron/Ollama endpoint,無法執行反方稽核。', + raw_response=None, + guardrails=guardrails, + ) + + ok, probe_message = await _probe_ollama_model(base_url, model) + if not ok: + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='degraded', + status_label='NemoTron 尚未可用', + model=model, + reviewed_count=0, + summary=probe_message, + raw_response=None, + guardrails=guardrails, + ) + + match_payload = await _query_match_day_snapshot(target_date) + card = generate_daily_card(target_date.isoformat(), match_payload) + compact_items = _compact_agent_review_items(card, limit=5 if allow_live_model else 10) + if not compact_items: + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='no_candidates', + status_label='沒有可稽核候選', + model=model, + reviewed_count=0, + summary='當日沒有可稽核的推薦候選;可能是尚未開盤、資料不足,或推薦閘門全部擋下。', + raw_response=None, + guardrails=guardrails, + ) + + prompt_items = [ + { + '玩法': item.get('market'), + '選項': item.get('selection'), + '信心': item.get('confidence_score'), + 'EV': item.get('ev_percent'), + '盤口': '有' if item.get('has_market_odds') else '缺', + '品質': item.get('data_quality'), + } + for item in compact_items + ] + prompt = ( + '你是世界盃投注量化系統的反方稽核員。' + '請只用繁體中文輸出 4 行:總結、最高風險、可保留、等待資料。' + '每行 45 字內,不新增賽事或賠率,不承諾獲利。' + f'\n日期:{target_date.isoformat()}' + f'\n候選:{json.dumps(prompt_items, ensure_ascii=False)}' + ) + + if not allow_live_model: + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='pending_cache', + status_label='等待背景稽核快取', + model=model, + reviewed_count=len(compact_items), + summary=_build_agent_review_fallback(compact_items, '背景 NemoTron 稽核尚未產生快取'), + raw_response=None, + guardrails=guardrails, + ) + + timeout_seconds = _agent_review_timeout_seconds() + try: + review_text = await asyncio.wait_for( + _run_ollama_review(prompt, model, base_url, timeout_seconds), + timeout=timeout_seconds + 2.0, + ) + except (asyncio.TimeoutError, TimeoutError, httpx.TimeoutException): + reason = f'NemoTron 超過 {timeout_seconds:.0f} 秒未回應' + gemini_review = await _run_gemini_agent_review( + target_date=target_date, + prompt=prompt, + compact_items=compact_items, + guardrails=guardrails, + nemotron_model=model, + failure_reason=reason, + ) + if gemini_review is not None: + return gemini_review + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='degraded', + status_label='NemoTron 回應逾時,已使用量化降級稽核', + model=model, + reviewed_count=len(compact_items), + summary=_build_agent_review_fallback(compact_items, reason), + raw_response=None, + guardrails=guardrails, + ) + except Exception as exc: + reason = f'NemoTron 呼叫失敗:{type(exc).__name__}' + gemini_review = await _run_gemini_agent_review( + target_date=target_date, + prompt=prompt, + compact_items=compact_items, + guardrails=guardrails, + nemotron_model=model, + failure_reason=reason, + ) + if gemini_review is not None: + return gemini_review + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='degraded', + status_label='NemoTron 稽核失敗', + model=model, + reviewed_count=len(compact_items), + summary=_build_agent_review_fallback(compact_items, reason), + raw_response=None, + guardrails=guardrails, + ) + + return AgentDailyReviewResponse( + generated_at=generated_at, + date=target_date.isoformat(), + status='active', + status_label='NemoTron 已完成反方稽核', + model=model, + reviewed_count=len(compact_items), + summary=review_text or 'NemoTron 已回應,但內容為空;建議保留量化閘門判斷。', + raw_response=review_text, + guardrails=guardrails, + ) async def _query_latest_smart_money( @@ -966,25 +3353,29 @@ async def _query_opening_odds( match_id: str, market_type: str, selection: str, + market_line: float | None = None, + handicap: float | None = None, ) -> tuple[float | None, float | None]: + filters = [ + OddsHistory.match_id == match_id, + OddsHistory.market_type == market_type, + OddsHistory.selection == selection, + ] + if market_line is not None: + filters.append(OddsHistory.market_line == market_line) + if handicap is not None: + filters.append(OddsHistory.handicap == handicap) + 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, - ) + .where(*filters) .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, - ) + .where(*filters) .order_by(desc(OddsHistory.recorded_at)) .limit(1) ) @@ -999,6 +3390,47 @@ async def _query_opening_odds( ) +async def _query_match_odds_source_meta(session: Any, match_id: str) -> dict[str, str]: + stmt = ( + select( + Bookmaker.id, + Bookmaker.name, + func.count(OddsHistory.id), + func.max(OddsHistory.recorded_at), + ) + .join(OddsHistory, OddsHistory.bookmaker_id == Bookmaker.id) + .where(OddsHistory.match_id == match_id) + .group_by(Bookmaker.id, Bookmaker.name) + ) + result = await session.execute(stmt) + rows = result.all() + if not rows: + return {'label': '模型推算最低門檻', 'kind': 'conditional_threshold'} + + bookmaker_ids = {str(row[0]) for row in rows} + def source_timestamp(row: Any) -> float: + value = row[3] + if hasattr(value, 'timestamp'): + try: + return float(value.timestamp()) + except (TypeError, ValueError, OSError): + return 0.0 + return 0.0 + + latest_row = sorted(rows, key=source_timestamp, reverse=True)[0] + latest_name = str(latest_row[1] or latest_row[0]) + + if 'taiwan-sports-lottery-reference' in bookmaker_ids: + if len(bookmaker_ids) > 1: + return {'label': f'台灣運彩參考盤 + {len(bookmaker_ids) - 1} 個來源', 'kind': 'reference_market'} + return {'label': '台灣運彩參考盤', 'kind': 'reference_market'} + if len(bookmaker_ids) >= 2: + return {'label': f'多來源盤口({len(bookmaker_ids)} 個來源)', 'kind': 'multi_provider_market'} + if 'espn-odds' in bookmaker_ids or 'espn' in latest_name.lower(): + return {'label': 'ESPN 比分備援盤', 'kind': 'scoreboard_fallback'} + return {'label': latest_name, 'kind': 'single_provider_market'} + + def _to_summary(records: list[ProofYieldRecord]) -> LedgerSummary: return ProofOfYieldStore.summarize(records) @@ -1397,6 +3829,8 @@ async def list_matches(limit: int = 500) -> list[MatchListItem]: match_id=match_payload['match_id'], home_team=match_payload['home_team'], away_team=match_payload['away_team'], + home_score=match_payload.get('home_score'), + away_score=match_payload.get('away_score'), kickoff_utc=match_payload['kickoff_utc'], status=str(match_payload['status']), venue_name=match_payload['venue_name'], @@ -1417,6 +3851,8 @@ async def get_match_detail_route(match_id: str) -> MatchDetailResponse: match_id=payload['match_id'], home_team=payload['home_team'], away_team=payload['away_team'], + home_score=payload.get('home_score'), + away_score=payload.get('away_score'), home_xg=payload['home_xg'], away_xg=payload['away_xg'], match_time_utc=payload['match_time_utc'], @@ -1460,14 +3896,212 @@ async def calculate_hedge_signal(req: HedgeRequest) -> HedgeResponse: return HedgeResponse(**result) +def _daily_card_calendar_cache_key(start_date: str, end_date: str | None = None) -> str: + end_part = end_date or 'auto' + return f'daily-card-calendar:v2:{start_date}:{end_part}' + + +async def _read_cached_daily_card_calendar(cache_key: str) -> dict[str, Any] | None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + raw = await redis.get(cache_key) + if not raw: + return None + ttl = await redis.ttl(cache_key) + payload = json.loads(raw) + if not isinstance(payload, dict): + return None + payload = dict(payload) + payload['cache_status'] = 'hit' + payload['cache_remaining_seconds'] = max(0, int(ttl or 0)) + payload['served_at'] = datetime.now(timezone.utc).isoformat() + return payload + except Exception as exc: + logger.warning('日期推薦摘要快取讀取失敗:%s', exc) + return None + finally: + await redis.aclose() + + +async def _write_cached_daily_card_calendar(cache_key: str, payload: dict[str, Any]) -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.setex( + cache_key, + DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, + json.dumps(payload, ensure_ascii=False, default=str), + ) + except Exception as exc: + logger.warning('日期推薦摘要快取寫入失敗:%s', exc) + finally: + await redis.aclose() + + +async def _build_daily_card_calendar_payload(start_date: str = '2026-06-11', end_date: str | None = None) -> dict[str, Any]: + start = _to_date(start_date).isoformat() + end = _to_date(end_date).isoformat() if end_date else None + match_rows = await _query_match_list(limit=1000) + match_counts: dict[str, int] = defaultdict(int) + + for match_payload in match_rows: + kickoff = match_payload.get('kickoff_utc') + try: + if isinstance(kickoff, datetime): + kickoff_dt = kickoff + else: + kickoff_dt = datetime.fromisoformat(str(kickoff).replace('Z', '+00:00')) + if kickoff_dt.tzinfo is None: + kickoff_dt = kickoff_dt.replace(tzinfo=timezone.utc) + taipei_date = kickoff_dt.astimezone(timezone(timedelta(hours=8))).date().isoformat() + except (TypeError, ValueError): + continue + + if taipei_date < start: + continue + if end is not None and taipei_date > end: + continue + match_counts[taipei_date] += 1 + + dates: list[dict[str, Any]] = [] + for target_date in sorted(match_counts.keys()): + target_day = _to_date(target_date) + snapshot_payload = await _read_daily_recommendation_snapshot_payload(target_date) + if target_day <= _taipei_today_date() and snapshot_payload and _all_daily_card_items(snapshot_payload): + card = snapshot_payload + else: + match_payload = await _query_match_day_snapshot(target_day) + card = generate_daily_card(target_date, match_payload) + if target_day <= _taipei_today_date() and not _all_daily_card_items(card) and match_counts[target_date] > 0: + card = _with_missing_snapshot_notice(target_date, card, match_counts[target_date]) + if target_day >= _taipei_today_date() and _all_daily_card_items(card): + await _persist_daily_recommendation_snapshot(target_date, card) + snapshot_payload = card + items = _all_daily_card_items(card) + snapshot_item_count = len(_all_daily_card_items(snapshot_payload)) if snapshot_payload else 0 + snapshot_status = 'saved' + if snapshot_item_count <= 0: + snapshot_status = 'missing_after_kickoff' if card.get('market_data_status') == 'snapshot_missing_after_kickoff' else 'not_saved_yet' + live_count = sum(1 for item in items if item.get('has_market_odds') is True) + total_amount_twd = card.get('total_daily_amount_twd') + if total_amount_twd is None: + total_amount_twd = round( + float(card.get('total_daily_unit_recommendation') or 0) * float(card.get('unit_size_twd') or 1000) + ) + + dates.append( + { + 'date': target_date, + 'match_count': match_counts[target_date], + 'matched_matches': card.get('matched_matches', 0), + 'recommendation_count': len(items), + 'live_count': live_count, + 'watch_count': len(items) - live_count, + 'safe_single_count': len(card.get('safe_singles') or []), + 'high_risk_single_count': len(card.get('high_risk_singles') or []), + 'safe_parlay_count': len(card.get('safe_parlays') or []), + 'sgp_lottery_count': len(card.get('sgp_lotteries') or []), + 'total_amount_twd': total_amount_twd, + 'market_data_status': card.get('market_data_status'), + 'snapshot_status': snapshot_status, + 'snapshot_item_count': snapshot_item_count, + 'snapshot_preserved_count': int((card.get('data_quality_summary') or {}).get('preserved_snapshot_items') or 0), + 'summary': card.get('summary'), + } + ) + + return { + 'generated_at': datetime.now(timezone.utc).isoformat(), + 'start_date': start, + 'end_date': end or (dates[-1]['date'] if dates else start), + 'dates': dates, + 'cache_status': 'generated', + 'cache_ttl_seconds': DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, + } + + +@app.get('/analytics/daily-card-calendar') +async def daily_card_calendar_route(start_date: str = '2026-06-11', end_date: str | None = None) -> dict[str, Any]: + """依世界盃日期產生賽事與推薦摘要,供首頁與作戰室日期盤使用。""" + + start = _to_date(start_date).isoformat() + end = _to_date(end_date).isoformat() if end_date else None + cache_key = _daily_card_calendar_cache_key(start, end) + cached = await _read_cached_daily_card_calendar(cache_key) + if cached: + return cached + + payload = await _build_daily_card_calendar_payload(start, end) + await _write_cached_daily_card_calendar(cache_key, payload) + return payload + + +@app.get('/analytics/daily-card-calendar/status') +async def daily_card_calendar_status_route() -> dict[str, Any]: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + raw = await redis.get(DAILY_CARD_CALENDAR_STATUS_KEY) + if not raw: + return { + 'status': 'unknown', + 'status_label': '日期摘要快取 worker 尚未回報', + 'cache_ttl_seconds': DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, + } + payload = json.loads(raw) + if isinstance(payload, dict): + return payload + finally: + await redis.aclose() + + return { + 'status': 'error', + 'status_label': '日期摘要快取 worker 狀態格式異常', + 'cache_ttl_seconds': DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS, + } + + @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) + target_day = _to_date(target_date) + snapshot_payload = await _read_daily_recommendation_snapshot_payload(target_date) if target_day <= _taipei_today_date() else None + if target_day < _taipei_today_date() and snapshot_payload: + return DailyCardResponse(**snapshot_payload) + + match_payload = await _query_match_day_snapshot(target_day) result = generate_daily_card(target_date, match_payload) + if target_day <= _taipei_today_date() and not _all_daily_card_items(result): + if snapshot_payload and _all_daily_card_items(snapshot_payload): + return DailyCardResponse(**snapshot_payload) + return DailyCardResponse(**_with_missing_snapshot_notice(target_date, result)) + if target_day >= _taipei_today_date(): + await _persist_daily_recommendation_snapshot(target_date, result) return DailyCardResponse(**result) +@app.get('/analytics/recommendation-performance', response_model=RecommendationPerformanceResponse) +async def recommendation_performance_route(days_back: int = 7) -> RecommendationPerformanceResponse: + normalized_days_back = max(1, min(days_back, 30)) + return await _build_recommendation_performance(normalized_days_back) + + +@app.get('/analytics/agent-verification', response_model=AgentVerificationResponse) +async def agent_verification_route() -> AgentVerificationResponse: + return await _build_agent_verification() + + +@app.get('/analytics/gemini-usage', response_model=GeminiUsageResponse) +async def gemini_usage_route() -> GeminiUsageResponse: + return await _read_gemini_usage() + + +@app.get('/analytics/agent-daily-review/{target_date}', response_model=AgentDailyReviewResponse) +async def agent_daily_review_route(target_date: str) -> AgentDailyReviewResponse: + review_date = _to_date(target_date) + cached_review = await _read_cached_agent_daily_review(review_date) + if cached_review is not None: + return cached_review + return await _build_agent_daily_review(review_date, allow_live_model=False) + + 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/db_init_timescaledb.sql b/platform/backend/db_init_timescaledb.sql index 8e1d9ca..345207f 100644 --- a/platform/backend/db_init_timescaledb.sql +++ b/platform/backend/db_init_timescaledb.sql @@ -1,4 +1,5 @@ CREATE EXTENSION IF NOT EXISTS timescaledb; +CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TABLE IF NOT EXISTS venues ( id TEXT PRIMARY KEY, @@ -30,18 +31,26 @@ CREATE TABLE IF NOT EXISTS matches ( match_time_utc TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'pre-match', home_xg DOUBLE PRECISION, - away_xg DOUBLE PRECISION + away_xg DOUBLE PRECISION, + home_score INTEGER, + away_score INTEGER, + result_synced_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS odds_history ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, 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, + market_line DOUBLE PRECISION, + handicap DOUBLE PRECISION, decimal_odds DOUBLE PRECISION NOT NULL, implied_probability DOUBLE PRECISION NOT NULL, - recorded_at TIMESTAMPTZ NOT NULL + source_market_key TEXT, + source_outcome_name TEXT, + recorded_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (id, recorded_at) ); CREATE TABLE IF NOT EXISTS smart_money_flow ( @@ -58,6 +67,8 @@ CREATE TABLE IF NOT EXISTS smart_money_flow ( 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_odds_match_market_line_recorded_at + ON odds_history (match_id, market_type, selection, market_line, handicap, 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); @@ -99,13 +110,14 @@ CREATE TABLE IF NOT EXISTS affiliate_bookmakers ( ); CREATE TABLE IF NOT EXISTS affiliate_clicks ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, 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 + converted BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (id, timestamp) ); CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_timestamp diff --git a/platform/backend/requirements.txt b/platform/backend/requirements.txt index 23fada6..392a057 100644 --- a/platform/backend/requirements.txt +++ b/platform/backend/requirements.txt @@ -10,3 +10,4 @@ numpy==2.1.3 scipy==1.14.1 scikit-learn==1.5.2 xgboost==2.1.0 +httpx==0.27.0 diff --git a/platform/web/Dockerfile b/platform/web/Dockerfile index 33dec94..094fc83 100644 --- a/platform/web/Dockerfile +++ b/platform/web/Dockerfile @@ -1,16 +1,60 @@ -FROM node:22-alpine +# ── stage 1: dependencies ────────────────────────────────────────────────────── +FROM node:22-alpine AS deps WORKDIR /app +RUN apk add --no-cache openssl +COPY package.json ./ +RUN npm install --legacy-peer-deps -COPY package.json package-lock.json* ./ -RUN npm install +# ── stage 2: build ───────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder +WORKDIR /app +RUN apk add --no-cache openssl +# NOTE: NEXTAUTH_SECRET is a build-time placeholder only — never expose real secrets in ARG/ENV +ENV DATABASE_URL=postgresql://fifa_user:change_me@localhost:5432/fifa2026 +ENV NEXTAUTH_SECRET=build-time-placeholder +ENV NEXTAUTH_URL=https://2026fifa.wooo.work +ENV ANALYTICS_BACKEND_URL=http://fifa2026-backend:8000 +COPY --from=deps /app/node_modules ./node_modules COPY . . - +RUN npx prisma generate RUN npm run build +# ── stage 3: runtime (hardened) ──────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +WORKDIR /app +ENV NODE_ENV=production ENV PORT=3000 + +# Install openssl first (needed at runtime) +RUN apk add --no-cache openssl + +# Security: create non-root user BEFORE removing shell +RUN addgroup -g 1001 -S nodejs \ + && adduser -S nextjs -u 1001 -G nodejs + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/prisma ./prisma + +# Lock down ownership +RUN chown -R nextjs:nodejs /app \ + && chmod -R o-rwx /app + +# Security: remove tools that can be used to download/execute malware +# MUST happen AFTER all RUN commands that need a shell +RUN rm -f /usr/bin/wget /usr/bin/curl \ + /usr/local/bin/npm /usr/local/bin/npx \ + && true + +# Drop all privileges – run as nextjs (uid 1001) +USER nextjs + EXPOSE 3000 -CMD ["npm", "start"] - +# Use node directly (not npm) to avoid extra shell processes +CMD ["node", "node_modules/.bin/next", "start", "-p", "3000"] diff --git a/platform/web/app/api/analytics/daily-card/[date]/route.ts b/platform/web/app/api/analytics/daily-card/[date]/route.ts index 5ea4681..ad8a901 100644 --- a/platform/web/app/api/analytics/daily-card/[date]/route.ts +++ b/platform/web/app/api/analytics/daily-card/[date]/route.ts @@ -2,10 +2,25 @@ 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 }> }) { +type RouteContext = { + params: Promise<{ date: string }>; +}; + +export async function GET(_request: Request, { params }: RouteContext) { + const { date } = await params; + + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return NextResponse.json({ message: '日期格式必須為 YYYY-MM-DD' }, { status: 400 }); + } + try { - const { date } = await params; - const response = await fetch(`${ANALYTICS_BACKEND}/analytics/daily-card/${date}`, { method: 'GET' }); + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/daily-card/${encodeURIComponent(date)}`, { + method: 'GET', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + }, + }); if (!response.ok) { const message = await response.text(); @@ -13,9 +28,12 @@ export async function GET(_: Request, { params }: { params: Promise<{ date: stri } const data = (await response.json()) as Record; - return NextResponse.json(data); + return NextResponse.json({ + generated_at: new Date().toISOString(), + ...data, + }); } catch (error) { - const message = error instanceof Error ? error.message : '每日操盤卡服務暫時無法連線'; + 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 index 774f0fc..bb70763 100644 --- a/platform/web/app/api/analytics/matches/[matchId]/route.ts +++ b/platform/web/app/api/analytics/matches/[matchId]/route.ts @@ -2,11 +2,14 @@ import { NextResponse } from 'next/server'; const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; +export const dynamic = 'force-dynamic'; + export async function GET(_: Request, { params }: { params: Promise<{ matchId: string }> }) { try { const { matchId } = await params; - const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, { + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${encodeURIComponent(matchId)}`, { method: 'GET', + cache: 'no-store', headers: { 'Content-Type': 'application/json', }, diff --git a/platform/web/app/api/analytics/matches/route.ts b/platform/web/app/api/analytics/matches/route.ts index db35726..a1b6f33 100644 --- a/platform/web/app/api/analytics/matches/route.ts +++ b/platform/web/app/api/analytics/matches/route.ts @@ -2,10 +2,13 @@ import { NextResponse } from 'next/server'; const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; +export const dynamic = 'force-dynamic'; + export async function GET() { try { const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { method: 'GET', + cache: 'no-store', headers: { 'Content-Type': 'application/json', }, diff --git a/platform/web/app/backtesting/page.tsx b/platform/web/app/backtesting/page.tsx index b820072..ad27c49 100644 --- a/platform/web/app/backtesting/page.tsx +++ b/platform/web/app/backtesting/page.tsx @@ -17,15 +17,19 @@ type TradeRecord = { 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: '讓球' }, -]; +const history: TradeRecord[] = []; + +const emptyBacktestResult: BacktestResponse = { + 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 }], +}; function toLocalISOString(date: string): string { return new Date(`${date}T15:00:00+08:00`).toISOString(); @@ -74,17 +78,7 @@ export default function BacktestingPage() { 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 [result, setResult] = useState(emptyBacktestResult); const filtered = useMemo(() => { return history.filter((trade) => { @@ -133,6 +127,15 @@ export default function BacktestingPage() { setLoading(true); const execute = async () => { + if (history.length === 0) { + if (active) { + setResult(emptyBacktestResult); + setErrorMessage('尚無真實已結算推薦注單;不展示匿名範例勝率或模擬報酬率。'); + setLoading(false); + } + return; + } + try { const data = await runBacktest(requestPayload); if (active) { @@ -159,6 +162,11 @@ export default function BacktestingPage() { return (

自訂策略回測引擎

+
+

+ 目前尚無真實已結算推薦注單可回測,因此本頁不展示匿名範例勝率或模擬報酬率。正式績效只會來自「公開收益帳本」內已結算推薦。 +

+

策略條件設定

@@ -230,15 +238,15 @@ export default function BacktestingPage() {
-

樣本數

+

已結算樣本數

{result.matched}

-

勝率

+

實證勝率

{result.win_rate.toFixed(1)}%

-

ROI

+

實證報酬率

{result.roi_percent.toFixed(2)}%

@@ -255,7 +263,7 @@ export default function BacktestingPage() {
@@ -269,7 +277,7 @@ export default function BacktestingPage() { {trade.isWin ? '勝' : '敗'}|賠率 {trade.odds} ))} - {filtered.length === 0 ?
  • 此條件下目前無筆交易
  • : null} + {filtered.length === 0 ?
  • 尚無真實已結算交易,不顯示示範注單。
  • : null}
    diff --git a/platform/web/app/daily-card/page.tsx b/platform/web/app/daily-card/page.tsx index c676c82..2b5e21f 100644 --- a/platform/web/app/daily-card/page.tsx +++ b/platform/web/app/daily-card/page.tsx @@ -1,11 +1,15 @@ 'use client'; +import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; import { formatToTaipeiTime } from '@/lib/timezone'; -import { getDailyCard, type DailyCardItem } from '@/lib/analytics-api'; +import { getDailyCard, getDailyCardCalendar, type DailyCardCalendarDate, type DailyCardItem, type DailyCardResponse } from '@/lib/analytics-api'; import { ActionableBetCard } from '@/components/ActionableBetCard'; -const TAB_MAP: Record<'safe' | 'risk' | 'parlay' | 'sgp', string> = { +type TabKey = 'all' | 'safe' | 'risk' | 'parlay' | 'sgp'; + +const TAB_MAP: Record = { + all: 'ALL', safe: 'SAFE_SINGLE', risk: 'HIGH_RISK_SINGLE', parlay: 'SAFE_PARLAY', @@ -13,45 +17,256 @@ const TAB_MAP: Record<'safe' | 'risk' | 'parlay' | 'sgp', string> = { }; const sampleDate = formatToTaipeiTime(new Date().toISOString(), 'yyyy-MM-dd'); +const tomorrowSampleDate = formatToTaipeiTime(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), 'yyyy-MM-dd'); +const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist'; +const TOURNAMENT_START_DATE = '2026-06-11'; + +function pickKey(item: DailyCardItem): string { + const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim(); + return [ + normalize(item.match_id), + normalize(item.market_type), + normalize(item.selection), + ].join('|'); +} + +function formatTwd(value: number): string { + return new Intl.NumberFormat('zh-TW', { + style: 'currency', + currency: 'TWD', + maximumFractionDigits: 0, + }).format(value); +} + +function stakeAmountTwd(item: DailyCardItem): number { + return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000)); +} + +function dailyAmountTwd(card?: DailyCardResponse | null): number { + if (!card) return 0; + return card.total_daily_amount_twd ?? Math.round(card.total_daily_unit_recommendation * (card.unit_size_twd ?? 1000)); +} + +function itemsFromCard(card?: DailyCardResponse | null): DailyCardItem[] { + if (!card) return []; + return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles]; +} + +function isConditionalPick(item: DailyCardItem): boolean { + return ( + item.has_market_odds === false || + item.selection.includes('預掛條件') || + item.selection.includes('參考盤監控') || + item.rationale.includes('尚未取得可用即時盤口') || + item.rationale.includes('尚未取得完整實盤賠率') || + Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件'))) + ); +} + +function isMissingSnapshotCard(card?: DailyCardResponse | null): boolean { + return card?.market_data_status === 'snapshot_missing_after_kickoff'; +} + +function snapshotStatusText(value?: string | null): string { + if (value === 'saved') return '已鎖賽前快照'; + if (value === 'missing_after_kickoff') return '缺賽前快照'; + if (value === 'not_saved_yet') return '等待快照保存'; + return '快照狀態待回報'; +} + +function qualityText(value?: string): string { + if (value === 'rank_elo_prior') return '國際排名與實力分數'; + if (value === 'fallback_prior') return '基礎估計'; + if (value === 'observed') return '實測/完整模型'; + if (value === 'mixed') return '混合來源'; + return '待標示'; +} + +function sourceKindText(item: DailyCardItem): string { + if (item.odds_source_kind === 'reference_market') return '台灣盤參考'; + if (item.odds_source_kind === 'multi_provider_market') return '多來源盤'; + if (item.odds_source_kind === 'single_provider_market') return '單一來源盤'; + if (item.odds_source_kind === 'scoreboard_fallback') return '比分備援盤'; + if (item.odds_source_kind === 'conditional_threshold') return '模型門檻'; + return item.has_market_odds ? '實盤檢查' : '模型門檻'; +} + +function sourceExplainText(item: DailyCardItem): string { + if (item.odds_source_kind === 'reference_market') { + return '台灣運彩參考盤可用來比對最低賠率,但不是多莊家正式市場。'; + } + if (item.odds_source_kind === 'multi_provider_market') { + return '多個賠率來源可比對,仍需確認同一玩法、同一分線與最新盤口。'; + } + if (item.odds_source_kind === 'conditional_threshold') { + return '這是模型推算出的最低進場賠率,平台未達門檻就跳過。'; + } + return item.has_market_odds + ? '已有盤口可做下注前檢查,但仍要重新核對來源與賠率。' + : '尚未取得該玩法直接盤口,只能先監控。'; +} + +function plainBackendText(value?: string): string { + if (!value) { + return ''; + } + + return value + .replaceAll('AI Agent', 'AI 監控') + .replaceAll('AI', 'AI') + .replaceAll('EV', '期望值') + .replaceAll('edge', '模型優勢') + .replaceAll('CLV', '收盤價差') + .replaceAll('xG', '預期進球') + .replaceAll('Poisson', '進球分布') + .replaceAll('FIFA ranking / Elo', '國際排名與實力分數') + .replaceAll('FIFA 排名/Elo', '國際排名與實力分數') + .replaceAll('FIFA/Elo', '國際排名與實力分數') + .replaceAll('market implied', '市場估計') + .replaceAll('市場隱含機率', '市場估計機率') + .replaceAll('小倉位', '小注碼') + .replaceAll('正 EV', '期望值為正') + .replaceAll('正 期望值', '期望值為正') + .replaceAll('高 EV', '高期望值') + .replaceAll('高 期望值', '高期望值') + .replaceAll('edge 與倉位', '模型優勢與注碼'); +} + +function recommendationText(item: DailyCardItem): string { + const isConditional = isConditionalPick(item); + + if (item.recommendation === 'SAFE_SINGLE') return isConditional ? '觀察單關' : '核心單關'; + if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠'; + if (item.recommendation === 'SAFE_PARLAY') return isConditional ? '觀察串關' : '跨場串關'; + if (item.recommendation === 'SGP_LOTTERY') return '同場小注'; + return '研究候選'; +} + +function tabButtonClass(active: boolean): string { + return `rounded-full px-4 py-2 text-sm font-bold transition-all ${ + active + ? 'bg-[#7d2a15] text-white shadow-[0_10px_26px_rgba(125,42,21,0.20)]' + : 'border border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822] hover:text-[#7d2a15]' + }`; +} export default function DailyCardPage() { - const [targetDate] = useState(sampleDate); + const [selectedDate, setSelectedDate] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const [activeTab, setActiveTab] = useState<'safe' | 'risk' | 'parlay' | 'sgp'>('safe'); + const [activeTab, setActiveTab] = useState('all'); const [selectedCount, setSelectedCount] = useState(0); - const [data, setData] = useState> | null>(null); + const [calendarDates, setCalendarDates] = useState([]); + const [dailyCards, setDailyCards] = useState>({}); + const [watchlist, setWatchlist] = useState([]); + const [feedbackMessage, setFeedbackMessage] = useState(''); - const tabCards: Record<'safe' | 'risk' | 'parlay' | 'sgp', DailyCardItem[]> = useMemo(() => { - if (!data) { - return { safe: [], risk: [], parlay: [], sgp: [] }; + const calendarByDate = useMemo(() => { + return new Map(calendarDates.map((item) => [item.date, item])); + }, [calendarDates]); + + const chronologicalDates = useMemo(() => { + return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b)); + }, [calendarDates]); + + const dateOptions = useMemo(() => { + const pinned = [sampleDate, tomorrowSampleDate].filter((date) => calendarByDate.has(date)); + const pinnedSet = new Set(pinned); + return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))]; + }, [calendarByDate, chronologicalDates]); + + const selectedCard = dailyCards[selectedDate] ?? null; + const selectedSummary = calendarByDate.get(selectedDate); + const selectedMissingSnapshot = isMissingSnapshotCard(selectedCard) || selectedSummary?.market_data_status === 'snapshot_missing_after_kickoff'; + const selectedSnapshotStatus = selectedSummary?.snapshot_status + ?? (selectedMissingSnapshot ? 'missing_after_kickoff' : selectedCard ? 'not_saved_yet' : null); + const selectedSnapshotItemCount = selectedSummary?.snapshot_item_count ?? 0; + const selectedMatchCount = selectedSummary?.match_count ?? selectedCard?.matched_matches ?? 0; + const dateSummaries = useMemo(() => { + return dateOptions.map((date) => { + const card = dailyCards[date]; + const items = itemsFromCard(card); + const summary = calendarByDate.get(date); + return { + date, + matchCount: summary?.match_count ?? card?.matched_matches ?? 0, + recommendationCount: summary?.recommendation_count ?? items.length, + liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length, + marketDataStatus: summary?.market_data_status ?? card?.market_data_status ?? null, + snapshotStatus: summary?.snapshot_status ?? null, + snapshotItemCount: summary?.snapshot_item_count ?? 0, + }; + }); + }, [calendarByDate, dailyCards, dateOptions]); + const nextAvailableSummary = useMemo(() => { + const future = dateSummaries.filter((item) => item.date > selectedDate && item.recommendationCount > 0); + if (future.length) return future[0]; + return dateSummaries.find((item) => item.date !== selectedDate && item.recommendationCount > 0) ?? null; + }, [dateSummaries, selectedDate]); + + const tabCards: Record = useMemo(() => { + if (!selectedCard) { + return { all: [], safe: [], risk: [], parlay: [], sgp: [] }; } + const safe = selectedCard.safe_singles; + const risk = selectedCard.high_risk_singles; + const parlay = selectedCard.safe_parlays; + const sgp = selectedCard.sgp_lotteries; return { - safe: data.safe_singles, - risk: data.high_risk_singles, - parlay: data.safe_parlays, - sgp: data.sgp_lotteries, + all: [...safe, ...parlay, ...sgp, ...risk], + safe, + risk, + parlay, + sgp, }; - }, [data]); + }, [selectedCard]); const briefing = useMemo(() => { - if (!data) { - return '系統載入中,將於短時間內給出當日全場賽前簡報。'; + if (!selectedCard) { + return '系統載入中,正在運算該日期的賽事、盤口與多玩法投注候選...'; } + if (isMissingSnapshotCard(selectedCard)) { + return `${selectedDate} 已開賽或完賽,但目前沒有可用的賽前投注快照。為了避免事後看答案補造推薦,這天不會產生新的下注建議;請切到下一個有候選的日期,或到賽後校準室看命中率回顧。`; + } + const matched = selectedMatchCount; + const totalAmount = dailyAmountTwd(selectedCard); + const safeCount = selectedCard.safe_singles.length; + const riskCount = selectedCard.high_risk_singles.length; + const allCards = tabCards.all; + const liveCount = allCards.filter((item) => item.has_market_odds).length; + const preMarketCount = allCards.length - liveCount; - return `AI 總結:今日比賽共 ${data.matched_matches} 場,策略核心為 ${ - data.total_daily_unit_recommendation - } Units。${ - data.safe_singles.length > 0 - ? `檢測到 ${data.safe_singles.length} 組高穩定單關機會,重點偏向亞盤與低風險連續進位。` - : '低風險盤口偏弱,建議保守減碼。' + return `${selectedDate} 已整理 ${matched} 場賽事,目前有 ${liveCount} 組可做下注前檢查、${preMarketCount} 組只能先放進賠率監控清單。建議參考上限為 ${formatTwd(totalAmount)}。${ + safeCount > 0 + ? `其中 ${safeCount} 組是單關候選;如果還沒有真實賠率,只能等待賠率達標後再考慮。` + : '目前沒有足夠漂亮的低風險單關,先保留資金比較合理。' } ${ - data.high_risk_singles.length > 0 - ? `另有 ${data.high_risk_singles.length} 組高賠搏冷訊號可做小額槓桿。` + riskCount > 0 + ? `另外 ${riskCount} 組屬於高賠小注候選,波動大,只適合很小的注碼。` : '' }`; - }, [data]); + }, [selectedCard, selectedDate, selectedMatchCount, tabCards.all]); + + const executionMetrics = useMemo(() => { + const allCards = tabCards.all; + const liveCount = allCards.filter((item) => item.has_market_odds).length; + const preMarketCount = allCards.length - liveCount; + const referenceCount = allCards.filter((item) => item.odds_source_kind === 'reference_market').length; + const conditionalCount = allCards.filter((item) => item.odds_source_kind === 'conditional_threshold' || !item.has_market_odds).length; + const qualities = Array.from(new Set(allCards.map((item) => qualityText(item.data_quality)))); + const sourceLabels = Array.from(new Set(allCards.map((item) => item.odds_source_label || sourceKindText(item)))).slice(0, 4); + return { + total: allCards.length, + liveCount, + preMarketCount, + referenceCount, + conditionalCount, + qualities: qualities.length ? qualities.join('、') : '尚無推薦', + sourceLabels: sourceLabels.length ? sourceLabels.join('、') : '尚無盤口來源', + refreshSeconds: selectedCard?.auto_refresh_seconds ?? 60, + }; + }, [selectedCard, tabCards.all]); useEffect(() => { const load = async () => { @@ -59,78 +274,396 @@ export default function DailyCardPage() { setError(''); try { - const response = await getDailyCard(targetDate); - setData(response); + const calendar = await getDailyCardCalendar(TOURNAMENT_START_DATE); + const groupedDates = calendar.dates.map((item) => item.date).sort((a, b) => a.localeCompare(b)); + const pinnedDates = [sampleDate, tomorrowSampleDate].filter((date) => groupedDates.includes(date)); + const pinnedSet = new Set(pinnedDates); + const allDates = [...pinnedDates, ...groupedDates.filter((date) => !pinnedSet.has(date))]; + const preferredDate = calendar.dates.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date + ?? calendar.dates.find((item) => item.date >= sampleDate && item.recommendation_count > 0)?.date + ?? pinnedDates[0] + ?? allDates.find((date) => date >= sampleDate) + ?? allDates[0] + ?? sampleDate; + + setCalendarDates(calendar.dates); + setSelectedDate((current) => { + if (allDates.includes(current)) return current; + return preferredDate; + }); } catch (payloadError) { - setError(payloadError instanceof Error ? payloadError.message : '每日作戰卡暫時無法抓取'); + setError(payloadError instanceof Error ? payloadError.message : '無法取得日期推薦摘要資料'); } finally { setLoading(false); } }; load().catch(() => undefined); - }, [targetDate]); + const interval = window.setInterval(() => { + load().catch(() => undefined); + }, 60_000); + + return () => { + window.clearInterval(interval); + }; + }, []); + + useEffect(() => { + if (!selectedDate) return undefined; + + let mounted = true; + const loadSelectedCard = async () => { + setLoading(true); + setError(''); + + try { + const card = await getDailyCard(selectedDate); + if (!mounted) return; + setDailyCards((current) => ({ ...current, [selectedDate]: card })); + } catch (payloadError) { + if (!mounted) return; + setError(payloadError instanceof Error ? payloadError.message : '無法取得該日期推薦卡片'); + } finally { + if (mounted) setLoading(false); + } + }; + + loadSelectedCard().catch(() => undefined); + const interval = window.setInterval(() => { + loadSelectedCard().catch(() => undefined); + }, 60_000); + + return () => { + mounted = false; + window.clearInterval(interval); + }; + }, [selectedDate]); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(WATCHLIST_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const normalized = parsed.filter(Boolean).slice(0, 20); + setWatchlist(normalized); + setSelectedCount(normalized.length); + window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(normalized)); + } + } catch { + window.localStorage.removeItem(WATCHLIST_KEY); + } + }, []); + + const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]); function handleAddToSlip(item: DailyCardItem) { - setSelectedCount((count) => count + 1); - // 未建立後續注單 API,先保留為前端選單暫存 - window.alert(`已加入注單追蹤:${item.match_label} | ${item.selection}`); + const key = pickKey(item); + const exists = watchlist.some((existing) => pickKey(existing) === key); + + if (exists) { + setFeedbackMessage(`這張已在監控清單中:${item.match_label} | ${item.selection}`); + return; + } + + const next = [item, ...watchlist].slice(0, 20); + setWatchlist(next); + setSelectedCount(next.length); + setFeedbackMessage(`已加入${isConditionalPick(item) ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}`); + try { + window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next)); + } catch { + setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} | ${item.selection}`); + } + } + + function handleRemoveWatchItem(item: DailyCardItem) { + const next = watchlist.filter((existing) => pickKey(existing) !== pickKey(item)); + setWatchlist(next); + setSelectedCount(next.length); + try { + window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next)); + } catch { + setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。'); + } + } + + function handleClearWatchlist() { + setWatchlist([]); + setSelectedCount(0); + try { + window.localStorage.removeItem(WATCHLIST_KEY); + } catch { + setFeedbackMessage('已清空本頁清單;瀏覽器永久保存狀態稍後會在重新整理後同步。'); + } } return ( -
    -
    -

    每日操盤戰情室

    -

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

    -

    日期:{targetDate}

    -

    {briefing}

    -

    已加入注單:{selectedCount} 單

    +
    +
    +

    每日作戰室:依日期選擇投注候選

    +

    + 這頁把外部已取得資料的賽事依日期攤開,按玩法分成單關、跨場串關、同場串關與小注高賠。今天與明天放在最前面,後面保留世界盃開踢後所有有賽事的日期。 +

    +
    + {[ + { href: '/source-health', step: '1', title: '先看資料健康', detail: '確認賽程、比分、新聞與盤口是否新鮮。' }, + { href: '/recommendation-readiness', step: '2', title: '再看推薦閘門', detail: '確認現在能不能用正式推薦語氣。' }, + { href: '/market-coverage', step: '3', title: '檢查盤口覆蓋', detail: '確認玩法是否真的有盤、有來源。' }, + { href: '/daily-card', step: '4', title: '最後挑候選', detail: '只挑賠率達標且注碼可控的卡片。' }, + ].map((item) => ( + +
    + {item.step} +
    +

    {item.title}

    +

    {item.detail}

    +
    +
    + + ))} +
    +
    +
    +
    +

    日期賽事推薦盤

    +

    + 按鈕格式為「日期|幾場比賽|幾組推薦」。推薦數會依外部盤口與模型條件即時更新;沒有達標就顯示 0 推,不硬湊。 +

    +
    +

    今天 / 明天優先,其餘依世界盃日期排序

    +
    +
    + {dateSummaries.map((item) => { + const isPinned = item.date === sampleDate || item.date === tomorrowSampleDate; + const missingSnapshot = item.marketDataStatus === 'snapshot_missing_after_kickoff'; + return ( + + ); + })} +
    +
    +
    +

    目前日期:{selectedDate}|當日賽事 {selectedMatchCount} 場|推薦 {tabCards.all.length} 組

    +

    + 參考上限:{selectedCard ? formatTwd(dailyAmountTwd(selectedCard)) : '-'} +

    +
    +
    +

    {briefing}

    + {selectedCard?.summary ?

    當日摘要:{plainBackendText(selectedCard.summary)}

    : null} + {selectedCard?.execution_policy ?

    執行規則:{plainBackendText(selectedCard.execution_policy)}

    : null} +
    + {selectedMissingSnapshot ? ( +
    +
    +
    +

    此日期不補造投注推薦

    +

    缺少賽前快照,保留紀錄但不事後報牌

    +

    + 這代表賽事已開賽或完賽,但系統沒有保存到可驗證的賽前推薦快照。專業做法是承認缺口,不用賽後結果倒推假高勝率。 +

    +
    +
    + {nextAvailableSummary ? ( + + ) : null} + + 看賽後校準 + +
    +
    +
    + ) : null} +
    + {[ + { label: '實盤可檢查', value: executionMetrics.liveCount, helper: '可進入下注前檢查' }, + { label: '預掛觀察', value: executionMetrics.preMarketCount, helper: '只加入賠率監控' }, + { label: '台灣盤參考', value: executionMetrics.referenceCount, helper: '可比對最低賠率' }, + { label: '資料品質', value: executionMetrics.qualities, helper: '目前模型來源' }, + { label: '賽前快照', value: snapshotStatusText(selectedSnapshotStatus), helper: selectedSnapshotItemCount ? `已保存 ${selectedSnapshotItemCount} 組` : '保存狀態' }, + ].map((item) => ( +
    +

    {item.helper}

    +

    {item.value}

    +

    {item.label}

    +
    + ))} +
    +
    +

    目前盤口來源

    +

    + {executionMetrics.sourceLabels}。若顯示「模型推算最低門檻」,代表該玩法尚未取得直接盤口;若顯示「台灣運彩參考盤」,代表可比對台灣盤賠率,但仍不是多莊家正式市場。 +

    +
    +
    +
    +

    怎麼看這些推薦

    +

    + 先看「下注方式」確認是單關、跨場串關或同場串關;再看「最低可接受賠率」。 + 如果平台賠率低於卡片上的數字,就不要下注。最後用建議單位控制單場風險。 +

    +
    +
    +

    AI 只負責提醒,不直接決定下注

    +

    + AI 會協助盯新聞、傷停、賠率異動與資料延遲;真正能不能列為推薦,仍要通過勝率、最低賠率、風險上限與資料新鮮度檢查,不能只靠直覺。 +

    +
    +
    +

    我們怎麼避免亂推

    +

    + 每張卡都會檢查賠率是否達標、資料是否夠新、串關是否互相牽動。沒有真實賠率或賠率太低時,只能列入觀察,不硬推下注。 +

    +
    +
    +
    +
    0 ? 'status-led-ok' : 'status-led-warn'}`} /> +

    目前監控清單:{watchlist.length || selectedCount} 筆

    +
    + {feedbackMessage ? ( +

    + {feedbackMessage} +

    + ) : null}
    -
    -

    Hard Filters

    -
    +
    +

    推薦分類

    +

    先用分類縮小範圍;如果不確定,直接看「全部候選」。

    +
    +
    - {loading ?

    載入中...

    : null} - {error ?

    {error}

    : null} +
    +
    +
    +

    賠率監控清單

    +

    + 把等待開盤的條件與可檢查的候選集中管理。下注前逐一確認同一場、同一玩法、同一選項、賠率達標與注碼上限。 +

    +
    + {watchlist.length > 0 ? ( + + ) : null} +
    + + {watchlist.length ? ( +
    + {watchlist.map((item) => ( +
    +
    + + {isConditionalPick(item) ? '等待賠率達標' : '實盤檢查'} + + {recommendationText(item)} + + {item.odds_source_label || sourceKindText(item)} + +
    +

    {item.match_label}

    +

    {item.market_type}|{item.selection}

    +

    {sourceExplainText(item)}

    +
    +

    目標賠率 {item.target_odds.toFixed(2)}

    +

    模型勝率 {item.win_prob.toFixed(2)}%

    +

    信心分數 {typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : '-'}

    +

    參考上限 {formatTwd(stakeAmountTwd(item))}

    +
    + +
    + ))} +
    + ) : ( +
    + 尚未加入監控。看到符合策略的預掛條件或實盤候選時,按下卡片底部按鈕即可加入;加入後會在這裡集中檢查。 +
    + )} +
    + + {loading ?

    同步市場即時資料中...

    : null} + {error ?

    {error}

    : null}
    {tabCards[activeTab].map((item) => ( @@ -138,12 +671,19 @@ export default function DailyCardPage() { key={`${item.match_id}-${item.selection}-${item.market_type}`} item={item} onAddToSlip={handleAddToSlip} + isInSlip={watchlistKeys.has(pickKey(item))} className="panel-glow" /> ))} {!loading && tabCards[activeTab].length === 0 ? ( -

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

    +
    +

    + {selectedMissingSnapshot + ? '此日期缺少賽前快照,因此不顯示事後補造的投注推薦。請切到下一個有候選的日期,或查看賽後校準。' + : '這個分類目前沒有達標候選。沒有足夠資料或賠率不漂亮時,系統不會硬湊推薦。'} +

    +
    ) : null}
    diff --git a/platform/web/app/deep-bet/page.tsx b/platform/web/app/deep-bet/page.tsx index 90ef7d6..23d966a 100644 --- a/platform/web/app/deep-bet/page.tsx +++ b/platform/web/app/deep-bet/page.tsx @@ -1,25 +1,38 @@ -import { QuickBetButton } from '@/components/QuickBetButton'; +import Link from 'next/link'; export default function DeepBetPage() { - const matchId = 'FIFA2026-FR-PA03'; - const selection = '德國勝'; return ( -
    -

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

    -
    -

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

    +
    +

    授權功能 / 目前不開放一鍵帶入

    +

    一鍵帶入下注連結

    +

    + 這不是一般推薦頁,而是未來接合法投注平台時使用的操作工具。正式環境不放預設賽事、不放預設數字,也不把使用者導到未驗證連結。

    +
    + {[ + ['目前狀態', '等待授權連結來源', '尚未接入合法且可驗證的一鍵帶入連結。'], + ['安全原則', '不預填假賠率', '必須同一場、同一玩法、同一選項、同一分線。'], + ['替代流程', '先手動核對卡片', '每日作戰室會告訴你最低賠率與參考上限。'], + ].map(([title, value, detail]) => ( +
    +

    {title}

    +

    {value}

    +

    {detail}

    +
    + ))} +
    +
    -

    場次

    -

    {matchId}|{selection}

    +

    現在請走這個流程

    +

    + 先到每日作戰室查看單關、跨場串關與同場串關;每張卡片會寫清楚下注方式、最低可接受賠率與新台幣參考上限。 +

    - - - + 前往每日作戰室 + 先看推薦就緒度
    diff --git a/platform/web/app/globals.css b/platform/web/app/globals.css index c2c038e..94ec398 100644 --- a/platform/web/app/globals.css +++ b/platform/web/app/globals.css @@ -1,12 +1,20 @@ -@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;700;900&family=VT323&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+TC:wght@400;500;700;900&family=JetBrains+Mono:wght@400;700&display=swap'); @tailwind base; @tailwind components; @tailwind utilities; :root { - --font-body: 'Noto Sans TC'; - --font-matrix: 'VT323'; - --warm-bg: #f6f0e1; + --font-body: 'Inter', 'Noto Sans TC', sans-serif; + --font-matrix: 'JetBrains Mono', monospace; + --bg-color: #0B0E14; + --text-color: #E2E8F0; + --accent-color: #b83822; + --accent-glow: rgba(184, 56, 34, 0.28); + --surface-cream: #fff8e6; + --surface-cream-soft: #f6f0e1; + --surface-border: #e7c89b; + --text-coffee: #3f2f25; + --text-muted-coffee: #7a5b46; } html, @@ -14,78 +22,127 @@ 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; + background-color: var(--bg-color); + background-image: + radial-gradient(circle at 18% 20%, rgba(184, 56, 34, 0.10), transparent 24%), + radial-gradient(circle at 82% 28%, rgba(231, 200, 155, 0.10), transparent 26%), + linear-gradient(180deg, #080a10 0%, #111827 56%, #0b0e14 100%); + color: var(--text-color); + font-family: var(--font-body); } * { 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)); + position: relative; + z-index: 1; } +/* Unified warm research-card surface */ .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); + background: rgba(255, 248, 230, 0.94); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--surface-border); + box-shadow: 0 18px 50px rgba(98, 58, 34, 0.16); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.panel-glow:hover { + border-color: rgba(184, 56, 34, 0.38); + box-shadow: 0 22px 58px rgba(98, 58, 34, 0.20); + transform: translateY(-2px); +} + +.panel-glow .text-slate-50, +.panel-glow .text-slate-100, +.panel-glow .text-slate-200, +.panel-glow .text-cyan-200, +.panel-glow .text-cyan-300, +.panel-glow .text-cyan-400 { + color: var(--text-coffee); +} + +.panel-glow .text-slate-300, +.panel-glow .text-slate-400, +.panel-glow .text-slate-500, +.panel-glow .text-slate-600 { + color: var(--text-muted-coffee); +} + +.panel-glow .bg-slate-950\/50, +.panel-glow .bg-slate-950\/60, +.panel-glow .bg-slate-900\/60, +.panel-glow .bg-slate-800 { + background-color: rgba(255, 255, 255, 0.66); +} + +.panel-glow .border-slate-700, +.panel-glow .border-slate-800 { + border-color: #eadcb9; } .dot-matrix { - font-family: var(--font-matrix), monospace; - letter-spacing: 0.09em; + font-family: var(--font-matrix); + letter-spacing: 0.05em; + text-transform: uppercase; } +/* Status LEDs */ .status-led { - width: 0.6rem; - height: 0.6rem; - border-radius: 9999px; + width: 8px; + height: 8px; + border-radius: 50%; display: inline-block; + box-shadow: 0 0 8px currentColor; } -.status-led-ok { background: #1a9a57; } -.status-led-warn { background: #dcb53b; } -.status-led-danger { background: #d1432d; } +.status-led-ok { background: #10B981; color: #10B981; } +.status-led-warn { background: #F59E0B; color: #F59E0B; } +.status-led-danger { background: #EF4444; color: #EF4444; } +/* Edge Glow Animation */ @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); + box-shadow: 0 0 0 rgba(0, 240, 255, 0); + border-color: rgba(255, 255, 255, 0.08); } 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); + box-shadow: 0 0 20px rgba(184, 56, 34, 0.28); + border-color: #b83822; } 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); + box-shadow: 0 0 0 rgba(0, 240, 255, 0); + border-color: rgba(255, 255, 255, 0.08); } } .prop-top-edge { - animation: top-edge-glow 1.8s ease-in-out infinite; + animation: top-edge-glow 2s ease-in-out infinite; } @keyframes dot-matrix-pulse { - 0%, 100% { - opacity: 0.35; - transform: translateY(0); - } - 50% { - opacity: 1; - transform: translateY(-2px); - } + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; text-shadow: 0 0 8px var(--accent-color); } } .dot-matrix-loading { - animation: dot-matrix-pulse 1s steps(1) infinite; + animation: dot-matrix-pulse 1.5s ease-in-out infinite; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { background: rgba(246, 240, 225, 0.45); } +::-webkit-scrollbar-thumb { + background: rgba(184, 56, 34, 0.32); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(184, 56, 34, 0.55); } diff --git a/platform/web/app/kelly/page.tsx b/platform/web/app/kelly/page.tsx index 3b56907..33c9d27 100644 --- a/platform/web/app/kelly/page.tsx +++ b/platform/web/app/kelly/page.tsx @@ -3,12 +3,15 @@ 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 index 8cc6fef..235d3ed 100644 --- a/platform/web/app/layout.tsx +++ b/platform/web/app/layout.tsx @@ -1,35 +1,20 @@ -import Link from 'next/link'; import './globals.css'; import type { ReactNode } from 'react'; import type { Metadata } from 'next'; +import { HeaderNav } from '@/components/HeaderNav'; 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 viewport = { + themeColor: '#0B0E14', +}; export const metadata: Metadata = { - title: '2026 FIFA 專業投注研究中心', - description: '以台北時區驅動的全站即時投注資料與量化分析平台', + title: '2026 世界盃專業量化投注研究中心', + description: '以台北時區驅動的世界盃即時賽程、新聞、賠率與量化投注研究平台', manifest: '/manifest.json', - themeColor: '#f6f0e1', other: { - 'application-name': '2026 World Cup Quantum Ops', + 'application-name': '2026 世界盃專業量化投注研究中心', }, }; @@ -40,23 +25,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
    -
    -
    -

    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 index 614cfe6..d4bd57e 100644 --- a/platform/web/app/match-conditions/page.tsx +++ b/platform/web/app/match-conditions/page.tsx @@ -49,6 +49,11 @@ export default function MatchConditionsPage() { return (

    裁判與天候條件量化

    +
    +

    + 此頁是條件模型計算器。正式推薦必須由每日作戰室整合賽程、場館、盤口與資料品質後產生,這裡的手動輸入不會直接變成下注訊號。 +

    +

    條件輸入

    @@ -156,4 +161,3 @@ export default function MatchConditionsPage() {
    ); } - diff --git a/platform/web/app/matches/[matchId]/page.tsx b/platform/web/app/matches/[matchId]/page.tsx index eb234e8..04900d3 100644 --- a/platform/web/app/matches/[matchId]/page.tsx +++ b/platform/web/app/matches/[matchId]/page.tsx @@ -11,9 +11,11 @@ import type { MatchOddsPoint, MatchPoisson, } from '@/lib/analytics-api'; +import { matchStatusKind, matchStatusLabel } from '@/lib/match-order'; import { formatToTaipeiTime } from '@/lib/timezone'; -export const revalidate = 60; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000'; const OPENAI_API_KEY = process.env.OPENAI_API_KEY; @@ -22,8 +24,7 @@ async function fetchMatchList(): Promise { try { const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { headers: { 'Content-Type': 'application/json' }, - cache: 'force-cache', - next: { revalidate: 60 }, + cache: 'no-store', }); if (!response.ok) { @@ -38,10 +39,9 @@ async function fetchMatchList(): Promise { async function fetchMatchDetail(matchId: string): Promise { try { - const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, { + const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${encodeURIComponent(matchId)}`, { headers: { 'Content-Type': 'application/json' }, - cache: 'force-cache', - next: { revalidate: 60 }, + cache: 'no-store', }); if (!response.ok) { @@ -64,7 +64,7 @@ function fallbackSummary(detail: MatchDetail): string { return `【量化總結】 ${home} vs ${away} 的 1x2 機率分佈顯示主勝 ${homeWin}%,平局 ${draw}%,客勝 ${awayWin}%。 -Poisson 預測給出主隊 ${poisson.expected_home_goals.toFixed(2)} / 客隊 ${poisson.expected_away_goals.toFixed(2)} 的進球走勢。 +進球分布模型預測主隊約 ${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 結構,避免逆風追買。`; @@ -80,8 +80,8 @@ async function buildQuantSummaryWithLLM(detail: MatchDetail): Promise { try { const prompt = `請以中文繁體為台灣使用者生成 300 字的世界盃賽前量化總結,嚴格使用專業投注語氣,不超過 420 字。 請使用以下數據: -- 主場 ${detail.home_team}:xG ${detail.home_xg.toFixed(2)} -- 客場 ${detail.away_team}:xG ${detail.away_xg.toFixed(2)} +- 主場 ${detail.home_team}:預期進球 ${detail.home_xg.toFixed(2)} +- 客場 ${detail.away_team}:預期進球 ${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)}。`; @@ -138,7 +138,7 @@ export async function generateMetadata({ params }: { params: Promise }): : `2026 世界盃賽事預測`; const description = match - ? `量化模型基於 xG、裁判與天候模型產出「${match.home_team} vs ${match.away_team}」完整賠率走勢與下注偏差訊號,含 2.5 球與波膽機率。` + ? `模型根據預期進球、裁判尺度與天候條件,整理「${match.home_team} vs ${match.away_team}」的賠率走勢與可能偏差,包含大小 2.5 球與比分機率。` : '2026 世界盃賽事量化預測、聰明錢流向與賠率走勢頁。'; return { @@ -149,7 +149,7 @@ export async function generateMetadata({ params }: { params: Promise }): title, description, locale: 'zh_TW', - siteName: '2026 FIFA Quantum Ops', + siteName: '2026 世界盃量化投注研究中心', }, }; } @@ -210,22 +210,22 @@ function buildDatasetJsonLd(detail: MatchDetail) { '@context': 'https://schema.org', '@type': 'Dataset', name: `${detail.home_team} vs ${detail.away_team} 量化推論資料集`, - description: `2026世界盃賽事模型資料。包含主客 xG、賠率時序、裁判與熱指數、波膽機率。`, + description: `2026世界盃賽事模型資料。包含主客預期進球、賠率走勢、裁判尺度、熱指數與比分機率。`, creator: { '@type': 'Organization', - name: '2026 FIFA Quantum Ops', + name: '2026 世界盃量化投注研究中心', }, license: 'Proprietary', temporalCoverage: detail.match_time_utc, variableMeasured: [ { '@type': 'PropertyValue', - name: 'xG', + name: '預期進球', value: `${detail.home_xg.toFixed(2)} / ${detail.away_xg.toFixed(2)}`, }, { '@type': 'PropertyValue', - name: 'Poisson 1X2', + name: '勝平負機率', value: JSON.stringify(detail.poisson.one_x_two), }, { @@ -249,7 +249,14 @@ export default async function MatchDetailPage({ params }: { params: Promise -

    賽事狀態:{detail.status.toUpperCase()}

    +

    賽事狀態:{statusLabel}

    -

    預估 xG

    +

    預估進球

    {detail.home_xg.toFixed(2)} : {detail.away_xg.toFixed(2)}

    @@ -296,7 +303,7 @@ export default async function MatchDetailPage({ params }: { params: Promise
    - +
    diff --git a/platform/web/app/matches/page.tsx b/platform/web/app/matches/page.tsx index 4db26a8..4a4df29 100644 --- a/platform/web/app/matches/page.tsx +++ b/platform/web/app/matches/page.tsx @@ -1,39 +1,40 @@ 'use client'; +import Link from 'next/link'; import { useEffect, useState } from 'react'; import { formatToTaipeiTime } from '@/lib/timezone'; -import { LiveMatchCenter } from '@/components/LiveMatchCenter'; import { getAllMatches, type MatchListItem } from '@/lib/analytics-api'; +import { matchStatusLabel, sortMatchesForProfessionalDisplay } from '@/lib/match-order'; + +function scoreLine(match: MatchListItem): string { + if (typeof match.home_score === 'number' && typeof match.away_score === 'number') { + return `${match.home_team} ${match.home_score} - ${match.away_score} ${match.away_team}`; + } + return `${match.home_team} vs ${match.away_team}`; +} export default function MatchesPage() { const [matches, setMatches] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { async function loadMatches() { try { const data = await getAllMatches(); - setMatches(data || []); + setMatches(sortMatchesForProfessionalDisplay(data || [])); + setError(null); } catch (error) { console.error('Failed to load matches', error); + setError('賽事資料暫時無法同步,系統正在等待資料源回補。請先到資料健康頁確認目前狀態。'); } finally { setLoading(false); } } loadMatches(); + const interval = window.setInterval(loadMatches, 60_000); + return () => window.clearInterval(interval); }, []); - const liveTimeline = [ - { minute: 18, label: '等待即時事件源接入...' } - ]; - - const xgData = [ - { minute: 15, xgHome: 0.1, xgAway: 0.1 } - ]; - - const zones = [ - { zone: '等待熱區資料', pct: 100 } - ]; - if (loading) { return
    載入即時賽事中...
    ; } @@ -41,7 +42,7 @@ export default function MatchesPage() { if (matches.length === 0) { return (
    - 無法取得賽事資料。請確認後端已啟動且 THE_ODDS_API_KEY 已正確設定。 + {error || '目前沒有可顯示的賽事資料。'}
    ); } @@ -51,20 +52,32 @@ export default function MatchesPage() {

    賽事中心

    {matches.map((match) => ( -
    +
    -

    {match.home_team} vs {match.away_team}

    -

    開賽:{formatToTaipeiTime(match.kickoff_utc)}

    +

    {scoreLine(match)}

    + + {matchStatusLabel(match)} +
    -

    即時狀態:{match.status}

    +

    開賽:{formatToTaipeiTime(match.kickoff_utc)}

    場地:{match.venue_name || '未定'} ({match.venue_city || '未定'})

    -
    +

    查看單場詳情與量化資料 →

    + ))}
    - +
    +

    即時事件與預期進球熱區資料

    +

    + 目前尚未接入可驗證的逐分鐘事件源,因此不再顯示假時間軸、假預期進球或假熱區。接入 Opta、FIFA 或授權事件源後,這裡才會開啟即時圖表。 +

    +
    ); } diff --git a/platform/web/app/ml-edge/page.tsx b/platform/web/app/ml-edge/page.tsx index 597ed30..a1bebd3 100644 --- a/platform/web/app/ml-edge/page.tsx +++ b/platform/web/app/ml-edge/page.tsx @@ -86,7 +86,12 @@ export default function MlEdgePage() { return (
    -

    ML Ensemble 量化預測(第 15 階段)

    +

    機器學習整合模型 量化預測(第 15 階段)

    +
    +

    + 此頁是模型推論工作台。未接入正式訓練集與即時盤口前,輸出只能作模型壓力測試,不會被標記為首頁投注推薦。 +

    +

    模型驅動參數

    @@ -143,7 +148,7 @@ export default function MlEdgePage() { />
    {errorMessage ?

    {errorMessage}

    : null} @@ -223,25 +228,25 @@ export default function MlEdgePage() {

    模型機率

    主勝 {edgeResult.model_probs.home.toFixed(2)} / 和局 {edgeResult.model_probs.draw.toFixed(2)} / 客勝 {edgeResult.model_probs.away.toFixed(2)}

    -

    模型大小:{edgeResult.is_fallback_model ? '規則回退' : 'ML Ensemble'}

    +

    模型大小:{edgeResult.is_fallback_model ? '規則回退' : '機器學習整合模型'}

    強烈偏差

    {edgeResult.strongest_outcome}

    -

    Edge:{edgeResult.strongest_edge_percent.toFixed(2)}%

    -

    是否 Strong Buy:{edgeResult.strong_buy ? '是' : '否'}

    +

    優勢:{edgeResult.strongest_edge_percent.toFixed(2)}%

    +

    是否 強烈候選:{edgeResult.strong_buy ? '是' : '否'}

    -

    各結果 Edge 估計

    +

    各結果 優勢 估計

      -
    • 主勝:{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? '(Strong Buy)' : ''}
    • -
    • 和局:{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? '(Strong Buy)' : ''}
    • -
    • 客勝:{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? '(Strong Buy)' : ''}
    • +
    • 主勝:{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? '(強烈候選)' : ''}
    • +
    • 和局:{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? '(強烈候選)' : ''}
    • +
    • 客勝:{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? '(強烈候選)' : ''}
    ) : ( -

    尚未提交分析,請先點擊「執行 ML Edge 推論」。

    +

    尚未提交分析,請先點擊「執行 機器學習優勢 推論」。

    )}
    @@ -259,4 +264,3 @@ export default function MlEdgePage() {
    ); } - diff --git a/platform/web/app/models/page.tsx b/platform/web/app/models/page.tsx index 29c5a2c..c86d3df 100644 --- a/platform/web/app/models/page.tsx +++ b/platform/web/app/models/page.tsx @@ -1,28 +1,36 @@ +import Link from 'next/link'; + export default function ModelsPage() { const modules = [ - { href: '/ml-edge', label: 'ML Ensemble 預測', desc: '自建模型 + ML Edge 比較,與莊家隱含機率對齊' }, - { href: '/match-conditions', label: '裁判/氣候影響模型', desc: '裁判嚴厲度、熱指數、海拔對 2H 下半場模型修正' }, - { href: '/rlm', label: 'RLM 反向盤口', desc: '票數/資金偏移與賠率異常走勢即時警示' }, - { href: '/proof-of-yield', label: 'Proof of Yield', desc: '交易明細公開帳本,量化 CLV 與資金曲線' }, - { href: '/props', label: '球員道具盤', desc: 'Player Props、雷達圖、Top Edge 提示' }, - { href: '/kelly', label: '凱利準則', desc: 'Kelly Fraction 與下注額建議' }, - { href: '/backtesting', label: '策略回測', desc: '動態條件、ROI 與 Equity Curve' }, - { href: '/deep-bet', label: '一鍵投注導向', desc: 'Deep Linking 進階下注導流' }, + { href: '/market-coverage', label: '盤口覆蓋率稽核', status: '正式流程', desc: '逐玩法檢查未來賽事是否真的有可驗證賠率列' }, + { href: '/kelly', label: '注碼建議', status: '可使用', desc: '依照勝率、賠率與風險偏好,估算每次最多下注多少' }, + { href: '/match-conditions', label: '裁判與天氣影響', status: '可使用', desc: '把裁判尺度、炎熱程度、海拔與下半場體能變化納入評估' }, + { href: '/ml-edge', label: '機器學習整合預測', status: '工作台', desc: '用自建模型比較市場賠率,找出可能被低估的選項' }, + { href: '/rlm', label: '反向盤口雷達', status: '資料依賴', desc: '票數/資金偏移與賠率異常走勢即時警示' }, + { href: '/props', label: '球員特殊玩法', status: '資料依賴', desc: '用球員表現資料找出可能有優勢的進球、射門與助攻玩法' }, + { href: '/proof-of-yield', label: '公開收益驗證', status: '賽後更新', desc: '公開每筆紀錄,追蹤命中率、報酬率與收盤價差' }, + { href: '/backtesting', label: '策略回測', status: '等真實樣本', desc: '用已結算推薦檢查策略是否長期有效,並觀察資金變化' }, + { href: '/deep-bet', label: '一鍵投注導向', status: '需授權', desc: '合法授權後,協助把選項帶入投注平台' }, ]; return (
    -

    量化模型模組整合

    +

    量化工具箱

    -

    整合階段 1~14 的核心模型模組,涵蓋「賽前機率推估」「投注價值偵測」「下注控管」與「執行效率」。

    +

    + 這裡是進階工具,不是第一入口。一般使用者先看首頁推薦與每日作戰室;需要檢查盤口、注碼、天候、模型或賽後績效時,再從這裡進入。 +

    {modules.map((module) => ( -
    -

    {module.label}

    -

    {module.desc}

    -

    路徑:{module.href}

    -
    + +
    +

    {module.label}

    + {module.status} +
    +

    {module.desc}

    +

    打開工具

    + ))}
    @@ -30,15 +38,15 @@ export default function ModelsPage() {

    泊松模型

    -

    根據進攻/防守強度計算主客隊 λ,推導大小球與波膽機率。

    +

    根據攻守強度估計雙方可能進幾球,再推估大小球與比分機率。

    蒙地卡羅模擬

    -

    高保真隨機模擬賽前轉換、進球分布與讓球結果,不只給結果,也給穩健性區間。

    +

    把同一場比賽重複模擬很多次,看結果是否穩定,而不是只看單一次預測。

    -

    EV 檢測

    -

    計算理論機率與市場賠率差,輸出高價值投注候選與偏離警訊。

    +

    是否值得下注

    +

    比較模型勝率與平台賠率,只有差距夠大、風險可控時才列為候選。

    diff --git a/platform/web/app/odds/page.tsx b/platform/web/app/odds/page.tsx index 5463ccf..c683ed8 100644 --- a/platform/web/app/odds/page.tsx +++ b/platform/web/app/odds/page.tsx @@ -1,48 +1,114 @@ -import { OddsLineMovementChart } from '@/components/OddsLineMovementChart'; +'use client'; -const samples = [ - { time: '12:00', bookmaker: 'Bet365', odds: 1.82 }, - { time: '12:30', bookmaker: 'Bet365', odds: 1.79 }, - { time: '13:00', bookmaker: 'Bet365', odds: 1.84 }, - { time: '12:00', bookmaker: 'Pinnacle', odds: 1.8 }, - { time: '12:30', bookmaker: 'Pinnacle', odds: 1.77 }, - { time: '13:00', bookmaker: 'Pinnacle', odds: 1.85 }, - { time: '12:00', bookmaker: 'DraftKings', odds: 1.83 }, - { time: '12:30', bookmaker: 'DraftKings', odds: 1.81 }, - { time: '13:00', bookmaker: 'DraftKings', odds: 1.8 }, -]; +import { useEffect, useState } from 'react'; +import { OddsLineMovementChart } from '@/components/OddsLineMovementChart'; +import { getAllMatches, getMatchById, type MatchListItem, type MatchDetail } from '@/lib/analytics-api'; export default function OddsPage() { + const [rows, setRows] = useState<{ match: MatchListItem; detail: MatchDetail | null }[]>([]); + const [selectedMatch, setSelectedMatch] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + try { + const list = await getAllMatches(); + const now = Date.now(); + const upcoming = list + .filter((match) => new Date(match.kickoff_utc).getTime() >= now - 120 * 60 * 1000 && match.status !== '已結束') + .slice(0, 5); + const hydrated = await Promise.all( + upcoming.map(async (match) => { + try { + return { match, detail: await getMatchById(match.match_id) }; + } catch { + return { match, detail: null }; + } + }), + ); + setRows(hydrated); + setSelectedMatch(hydrated.find((row) => row.detail?.odds_series.length)?.detail ?? hydrated[0]?.detail ?? null); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + load(); + const interval = window.setInterval(load, 60_000); + return () => window.clearInterval(interval); + }, []); + + const chartData = selectedMatch?.odds_series.map(point => ({ + time: new Date(point.recorded_at).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }), + bookmaker: point.bookmaker, + odds: point.decimal_odds, + })) || []; + + function latestBookOdds(detail: MatchDetail | null): string[] { + if (!detail?.odds_series.length) return []; + const latest = [...detail.odds_series].sort((a, b) => new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime()); + const seen = new Set(); + const values: string[] = []; + for (const point of latest) { + const key = `${point.bookmaker}-${point.market_type}-${point.selection}`; + if (seen.has(key)) continue; + seen.add(key); + values.push(`${point.bookmaker}|${point.market_type} ${point.selection} ${point.decimal_odds.toFixed(2)}`); + if (values.length === 3) break; + } + return values; + } + return ( -
    -

    跨平台賠率比較矩陣

    -
    -

    此頁面接入後端 odds API 與 Redis 快取後,可顯示即時最高賠率、套利空間與賠率走勢線。

    -
    - - - - - - - - - - - - - - - - - - - -
    場次Bet365PinnacleDraftKings套利
    德國 vs 西班牙1.921.901.91
    -
    +
    +

    跨平台市場指數比較矩陣

    +
    +

    此模組匯聚全球頂級機構即時定價,透過動態比對尋找潛在無風險套利空間與定價偏差。

    + + {loading ? ( +

    同步即時定價庫中...

    + ) : ( +
    + + + + + + + + + + + + {rows.map(({ match, detail }) => { + const odds = latestBookOdds(detail); + return ( + + + + + + + + ); + })} + +
    賽事最新盤口 1最新盤口 2最新盤口 3市場有效性
    {match.home_team} vs {match.away_team}{odds[0] ?? '等待盤口'}{odds[1] ?? '等待盤口'}{odds[2] ?? '等待盤口'} + {odds.length ? '已有可檢查盤口' : '等待資料源'} +
    +
    + )}
    - + {selectedMatch && chartData.length > 0 && ( + + )} + {!loading && !rows.some((row) => row.detail?.odds_series.length) ? ( +
    + 目前沒有可驗證的即時賠率序列;系統不再以固定 1.90 類數字假裝跨平台比較。 +
    + ) : null}
    ); } diff --git a/platform/web/app/offline/page.tsx b/platform/web/app/offline/page.tsx index 9c2b05d..b5dd6b4 100644 --- a/platform/web/app/offline/page.tsx +++ b/platform/web/app/offline/page.tsx @@ -48,7 +48,7 @@ export default function OfflinePage() { return (
    -

    OFFLINE: CONNECTION LOST

    +

    目前離線,正在保護最後資料

    你已進入離線保護模式

    網路暫時中斷,已為你保留最後可用賠率快取,並暫時停用即時更新。 @@ -63,7 +63,7 @@ export default function OfflinePage() { {snapshot.map((item) => (

  • {item.home_team || item.match_id} - {item.away_team ? ` vs ${item.away_team}` : ''} | {item.market_type || 'market'} + {item.away_team ? ` vs ${item.away_team}` : ''} | {item.market_type || '玩法'} {item.selection ? ` ${item.selection}` : ''}: {item.odds.toFixed(2)} ({formatTs(item.captured_at)}) diff --git a/platform/web/app/page.tsx b/platform/web/app/page.tsx index 4cd6998..5229883 100644 --- a/platform/web/app/page.tsx +++ b/platform/web/app/page.tsx @@ -1,74 +1,880 @@ -import Link from 'next/link'; -import { formatToTaipeiTime } from '@/lib/timezone'; +'use client'; -type Recommendation = { - match: string; - odds: string; - model: string; - ev: string; - updatedAt: string; +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { formatToTaipeiTime } from '@/lib/timezone'; +import { + getAllMatches, + getDailyCard, + getDailyCardCalendar, + getSourceHealth, + type DailyCardCalendarDate, + type DailyCardItem, + type DailyCardResponse, + type MatchListItem, + type SourceHealthResponse, +} from '@/lib/analytics-api'; + +type NewsItem = { + title: string; + url: string; + source: string; + publishedAt: string; }; -const sample: Recommendation[] = [ - { - match: '德國 vs 西班牙', - odds: '1.90', - model: '泊松 + 蒙地卡羅', - ev: '8.2%', - updatedAt: formatToTaipeiTime(new Date().toISOString()), - }, - { - match: '巴西 vs 法國', - odds: '2.08', - model: 'EV 偵測器', - ev: '6.9%', - updatedAt: formatToTaipeiTime(new Date().toISOString()), - }, - { - match: '荷蘭 vs 英格蘭', - odds: '3.10', - model: '聰明錢偏移 + Sharp 濾波', - ev: '5.4%', - updatedAt: formatToTaipeiTime(new Date().toISOString()), - }, +type LoadErrors = { + daily?: string; + matches?: string; + news?: string; + source?: string; +}; + +type GateState = { + label: string; + title: string; + detail: string; + tone: 'ready' | 'watch' | 'blocked'; + primaryAction: string; +}; + +const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist'; +const TOURNAMENT_START_DATE = '2026-06-11'; + +const moduleLinks = [ + { href: '/daily-card', label: '每日作戰室', desc: '單關、串關、同場串關與賠率監控' }, + { href: '/live-score', label: '即時比分中心', desc: '日期切換、即時比分、完賽結果與場地資訊' }, + { href: '/schedule', label: '完整賽程表', desc: '依台北時間查看下一場與已完賽結果' }, + { href: '/battle-room', label: '對戰情報室', desc: '模型勝率、進球分布、環境條件與盤口快照' }, + { href: '/source-health', label: '資料健康總覽', desc: '檢查賽程、比分、新聞與盤口是否即時更新' }, + { href: '/recommendation-performance', label: '賽後校準室', desc: '逐玩法回看命中率並修正模型權重' }, + { href: '/agent-verification', label: 'AI 驗證室', desc: 'Gemini 費用上限、NemoTron 復核與量化閘門' }, + { href: '/news', label: '即時新聞情報', desc: '新聞、傷停與外部事件監控' }, ]; -export default function Home() { +function pickKey(item: DailyCardItem): string { + const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim(); + return [normalize(item.match_id), normalize(item.market_type), normalize(item.selection)].join('|'); +} + +function formatTwd(value: number): string { + return new Intl.NumberFormat('zh-TW', { + style: 'currency', + currency: 'TWD', + maximumFractionDigits: 0, + }).format(value); +} + +function stakeAmountTwd(item: DailyCardItem): number { + return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000)); +} + +function itemsFromCard(card: DailyCardResponse | null): DailyCardItem[] { + if (!card) return []; + return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles]; +} + +function matchDate(match: MatchListItem): string { + return formatToTaipeiTime(match.kickoff_utc, 'yyyy-MM-dd'); +} + +function isConditionalPick(item: DailyCardItem): boolean { return ( -
    -
    -

    當日最高 EV 投注建議

    -

    所有時間欄位已統一轉為台北時間(UTC+8)。

    + item.has_market_odds === false || + item.selection.includes('預掛條件') || + item.selection.includes('參考盤監控') || + item.rationale.includes('尚未取得可用即時盤口') || + item.rationale.includes('尚未取得完整實盤賠率') || + Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件'))) + ); +} + +function recommendationText(item: DailyCardItem): string { + const conditional = isConditionalPick(item); + if (item.recommendation === 'SAFE_SINGLE') return conditional ? '觀察單關' : '核心單關'; + if (item.recommendation === 'HIGH_RISK_SINGLE') return conditional ? '高賠觀察' : '小注高賠'; + if (item.recommendation === 'SAFE_PARLAY') return conditional ? '觀察串關' : '跨場串關'; + if (item.recommendation === 'SGP_LOTTERY') return conditional ? '同場觀察' : '同場小注'; + return '研究候選'; +} + +function nextStepText(item: DailyCardItem): string { + if (isConditionalPick(item) || !item.has_market_odds) { + return '現在不要下注,先加入賠率監控。等平台開盤後,只有實際賠率達到卡片門檻才考慮。'; + } + if (item.odds_source_kind === 'reference_market') { + return '已有台灣運彩參考盤,可先做下注前檢查;但它不是多來源正式盤,下單前仍要確認平台最新盤口。'; + } + return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。'; +} + +function buildGateState( + sourceHealth: SourceHealthResponse | null, + errors: LoadErrors, + liveCount: number, + watchCount: number, +): GateState { + if (errors.source) { + return { + label: '資料源異常', + title: watchCount > 0 ? '正式下注先保留,預掛候選持續監控' : '資料源恢復前只保留研究觀察', + detail: watchCount > 0 + ? `資料源健康狀態暫時無法讀取,但仍保留 ${watchCount} 組預掛候選。這些不是立即下注單,必須等資料源恢復並達到賠率門檻。` + : '資料源健康狀態暫時無法讀取,首頁只保留資訊瀏覽,不把不完整資料包裝成高勝率下注。', + tone: watchCount > 0 ? 'watch' : 'blocked', + primaryAction: watchCount > 0 ? '查看預掛候選' : '檢查資料源', + }; + } + + if (!sourceHealth) { + return { + label: '同步中', + title: '正在讀取最新資料', + detail: '系統正在同步賽程、賽果、新聞與盤口狀態,完成後才會顯示候選。', + tone: 'watch', + primaryAction: '稍候刷新', + }; + } + + if ((sourceHealth.stale_unsettled_matches ?? 0) > 0) { + return { + label: '賽果延遲', + title: watchCount > 0 ? '正式下注暫停,預掛觀察持續更新' : '賽果追上前不硬推下注', + detail: watchCount > 0 + ? `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果,所以不升級成正式下注推薦;但 ${watchCount} 組日期盤候選仍會持續監控賠率門檻。` + : `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果。資料追上前,不會硬推下注,只保留資訊與條件監控。`, + tone: watchCount > 0 ? 'watch' : 'blocked', + primaryAction: watchCount > 0 ? '查看預掛候選' : '查看賽事中心', + }; + } + + if (sourceHealth.odds_coverage_status === 'full_market' && liveCount > 0) { + return { + label: '可檢查', + title: '已有實盤候選,可做下注前檢查', + detail: `目前有 ${liveCount} 組候選具備可比對盤口。仍需確認賠率、分線、傷停與注碼上限後才下單。`, + tone: 'ready', + primaryAction: '檢查推薦', + }; + } + + if (watchCount > 0) { + return { + label: '預掛觀察', + title: '日期盤候選持續推薦,先等賠率達標', + detail: `已找到 ${watchCount} 組模型候選,但正式盤口不足或仍是比分備援來源。這些會持續推薦為預掛觀察,不是叫你立刻下注;等平台賠率達到門檻再進下注前檢查。`, + tone: 'watch', + primaryAction: '查看預掛候選', + }; + } + + return { + label: '暫無候選', + title: '沒有達標候選就不硬推', + detail: '目前沒有同時通過勝率、最低賠率、資料新鮮度與風險上限的選項。保留資金比硬下注更專業。', + tone: 'blocked', + primaryAction: '等待刷新', + }; +} + +function gateClass(tone: GateState['tone']): string { + if (tone === 'ready') return 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'; + if (tone === 'blocked') return 'border-[#e7a49a] bg-[#fff0e8] text-[#b83822]'; + return 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]'; +} + +function weekdayLabel(date: string): string { + return new Intl.DateTimeFormat('zh-TW', { + timeZone: 'Asia/Taipei', + weekday: 'short', + }).format(new Date(`${date}T00:00:00+08:00`)); +} + +function recommendationDateLabel(date: string, today: string, tomorrow: string): string { + if (date === today) return `今天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`; + if (date === tomorrow) return `明天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`; + return `${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`; +} + +function recommendationDateState(date: string, today: string): string { + if (date < today) return '已完賽回看'; + if (date === today) return '今日優先'; + return '預售分析'; +} + +export default function Home() { + const [dateWindow] = useState(() => { + const now = new Date(); + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + return { + today: formatToTaipeiTime(now.toISOString(), 'yyyy-MM-dd'), + tomorrow: formatToTaipeiTime(tomorrow.toISOString(), 'yyyy-MM-dd'), + }; + }); + const [data, setData] = useState(null); + const [tomorrowData, setTomorrowData] = useState(null); + const [dailyCards, setDailyCards] = useState>({}); + const [calendarDates, setCalendarDates] = useState([]); + const [selectedRecommendationDate, setSelectedRecommendationDate] = useState(''); + const [matches, setMatches] = useState([]); + const [news, setNews] = useState([]); + const [sourceHealth, setSourceHealth] = useState(null); + const [watchlist, setWatchlist] = useState([]); + const [feedbackMessage, setFeedbackMessage] = useState(''); + const [loading, setLoading] = useState(true); + const [errors, setErrors] = useState({}); + const [loadedAt, setLoadedAt] = useState(''); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(WATCHLIST_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + setWatchlist(parsed.filter(Boolean).slice(0, 20)); + } + } catch { + window.localStorage.removeItem(WATCHLIST_KEY); + } + }, []); + + useEffect(() => { + let mounted = true; + + async function load() { + setLoading(true); + setErrors({}); + + const [dailyResult, tomorrowResult, matchesResult, newsResult, sourceResult, calendarResult] = await Promise.allSettled([ + getDailyCard(dateWindow.today), + getDailyCard(dateWindow.tomorrow), + getAllMatches(), + fetch('/api/news', { cache: 'no-store' }).then(async (response) => { + if (!response.ok) throw new Error(`news status ${response.status}`); + return response.json() as Promise<{ items?: NewsItem[] }>; + }), + getSourceHealth(), + getDailyCardCalendar(TOURNAMENT_START_DATE), + ]); + + if (!mounted) return; + + const nextErrors: LoadErrors = {}; + const matchPayload = matchesResult.status === 'fulfilled' ? matchesResult.value : []; + const calendarPayload = calendarResult.status === 'fulfilled' ? calendarResult.value.dates : []; + const calendarDateList = calendarPayload.map((item) => item.date).sort((a, b) => a.localeCompare(b)); + const pinnedDates = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarDateList.includes(date)); + const pinnedSet = new Set(pinnedDates); + const nextCards: Record = {}; + const requestDates = [...pinnedDates, ...calendarDateList.filter((date) => !pinnedSet.has(date))]; + const preferredDate = calendarPayload.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date + ?? calendarPayload.find((item) => item.date >= dateWindow.today && item.recommendation_count > 0)?.date + ?? pinnedDates[0] + ?? requestDates.find((date) => date >= dateWindow.today) + ?? requestDates[0] + ?? dateWindow.today; + + if (!mounted) return; + + if (dailyResult.status === 'fulfilled') { + setData(dailyResult.value); + nextCards[dateWindow.today] = dailyResult.value; + } + else nextErrors.daily = '每日推薦 API 尚未回應'; + + if (tomorrowResult.status === 'fulfilled') { + setTomorrowData(tomorrowResult.value); + nextCards[dateWindow.tomorrow] = tomorrowResult.value; + } + else nextErrors.daily = nextErrors.daily ?? '明日推薦 API 尚未回應'; + + if (matchesResult.status === 'fulfilled') setMatches(matchesResult.value); + else nextErrors.matches = '賽事中心 API 尚未回應'; + + if (newsResult.status === 'fulfilled') setNews(newsResult.value.items ?? []); + else nextErrors.news = '新聞 RSS 暫時無法更新'; + + if (sourceResult.status === 'fulfilled') setSourceHealth(sourceResult.value); + else nextErrors.source = '盤口資料源健康狀態暫時無法讀取'; + + if (calendarResult.status === 'fulfilled') setCalendarDates(calendarPayload); + else nextErrors.daily = nextErrors.daily ?? '日期推薦摘要 API 尚未回應'; + + setDailyCards(nextCards); + setSelectedRecommendationDate((current) => { + if (requestDates.includes(current)) return current; + return preferredDate; + }); + setErrors(nextErrors); + setLoadedAt(formatToTaipeiTime(new Date().toISOString())); + setLoading(false); + } + + load().catch((error) => { + if (mounted) { + setErrors({ daily: error instanceof Error ? error.message : '無法取得最新量化資料' }); + setLoading(false); + } + }); + const interval = window.setInterval(() => { + load().catch(() => undefined); + }, 60_000); + + return () => { + mounted = false; + window.clearInterval(interval); + }; + }, [dateWindow.today, dateWindow.tomorrow]); + + useEffect(() => { + if (!selectedRecommendationDate || dailyCards[selectedRecommendationDate]) return undefined; + + let mounted = true; + getDailyCard(selectedRecommendationDate) + .then((card) => { + if (!mounted) return; + setDailyCards((current) => ({ ...current, [selectedRecommendationDate]: card })); + }) + .catch(() => { + if (!mounted) return; + setErrors((current) => ({ ...current, daily: '此日期推薦卡片暫時無法載入' })); + }); + + return () => { + mounted = false; + }; + }, [dailyCards, selectedRecommendationDate]); + + const matchesByDate = useMemo(() => { + const grouped = new Map(); + matches.forEach((match) => { + const date = matchDate(match); + if (date < TOURNAMENT_START_DATE) return; + grouped.set(date, [...(grouped.get(date) ?? []), match]); + }); + return grouped; + }, [matches]); + + const chronologicalDates = useMemo(() => { + return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b)); + }, [calendarDates]); + + const calendarByDate = useMemo(() => { + return new Map(calendarDates.map((item) => [item.date, item])); + }, [calendarDates]); + + const recommendationDateOptions = useMemo(() => { + const pinned = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarByDate.has(date)); + const pinnedSet = new Set(pinned); + return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))]; + }, [calendarByDate, chronologicalDates, dateWindow.today, dateWindow.tomorrow]); + + const activeRecommendationDate = selectedRecommendationDate || recommendationDateOptions[0] || dateWindow.today; + const selectedCard = dailyCards[activeRecommendationDate] + ?? (activeRecommendationDate === dateWindow.tomorrow ? tomorrowData : null) + ?? (activeRecommendationDate === dateWindow.today ? data : null); + const recommendationItems = useMemo(() => itemsFromCard(selectedCard), [selectedCard]); + const selectedDateSummary = calendarByDate.get(activeRecommendationDate); + const selectedMatchCount = selectedDateSummary?.match_count ?? matchesByDate.get(activeRecommendationDate)?.length ?? selectedCard?.matched_matches ?? 0; + const dateSummaries = useMemo(() => { + return recommendationDateOptions.map((date) => { + const card = dailyCards[date] + ?? (date === dateWindow.today ? data : null) + ?? (date === dateWindow.tomorrow ? tomorrowData : null); + const items = itemsFromCard(card); + const summary = calendarByDate.get(date); + return { + date, + matchCount: summary?.match_count ?? matchesByDate.get(date)?.length ?? card?.matched_matches ?? 0, + recommendationCount: summary?.recommendation_count ?? items.length, + liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length, + }; + }); + }, [calendarByDate, dailyCards, data, dateWindow.today, dateWindow.tomorrow, matchesByDate, recommendationDateOptions, tomorrowData]); + + const topPicks = useMemo(() => { + const rank = (items: DailyCardItem[]) => + [...items].sort((a, b) => { + const aLiveBonus = a.has_market_odds ? 18 : 0; + const bLiveBonus = b.has_market_odds ? 18 : 0; + const aScore = aLiveBonus + (a.confidence_score ?? a.win_prob) + (a.ev_percent * 0.22) - (a.stake_units * 0.8); + const bScore = bLiveBonus + (b.confidence_score ?? b.win_prob) + (b.ev_percent * 0.22) - (b.stake_units * 0.8); + return bScore - aScore; + }); + + const singles = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_SINGLE')); + const parlays = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_PARLAY')); + const sameGame = rank(recommendationItems.filter((item) => item.recommendation === 'SGP_LOTTERY')); + const highRisk = rank(recommendationItems.filter((item) => item.recommendation === 'HIGH_RISK_SINGLE')); + const balanced = [...singles.slice(0, 3), ...parlays.slice(0, 2), ...sameGame.slice(0, 1), ...highRisk.slice(0, 2)]; + const used = new Set(balanced.map(pickKey)); + const remaining = rank(recommendationItems).filter((item) => !used.has(pickKey(item))); + return [...balanced, ...remaining].slice(0, 10); + }, [recommendationItems]); + + const categorySummary = useMemo(() => { + const count = (type: DailyCardItem['recommendation']) => recommendationItems.filter((item) => item.recommendation === type).length; + return [ + { label: '單關', value: count('SAFE_SINGLE') }, + { label: '跨場串關', value: count('SAFE_PARLAY') }, + { label: '同場串關', value: count('SGP_LOTTERY') }, + { label: '小注高賠', value: count('HIGH_RISK_SINGLE') }, + ]; + }, [recommendationItems]); + + const nextMatches = useMemo(() => { + const now = Date.now(); + return matches + .filter((match) => new Date(match.kickoff_utc).getTime() >= now - 1000 * 60 * 120) + .sort((a, b) => new Date(a.kickoff_utc).getTime() - new Date(b.kickoff_utc).getTime()) + .slice(0, 5); + }, [matches]); + + const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]); + const liveRecommendationCount = recommendationItems.filter((item) => item.has_market_odds).length; + const watchOnlyRecommendationCount = recommendationItems.length - liveRecommendationCount; + const gate = buildGateState(sourceHealth, errors, liveRecommendationCount, watchOnlyRecommendationCount); + + const latestOddsLabel = sourceHealth?.latest_odds_recorded_at ? formatToTaipeiTime(sourceHealth.latest_odds_recorded_at) : '尚無盤口時間'; + const latestResultLabel = sourceHealth?.latest_result_synced_at ? formatToTaipeiTime(sourceHealth.latest_result_synced_at) : '尚無賽果時間'; + const latestNewsLabel = sourceHealth?.news_status?.run_at ? formatToTaipeiTime(sourceHealth.news_status.run_at) : '尚無新聞排程'; + const latestFixturesLabel = sourceHealth?.fixtures_status?.run_at ? formatToTaipeiTime(sourceHealth.fixtures_status.run_at) : '尚無賽程排程'; + const oddsCoverageLabel = sourceHealth?.odds_coverage_status === 'full_market' + ? '完整實盤' + : sourceHealth?.odds_coverage_status === 'reference_market' + ? '台灣運彩參考盤' + : sourceHealth?.odds_coverage_status === 'limited_scoreboard_fallback' + ? '比分備援,盤口不足' + : sourceHealth?.odds_coverage_status === 'no_upcoming_market' + ? '未來賽事無實盤' + : '資料不足'; + const activeDateLabel = recommendationDateLabel(activeRecommendationDate, dateWindow.today, dateWindow.tomorrow); + const activeDateState = recommendationDateState(activeRecommendationDate, dateWindow.today); + const activeDateRecommendationCount = selectedDateSummary?.recommendation_count ?? recommendationItems.length; + const activeDateLiveCount = selectedDateSummary?.live_count ?? liveRecommendationCount; + const activeDateWatchCount = Math.max(activeDateRecommendationCount - activeDateLiveCount, 0); + const activeDateSummaryText = `${activeDateLabel}|${selectedMatchCount} 場比賽、${activeDateRecommendationCount} 筆候選,其中 ${activeDateLiveCount} 筆可做下注前檢查、${activeDateWatchCount} 筆先監控賠率。`; + + const healthItems = [ + { + label: '資料可用性', + ok: Boolean(sourceHealth) && (sourceHealth?.stale_unsettled_matches ?? 0) === 0 && !errors.source, + detail: errors.source ?? `${oddsCoverageLabel};賽果 ${latestResultLabel},逾時未更新 ${sourceHealth?.stale_unsettled_matches ?? 0} 場`, + }, + { + label: '推薦狀態', + ok: liveRecommendationCount > 0, + detail: `${liveRecommendationCount} 組可檢查、${watchOnlyRecommendationCount} 組只監控;目前日期掃描 ${selectedMatchCount} 場`, + }, + { + label: '賽程與新聞', + ok: matches.length > 0 && news.length > 0 && !errors.matches && !errors.news, + detail: `賽程 ${matches.length} 場,排程 ${latestFixturesLabel};新聞 ${news.length} 則,排程 ${latestNewsLabel}`, + }, + ]; + + function handleTrack(item: DailyCardItem) { + const key = pickKey(item); + if (watchlistKeys.has(key)) { + setFeedbackMessage(`已在清單中:${item.match_label} | ${item.selection}`); + return; + } + const next = [item, ...watchlist].slice(0, 20); + setWatchlist(next); + setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}`); + try { + window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next)); + } catch { + setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} | ${item.selection}`); + } + } + + function handleRemoveTracked(item: DailyCardItem) { + const key = pickKey(item); + const next = watchlist.filter((tracked) => pickKey(tracked) !== key); + setWatchlist(next); + setFeedbackMessage(`已移除監控:${item.match_label} | ${item.selection}`); + try { + window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next)); + } catch { + setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。'); + } + } + + return ( +
    +
    +
    +
    +

    首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}

    +

    下一批可檢查投注候選

    +

    + 一打開首頁先看這裡:系統會先看今天,若今天已無可下注賽事,就自動切到明天有資料的場次。若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。 +

    +

    + 目前主顯示日期:{activeRecommendationDate} + 。當日賽事 {selectedMatchCount} 場,推薦 {recommendationItems.length} 組。 +

    +
    +
    + 可檢查 {liveRecommendationCount} 組 + 預掛監控 {watchOnlyRecommendationCount} 組 + 60 秒刷新 +
    +
    + +
    + {dateSummaries.map((item) => { + const pinnedLabel = item.date === dateWindow.today ? '今天' : item.date === dateWindow.tomorrow ? '明天' : ''; + return ( + + ); + })} +
    + +
    + {categorySummary.map((item) => ( + + {item.label} {item.value} 組 + + ))} +
    + + {feedbackMessage ? ( +

    + {feedbackMessage} +

    + ) : null} + +
    +
    +
    +

    我的賠率監控清單

    +

    + {watchlist.length ? `已追蹤 ${watchlist.length} 組候選` : '還沒有加入候選'} +

    +

    + 加入後不是立刻下注,而是集中檢查來源、最低賠率、玩法與注碼上限。台灣盤參考價與模型推算門檻會分開標示。 +

    +
    + + 打開完整檢查清單 + +
    + {watchlist.length ? ( +
    + {watchlist.slice(0, 4).map((item) => { + const conditional = isConditionalPick(item) || !item.has_market_odds; + return ( +
    +
    + + {conditional ? '先監控' : '可檢查'} + + {item.odds_source_label ? ( + {item.odds_source_label} + ) : null} +
    +

    {item.match_label}

    +

    {item.market_type}|{item.selection}

    +
    +

    門檻
    {item.target_odds.toFixed(2)}

    +

    信心
    {typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : item.win_prob.toFixed(1)}

    +

    上限
    {formatTwd(stakeAmountTwd(item))}

    +
    + +
    + ); + })} +
    + ) : ( +

    + 看到候選卡片時,先按「加入賠率監控」或「加入下注前檢查」。清單會保留在此瀏覽器中,方便你逐筆核對,不用重新找。 +

    + )} +
    + + {loading ? ( +

    正在同步日期賽事與多玩法推薦...

    + ) : topPicks.length ? ( +
    + {topPicks.slice(0, 9).map((item) => { + const conditional = isConditionalPick(item) || !item.has_market_odds; + const inList = watchlistKeys.has(pickKey(item)); + return ( +
    +
    + {recommendationText(item)} + + {conditional ? '預掛觀察' : '下注前檢查'} + + {typeof item.confidence_score === 'number' ? 信心分數 {item.confidence_score.toFixed(1)} : null} + {item.odds_source_label ? {item.odds_source_label} : null} +
    +

    {item.match_label}

    +

    台北日期:{activeDateLabel}

    +

    {item.market_type}|{item.selection}

    +

    同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。

    +
    +

    最低可接受賠率:{item.target_odds.toFixed(2)}

    +

    模型勝率:{item.win_prob.toFixed(2)}%

    +

    參考上限:{formatTwd(stakeAmountTwd(item))}

    +
    +

    + {nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。 +

    +
    + + + 完整分析 + +
    +
    + ); + })} +
    + ) : ( +
    +

    目前沒有通過模型勝率、最低賠率、資料新鮮度與倉位上限的候選。專業推薦不是硬湊數量,沒有優勢就保留資金,等下一輪資料刷新。

    +
    + )}
    -
    - {sample.map((item) => ( -
    -

    {item.match}

    -

    推薦模型:{item.model}

    -

    EV {item.ev}

    -

    當前賠率:{item.odds}

    -

    更新時間:{item.updatedAt}

    +
    +
    +
    +
    +

    台北即時投注研究台 / {activeDateState} / {activeRecommendationDate}

    +

    + 世界盃下注推薦主控台 +

    +

    + 首頁先顯示可研究的投注候選,再判斷能不能進場。{activeDateSummaryText} 資料不足時只開監控,不用漂亮空殼假裝高勝率。 +

    +
    +
    + {gate.label} + {loadedAt || '同步中'} +
    +

    {gate.title}

    +

    {gate.detail}

    +
    + 正式盤口:{sourceHealth?.odds_coverage_status === 'full_market' ? '已具備' : '不足'} + 逾時賽果:{sourceHealth?.stale_unsettled_matches ?? 0} 場 + 目前日期:{activeDateLabel} + 監控清單:{watchlist.length} 筆 +
    +
    +
    + + {gate.primaryAction} + + + 檢查盤口覆蓋 + + + 推薦就緒度 + +
    +
    + +
    +
    +

    專業檢查順序

    + 60 秒刷新 +
    +
    + {[ + ['1', '先看狀態', '若是只監控或資料延遲,就不要直接下注。'], + ['2', '確認玩法', '單關、串關、同場串關要分開看,不混成同一種推薦。'], + ['3', '比對賠率', '平台賠率低於最低門檻就跳過,不能硬追。'], + ['4', '控制注碼', '只用卡片建議單位,尤其高賠與同場串關要小注。'], + ].map(([step, title, detail]) => ( +
    +
    + {step} +
    +

    {title}

    +

    {detail}

    +
    +
    +
    + ))} +
    +
    +
    +
    + +
    + {[ + { label: '可檢查候選', value: selectedCard ? liveRecommendationCount : '-', helper: '同日實盤資料' }, + { label: '監控候選', value: selectedCard ? watchOnlyRecommendationCount : '-', helper: '同日等賠率' }, + { label: '串關玩法', value: selectedCard ? recommendationItems.filter((item) => ['SAFE_PARLAY', 'SGP_LOTTERY'].includes(item.recommendation)).length : '-', helper: '跨場/同場' }, + { label: '近期賽事', value: nextMatches.length || '-', helper: '台北時間排序' }, + ].map((item) => ( +
    +

    {item.helper}

    +

    {item.value}

    +

    {item.label}

    ))}
    -
    -

    立即進入專業模組

    -
    - 賽事中心 - 賠率矩陣 - 聰明錢 - 模型頁 - ML Ensemble - 環境與裁判 - RLM 雷達 - 公開獲利帳本 - 球員道具盤 - 凱利下注 - 回測引擎 - 一鍵下注 - 投資組合 +
    +
    +
    +
    +

    依日期優先檢查 / {activeDateState}

    +

    {activeDateLabel} 多玩法候選清單

    +

    {activeDateSummaryText} 詳細下注方式與白話分析請進每日作戰室逐張檢查。

    +
    + 看完整作戰室 +
    + + {feedbackMessage ? ( +

    + {feedbackMessage} +

    + ) : null} + + {loading ? ( +

    同步市場即時資料中...

    + ) : topPicks.length ? ( +
    + {topPicks.map((item) => { + const conditional = isConditionalPick(item) || !item.has_market_odds; + const inList = watchlistKeys.has(pickKey(item)); + return ( +
    +
    + {recommendationText(item)} + + {conditional ? '先監控' : '可檢查'} + + {typeof item.confidence_score === 'number' ? 信心分數 {item.confidence_score.toFixed(1)} : null} + {activeDateLabel} + {item.confidence_band ? {item.confidence_band} : null} +
    +

    {item.match_label}

    +

    {item.market_type} | {item.selection}

    +
    +

    模型勝率 {item.win_prob.toFixed(2)}%

    +

    最低賠率 {item.target_odds.toFixed(2)}

    +

    參考上限 {formatTwd(stakeAmountTwd(item))}

    +
    +

    + {nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。 +

    +
    + + + 看完整分析 + +
    +
    + ); + })} +
    + ) : ( +
    +

    目前沒有通過勝率、期望值、資料新鮮度與注碼上限的候選。沒有優勢就不硬推單,這才是專業紀律。

    +

    {errors.daily ?? selectedCard?.summary ?? '等待下一次資料刷新。'}

    +
    + )} +
    + + +
    + +
    +

    核心頁面入口

    +

    首頁不再把所有工具一次攤開;這裡只保留主流程,其他進階工具放在上方工具箱。

    +
    + {moduleLinks.map((item) => ( + +

    {item.label}

    +

    {item.desc}

    + + ))}
    diff --git a/platform/web/app/paywall/page.tsx b/platform/web/app/paywall/page.tsx index c9a2df6..6d29fcb 100644 --- a/platform/web/app/paywall/page.tsx +++ b/platform/web/app/paywall/page.tsx @@ -1,10 +1,30 @@ +import Link from 'next/link'; + export default function PaywallPage() { return ( -
    -
    -

    升級專業會員

    -

    此區域為 PRO 會員專屬,提供量化高級模型與進階交易信號。

    - +
    +
    +

    會員方案 / 尚未開放收費

    +

    專業會員訂閱方案

    +

    + 目前正式站先以資料品質、推薦透明度與賽後校準為優先,尚未開放付費訂閱。未來若開放,會清楚列出功能、資料來源與限制,不會用未驗證績效做銷售包裝。 +

    +
    + {[ + ['目前可用', '首頁推薦、每日作戰室、賽程比分、AI 成本監控'], + ['暫不收費', '等待真實績效帳本與資料源穩定後才評估'], + ['付費前提', '每筆推薦可追蹤、可校準、可回看命中率'], + ].map(([title, detail]) => ( +
    +

    {title}

    +

    {detail}

    +
    + ))} +
    +
    + 先看今日推薦 + 查看公開收益 +
    ); diff --git a/platform/web/app/portfolio/page.tsx b/platform/web/app/portfolio/page.tsx index 7d37b73..135500a 100644 --- a/platform/web/app/portfolio/page.tsx +++ b/platform/web/app/portfolio/page.tsx @@ -78,7 +78,7 @@ const EMPTY_REPORT: PortfolioLeaksResponse = { }; export default function PortfolioPage() { - const [rawBetsText, setRawBetsText] = useState(JSON.stringify(DEFAULT_BETS, null, 2)); + const [rawBetsText, setRawBetsText] = useState('[]'); const [report, setReport] = useState(EMPTY_REPORT); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(''); @@ -98,12 +98,6 @@ export default function PortfolioPage() { } } - useEffect(() => { - loadBets({ user_bets: DEFAULT_BETS }).catch(() => { - // noop - }); - }, []); - async function onSubmit(event: FormEvent) { event.preventDefault(); @@ -129,7 +123,7 @@ export default function PortfolioPage() {

    貼上注單明細進行弱點診斷

    - 欄位建議包含:市場、單/串關、建議賠率、賠率、注碼、是否已結算與結果,系統將輸出各類別 ROI/CLV 及危險盲點。 + 欄位建議包含:市場、單/串關、建議賠率、賠率、注碼、是否已結算與結果。此頁不再預載範例績效,只有你貼入的真實注單才會進入弱點診斷。

    diff --git a/platform/web/app/proof-of-yield/page.tsx b/platform/web/app/proof-of-yield/page.tsx index fa0690c..5e9b9fb 100644 --- a/platform/web/app/proof-of-yield/page.tsx +++ b/platform/web/app/proof-of-yield/page.tsx @@ -50,14 +50,14 @@ export default function ProofOfYieldPage() { {/* 標題與核心指標 */}

    - Proof of Yield 公開獲利驗證 + 公開收益驗證 公開獲利驗證

    -

    所有預測皆為賽前發布,由區塊鏈/不可竄改日誌驗證,100% 透明。

    +

    只呈現已寫入帳本且已結算的推薦,不用空曲線或示意績效包裝尚未發生的獲利。

    -

    Total ROI (累積報酬率)

    +

    累積報酬率

    = 0 ? 'text-quant-orange' : 'text-red-500'}`}> {summary.roi_percent > 0 ? '+' : ''}{summary.roi_percent.toFixed(2)}%

    @@ -68,7 +68,7 @@ export default function ProofOfYieldPage() {
    -

    Avg CLV (平均收盤線價值)

    +

    平均收盤價差

    = 0 ? 'text-quant-red' : 'text-red-500'}`}> {summary.avg_clv_percent > 0 ? '+' : ''}{summary.avg_clv_percent.toFixed(2)}%

    @@ -78,9 +78,14 @@ export default function ProofOfYieldPage() { {/* 資金成長曲線 */}
    -

    Equity Curve (資產成長曲線)

    - - +

    資產成長曲線

    + {records.length === 0 ? ( +
    + 尚無真實結算資料,因此不顯示模擬收益曲線。 +
    + ) : ( + + @@ -93,7 +98,8 @@ export default function ProofOfYieldPage() { - +
    + )}
    {/* 歷史注單明細表 */} @@ -101,11 +107,11 @@ export default function ProofOfYieldPage() { - - - - - + + + + + @@ -118,7 +124,7 @@ export default function ProofOfYieldPage() { - + ))} diff --git a/platform/web/app/props/page.tsx b/platform/web/app/props/page.tsx index 180573b..b03e543 100644 --- a/platform/web/app/props/page.tsx +++ b/platform/web/app/props/page.tsx @@ -29,7 +29,7 @@ const metricLabelMap: Record = { passes: '傳球', }; -export default function PropsPage() { +export default function PlayerPropsPage() { const [playerName, setPlayerName] = useState('Kylian Mbappé'); const [opponentName, setOpponentName] = useState('墨西哥'); const [metric, setMetric] = useState('shots'); @@ -132,7 +132,12 @@ export default function PropsPage() { return (
    -

    球員道具盤(Player Props)專業模組

    +

    球員道具盤(球員道具盤)專業模組

    +
    +

    + 目前此頁是手動參數模擬器,不是即時球員道具盤報價。只有當球員出賽時間、先發、盤口與授權道具盤賠率接入後,才會標示為正式推薦。 +

    +

    道具盤參數

    diff --git a/platform/web/app/rlm/page.tsx b/platform/web/app/rlm/page.tsx index b26ff69..44dba9b 100644 --- a/platform/web/app/rlm/page.tsx +++ b/platform/web/app/rlm/page.tsx @@ -28,7 +28,7 @@ export default function RlmPage() { const data = await detectReverseLineMovement(payload); setResult(data); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'RLM 偵測暫時無法使用'); + setErrorMessage(error instanceof Error ? error.message : '反向盤口偵測暫時無法使用'); setResult(null); } finally { setLoading(false); @@ -37,7 +37,12 @@ export default function RlmPage() { return (
    -

    反向盤口移動(RLM)追蹤

    +

    反向盤口移動追蹤

    +
    +

    + 此頁只在同時有資金流與開收盤賠率時才會產生警示;預設欄位只是偵測器參數,不代表正式賽事訊號。 +

    +

    偵測條件

    @@ -94,15 +99,22 @@ export default function RlmPage() { onClick={runRlm} disabled={loading} > - {loading ? '偵測中…' : '執行 RLM 偵測'} + {loading ? '偵測中…' : '執行反向盤口偵測'}
    {errorMessage ?

    {errorMessage}

    : null}
    ({ + matchName: a.match_id, + market: a.market_type, + selection: a.selection, + ticketPct: a.ticket_pct, + handlePct: a.handle_pct, + openingOdds: a.opening_odds, + currentOdds: a.current_odds, + }))} />

    diff --git a/platform/web/app/sharp-money/page.tsx b/platform/web/app/sharp-money/page.tsx index 97a3aef..6a1fc6b 100644 --- a/platform/web/app/sharp-money/page.tsx +++ b/platform/web/app/sharp-money/page.tsx @@ -1,14 +1,38 @@ -import { MoneyFlowBar } from '@/components/MoneyFlowBar'; +import Link from 'next/link'; export default function SharpMoneyPage() { return ( -

    -

    聰明錢流向監控

    +
    +
    +

    進階資料頁 / 尚未接入正式資金流

    +

    資金流向追蹤

    +

    + 這頁未來會顯示「投注人數比例」與「投注金額比例」的差異,用來判斷市場是否出現大額資金偏向。正式環境不得用示意賽事冒充資金流,因此在授權資料源接入前,只保留狀態說明與下一步。 +

    +
    + +
    + {[ + ['目前狀態', '等待可驗證資金流資料源', '不顯示假票數、不顯示假資金比例。'], + ['現在該看', '每日作戰室與盤口覆蓋', '先以賠率門檻、資料新鮮度與注碼上限決策。'], + ['接入後會顯示', '票數、金額、異常移動時間', '只有同一場、同一玩法、同一選項才會比較。'], + ].map(([title, value, detail]) => ( +
    +

    {title}

    +

    {value}

    +

    {detail}

    +
    + ))} +
    +
    -

    示意:當投注筆數與投注金額走向反向時,提示 Sharp Money 可能存在。

    -
    - - +

    下一步

    +

    + 若要下注前檢查,先到每日作戰室加入監控清單;若要確認目前玩法是否有足夠盤口,先看盤口覆蓋。 +

    +
    + 前往每日作戰室 + 檢查盤口覆蓋
    diff --git a/platform/web/components/ActionableBetCard.tsx b/platform/web/components/ActionableBetCard.tsx index 0455c88..40b4373 100644 --- a/platform/web/components/ActionableBetCard.tsx +++ b/platform/web/components/ActionableBetCard.tsx @@ -2,36 +2,273 @@ import type { DailyCardItem } from '@/lib/analytics-api'; -type Props = { +type ActionableBetCardProps = { item: DailyCardItem; onAddToSlip: (item: DailyCardItem) => void; + isInSlip?: boolean; className?: string; }; -export function ActionableBetCard({ item, onAddToSlip, className = '' }: Props) { +function isConditionalPick(item: DailyCardItem): boolean { return ( -
    -

    {item.recommendation}

    -

    {item.match_label}

    -

    {item.market_type}

    -

    選項:{item.selection}

    -
    -

    目標賠率:{item.target_odds.toFixed(2)}

    -

    估計勝率:{item.win_prob.toFixed(2)}%

    -

    EV:{item.ev_percent.toFixed(2)}%

    -

    建議籌碼:{item.stake_units.toFixed(2)} Units

    + item.has_market_odds === false || + item.selection.includes('預掛條件') || + item.selection.includes('參考盤監控') || + item.rationale.includes('尚未取得可用即時盤口') || + item.rationale.includes('尚未取得完整實盤賠率') || + Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件'))) + ); +} + +function isReferenceMarket(item: DailyCardItem): boolean { + return item.odds_source_kind === 'reference_market'; +} + +function recommendationLabel(item: DailyCardItem): string { + const isConditional = isConditionalPick(item); + + if (item.recommendation === 'SAFE_SINGLE') return isConditional ? '觀察單關' : '核心單關'; + if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠'; + if (item.recommendation === 'SAFE_PARLAY') return isConditional ? '觀察串關' : '跨場串關'; + if (item.recommendation === 'SGP_LOTTERY') return '同場小注'; + return '研究候選'; +} + +function riskLabel(value?: string): string { + if (value === 'core') return '核心候選'; + if (value === 'speculative') return '小注高波動'; + if (value === 'parlay') return '串關組合'; + if (value === 'sgp') return '同場高波動'; + return '研究候選'; +} + +function betMode(item: DailyCardItem): string { + if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,依下方每一腿逐一加入。'; + if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用很小的注碼。'; + return '到投注平台選「單關」,找到同一場比賽與同一個市場後下注。'; +} + +function formatTwd(value: number): string { + return new Intl.NumberFormat('zh-TW', { + style: 'currency', + currency: 'TWD', + maximumFractionDigits: 0, + }).format(value); +} + +function stakeAmountTwd(item: DailyCardItem): number { + const unitSize = item.unit_size_twd ?? 1000; + return item.stake_amount_twd ?? Math.round(item.stake_units * unitSize); +} + +function stakeGuide(item: DailyCardItem): string { + const unitSize = item.unit_size_twd ?? 1000; + return `建議參考上限 ${formatTwd(stakeAmountTwd(item))},約 ${item.stake_units.toFixed(2)}u,系統目前以 1u 等於 ${formatTwd(unitSize)} 換算。`; +} + +function oddsRule(item: DailyCardItem): string { + const source = item.odds_source_label ? `目前來源:${item.odds_source_label}。` : ''; + return `${source}只有在你拿到的賠率大於或等於 ${item.target_odds.toFixed(2)} 時才考慮下注;如果平台賠率低於這個數字,期望值會被吃掉,直接跳過。`; +} + +function executionStatusLabel(item: DailyCardItem): string { + if (isReferenceMarket(item)) return '台灣盤參考'; + return isConditionalPick(item) ? '預掛條件' : '實盤可檢查'; +} + +function executionStatusDetail(item: DailyCardItem): string { + if (isReferenceMarket(item)) { + return '這張已有台灣運彩公開盤作為參考價,可以用來比對最低可接受賠率;但它仍不是多莊家正式盤,下注前要再確認同一玩法、同一分線與最新盤口。'; + } + + if (isConditionalPick(item)) { + return '目前尚未取得完整實盤或分線盤口,這張不是叫你立刻下注,而是先設定最低賠率門檻。等平台開盤後,只有賠率達標才進場。'; + } + + return '這張已有可比對的盤口資料,仍需在下注前確認同一市場、同一選項、同一分線與最低可接受賠率都一致。'; +} + +function dataQualityLabel(item: DailyCardItem): string { + if (isReferenceMarket(item)) return '資料品質:台灣運彩參考盤,已和模型門檻比對'; + if (item.data_quality === 'rank_elo_prior') return '資料品質:國際排名與實力分數估計,信心已降低'; + if (item.data_quality === 'fallback_prior') return '資料品質:基礎估計,僅能觀察'; + if (item.data_quality === 'mixed') return '資料品質:串關腿數混合來源'; + if (isConditionalPick(item)) return '資料品質:等待實盤確認'; + if (item.data_checks?.some((check) => check.includes('去水') || check.includes('莊家'))) return '資料品質:已檢查平台抽成影響'; + return '資料品質:基礎盤口檢查'; +} + +function plainCheckLabel(check: string): string { + return check + .replaceAll('Poisson', '進球分布') + .replaceAll('xG', '預期進球') + .replaceAll('EV', '期望值') + .replaceAll('edge', '模型優勢') + .replaceAll('CLV', '收盤價差') + .replaceAll('FIFA 排名/Elo', '國際排名與實力分數') + .replaceAll('FIFA/Elo', '國際排名與實力分數') + .replaceAll('市場隱含機率', '市場估計機率') + .replaceAll('正 期望值', '期望值為正') + .replaceAll('正期望值', '期望值為正') + .replaceAll('小倉位', '小注碼') + .replaceAll('預掛總賠率門檻', '等待總賠率達標') + .replaceAll('串關 EV 重新計算', '串關期望值重新計算') + .replaceAll('高 期望值 門檻', '高期望值門檻'); +} + +function plainLanguage(item: DailyCardItem): string { + const implied = typeof item.market_implied_prob === 'number' ? item.market_implied_prob.toFixed(2) : null; + const edge = typeof item.edge_percent === 'number' ? item.edge_percent.toFixed(2) : null; + const isConditional = isConditionalPick(item); + + if (implied && edge) { + const quality = item.data_quality && item.data_quality !== 'observed' ? ' 但此項目前資料品質不是完整即時盤,信心與倉位已被系統折扣。' : ''; + const sourceNote = isReferenceMarket(item) ? ' 這裡的市場機率來自台灣運彩參考盤,不是多莊家共識。' : ''; + if (isConditional) { + return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出;目前還沒有完整可驗證賠率,所以 ${implied}% 代表「最低可接受賠率」換算出的門檻,不是真實市場共識。模型多看好 ${edge} 個百分點,期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型條件可能有優勢,不代表這場一定會中。${quality}`; + } + return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,市場目前大約只反映 ${implied}% 的機率,等於模型多看好 ${edge} 個百分點。期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型下注的平均報酬為正,不代表這場一定會中。${sourceNote}${quality}`; + } + + return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,期望值 ${item.ev_percent.toFixed(2)}%。這是長期平均報酬判斷,不是單場保證;若資料品質不是完整即時盤,請只列入觀察。`; +} + +export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, className = '' }: ActionableBetCardProps) { + const confidence = typeof item.confidence_score === 'number' ? item.confidence_score : null; + const checks = item.data_checks ?? []; + const confidenceFactors = item.confidence_factors ?? []; + const isCombo = item.legs?.length; + const isConditional = isConditionalPick(item); + const referenceMarket = isReferenceMarket(item); + const actionLabel = isInSlip + ? isConditional ? '已在賠率監控清單' : '已在下注前檢查清單' + : isConditional ? '加入賠率監控清單' : '加入下注前檢查清單'; + + return ( +
    +
    +

    {recommendationLabel(item)}

    + {riskLabel(item.risk_level)} + + {executionStatusLabel(item)} + + {item.odds_source_label ? ( + + {item.odds_source_label} + + ) : null} + {confidence !== null ? ( + + 信心 {confidence.toFixed(1)} + + ) : null} + {item.confidence_band ? ( + + {item.confidence_band} + + ) : null}
    -

    {item.rationale}

    + +

    {item.match_label}

    +

    投注市場:{item.market_type}

    +

    投注選項:{item.selection}

    + +
    +

    推薦狀態

    +

    {executionStatusDetail(item)}

    +

    {dataQualityLabel(item)}

    +
    + +
    +

    下注方式

    +

    {betMode(item)}

    +
    +

    市場:{item.market_type}

    +

    選項:{item.selection}

    +

    最低可接受賠率:{item.target_odds.toFixed(2)}

    +

    建議上限:{formatTwd(stakeAmountTwd(item))}

    +
    +

    {oddsRule(item)}

    +

    {stakeGuide(item)}

    +
    + + {isCombo ? ( +
    +

    串關組成

    +
    + {item.legs?.map((leg, index) => ( +

    + 第 {index + 1} 腿:{leg.selection},賠率至少 {leg.odds.toFixed(2)} +

    + ))} +
    +
    + ) : null} + +
    +

    白話分析

    +

    {plainLanguage(item)}

    +
    +

    模型勝率:{item.win_prob.toFixed(2)}%

    + {typeof item.market_implied_prob === 'number' ? ( +

    {isConditional ? '賠率門檻換算機率' : '市場估計機率'}:{item.market_implied_prob.toFixed(2)}%

    + ) : null} + {typeof item.edge_percent === 'number' ?

    模型優勢:{item.edge_percent.toFixed(2)} 百分點

    : null} +

    預期期望值:{item.ev_percent.toFixed(2)}%

    +
    +
    + + {confidenceFactors.length || checks.length ? ( +
    +

    信心與計算依據

    +
    + {confidenceFactors.slice(0, 5).map((factor) => ( + + {plainCheckLabel(factor)} + + ))} + {checks.slice(0, 6).map((check) => ( + + {plainCheckLabel(check)} + + ))} +
    +
    + ) : null} + +
    +

    風險提醒

    +

    + 這是研究候選,不是結果承諾。若賠率低於最低可接受賠率、先發名單異常、傷停新聞改變、或資料源延遲,就不要下注,等待下一次刷新。 +

    +

    + 下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議單位。 +

    +
    - -
    + {isInSlip ? ( +

    + 已加入清單,請到本頁上方「賠率監控清單」集中檢查。 +

    + ) : null}
    ); } diff --git a/platform/web/components/BettingLeaksDashboard.tsx b/platform/web/components/BettingLeaksDashboard.tsx index 0ed68b3..de11a3c 100644 --- a/platform/web/components/BettingLeaksDashboard.tsx +++ b/platform/web/components/BettingLeaksDashboard.tsx @@ -12,12 +12,11 @@ import { } from 'recharts'; import type { - PortfolioHardTruth, PortfolioLeakCluster, PortfolioLeaksResponse, } from '@/lib/analytics-api'; -type Props = { +type BettingLeaksDashboardProps = { data: PortfolioLeaksResponse; }; @@ -58,7 +57,7 @@ function computeMaxDrawdown(points: { balance: number }[]) { return Number(maxDD.toFixed(2)); } -export function BettingLeaksDashboard({ data }: Props) { +export function BettingLeaksDashboard({ data }: BettingLeaksDashboardProps) { const [onlyPositiveAndHigh, setOnlyPositiveAndHigh] = useState(false); const clusters = useMemo(() => { @@ -81,7 +80,7 @@ export function BettingLeaksDashboard({ data }: Props) {

    {data.total_bet_count}

    -

    整體 ROI

    +

    整體報酬率

    = 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}> {data.overall_roi_percent.toFixed(2)}%

    @@ -106,7 +105,7 @@ export function BettingLeaksDashboard({ data }: Props) { onlyPositiveAndHigh ? 'bg-[#7d2a15] text-white' : 'bg-white/80 text-[#5f4330]' }`} > - 僅看「正 EV 且個人勝率高」 + 僅看「正 期望值 且個人勝率高」
    @@ -153,7 +152,7 @@ export function BettingLeaksDashboard({ data }: Props) {
    - + diff --git a/platform/web/components/EquityCurveChart.tsx b/platform/web/components/EquityCurveChart.tsx index f283672..d7aae65 100644 --- a/platform/web/components/EquityCurveChart.tsx +++ b/platform/web/components/EquityCurveChart.tsx @@ -15,13 +15,13 @@ type Point = { capital: number; }; -type Props = { +type 球員道具盤 = { title: string; points: Point[]; maxDrawdown: number; }; -export function EquityCurveChart({ title, points, maxDrawdown }: Props) { +export function EquityCurveChart({ title, points, maxDrawdown }: 球員道具盤) { return (

    {title}

    diff --git a/platform/web/components/HedgeAlert.tsx b/platform/web/components/HedgeAlert.tsx index b67b11c..7c98086 100644 --- a/platform/web/components/HedgeAlert.tsx +++ b/platform/web/components/HedgeAlert.tsx @@ -1,6 +1,6 @@ 'use client'; -type Props = { +type HedgeAlertProps = { isVisible: boolean; parlayLabel: string; counterSelection: string; @@ -8,7 +8,7 @@ type Props = { lockedProfit: number; }; -export function HedgeAlert({ isVisible, parlayLabel, counterSelection, hedgeStake, lockedProfit }: Props) { +export function HedgeAlert({ isVisible, parlayLabel, counterSelection, hedgeStake, lockedProfit }: HedgeAlertProps) { if (!isVisible) { return null; } diff --git a/platform/web/components/KellyBetSizing.tsx b/platform/web/components/KellyBetSizing.tsx index 6d98034..b74b5e3 100644 --- a/platform/web/components/KellyBetSizing.tsx +++ b/platform/web/components/KellyBetSizing.tsx @@ -34,7 +34,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing

    - Kelly Criterion + 注碼風險控管

    量化注碼建議

    @@ -49,7 +49,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing {/* 控制區:總資金 */}
    diff --git a/platform/web/components/LeaderboardBoard.tsx b/platform/web/components/LeaderboardBoard.tsx index ee80a90..33885a8 100644 --- a/platform/web/components/LeaderboardBoard.tsx +++ b/platform/web/components/LeaderboardBoard.tsx @@ -2,70 +2,20 @@ import React from 'react'; -// 模擬的大神資料 -const leaderboardData = [ - { rank: 1, name: 'QuantKing99', clv: '+8.4%', roi: '+65.2%', isSharp: true }, - { rank: 2, name: 'RLM_Hunter', clv: '+7.1%', roi: '+52.8%', isSharp: true }, - { rank: 3, name: 'AlphaBet', clv: '+6.5%', roi: '+48.1%', isSharp: true }, - { rank: 4, name: 'NormalGuy', clv: '+1.2%', roi: '+5.4%', isSharp: false }, - { rank: 5, name: 'LuckyBettor', clv: '-2.1%', roi: '+12.5%', isSharp: false }, -]; - export default function LeaderboardBoard() { - - const handleCopyBet = (bettorName: string) => { - alert(`[系統提示] 已啟動一鍵跟單 ${bettorName}!\n系統將自動按凱利比例配置您的資金。`); - // 實務上這裡會呼叫後端建立跟單關係,並計算注碼 - }; - return (

    - 🏆 量化大神排行榜 (Social Trading) + 量化跟單排行榜

    -

    根據 CLV (收盤線價值) 與近 30 天 ROI 進行排名。一鍵跟單頂級操盤手。

    +

    + 尚未接入真實、可審計的投注帳本與收盤價差資料前,不顯示模擬高手、不提供一鍵跟單。 +

    -
    -
    DateMatch / SelectionCLVResultProfit/Loss日期賽事 / 選項收盤價差結果損益
    {bet.settled_at.split('T')[0]} {bet.match_id} | {bet.selection} {bet.clv_percent !== null ? `${bet.clv_percent.toFixed(2)}%` : '-'}{bet.is_win ? 'WIN' : 'LOSS'}{bet.is_win ? '命中' : '未中'} 0 ? 'text-green-600' : 'text-red-500'}`}>{bet.pnl > 0 ? '+' : ''}{bet.pnl.toFixed(2)}
    賽段 筆數 成交金額ROI報酬率 勝率 狀態
    - - - - - - - - - - - {leaderboardData.map((bettor) => ( - - - - - - - - ))} - -
    RankBettorAvg CLV30D ROIAction
    - {bettor.rank <= 3 ? #{bettor.rank} : `#${bettor.rank}`} - -
    - - {bettor.name} - - {bettor.isSharp && Sharp} -
    -
    {bettor.clv}{bettor.roi} - -
    +
    + 等待資料:已結算注單、下注時間、進場賠率、收盤賠率、收盤價差、報酬率、最大回撤與風險分層。資料未齊前,本模組維持關閉。
    ); diff --git a/platform/web/components/LiveMatchCenter.tsx b/platform/web/components/LiveMatchCenter.tsx index 3f28bbb..09e21eb 100644 --- a/platform/web/components/LiveMatchCenter.tsx +++ b/platform/web/components/LiveMatchCenter.tsx @@ -21,7 +21,7 @@ type ZonePoint = { pct: number; }; -type Props = { +type 球員道具盤 = { timeline: { minute: number; label: string; @@ -30,7 +30,7 @@ type Props = { heatZones: ZonePoint[]; }; -export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) { +export function LiveMatchCenter({ timeline, xgSeries, heatZones }: 球員道具盤) { return (
    @@ -45,7 +45,7 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
    -

    xG 累積走勢

    +

    預期進球累積走勢

    @@ -56,8 +56,8 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) { contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }} itemStyle={{ color: '#5f4031' }} /> - - + +
    @@ -82,4 +82,3 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
    ); } - diff --git a/platform/web/components/MatchConditionsCard.tsx b/platform/web/components/MatchConditionsCard.tsx index 011c69f..5dc590d 100644 --- a/platform/web/components/MatchConditionsCard.tsx +++ b/platform/web/components/MatchConditionsCard.tsx @@ -58,4 +58,3 @@ export function MatchConditionsCard({
    ); } - diff --git a/platform/web/components/MobileBottomNav.tsx b/platform/web/components/MobileBottomNav.tsx index a454c1e..f916070 100644 --- a/platform/web/components/MobileBottomNav.tsx +++ b/platform/web/components/MobileBottomNav.tsx @@ -9,10 +9,11 @@ type NavItem = { }; const navItems: NavItem[] = [ - { href: '/daily-card', label: '每日作戰室' }, - { href: '/matches', label: '賽事' }, - { href: '/portfolio', label: '投資組合' }, - { href: '/proof-of-yield', label: '帳本' }, + { href: '/', label: '首頁' }, + { href: '/daily-card', label: '作戰' }, + { href: '/live-score', label: '比分' }, + { href: '/schedule', label: '賽程' }, + { href: '/source-health', label: '健康' }, ]; export function MobileBottomNav() { @@ -22,7 +23,7 @@ export function MobileBottomNav() {
    ); } - diff --git a/platform/web/components/PlayerMatchupRadar.tsx b/platform/web/components/PlayerMatchupRadar.tsx index 86af132..7603c9d 100644 --- a/platform/web/components/PlayerMatchupRadar.tsx +++ b/platform/web/components/PlayerMatchupRadar.tsx @@ -16,7 +16,7 @@ type MetricPoint = { opponent: number; }; -type Props = { +type 球員道具盤 = { playerName: string; opponentName: string; metrics: { @@ -29,7 +29,7 @@ type Props = { }; }; -export function PlayerMatchupRadar({ playerName, opponentName, metrics }: Props) { +export function PlayerMatchupRadar({ playerName, opponentName, metrics }: 球員道具盤) { const data: MetricPoint[] = [ { metric: '攻擊', player: metrics.攻擊, opponent: Math.max(0, 100 - metrics.攻擊) }, { metric: '運球', player: metrics.運球, opponent: Math.max(0, 100 - metrics.運球) }, diff --git a/platform/web/components/PropValueCard.tsx b/platform/web/components/PropValueCard.tsx index 1990817..71c6718 100644 --- a/platform/web/components/PropValueCard.tsx +++ b/platform/web/components/PropValueCard.tsx @@ -1,6 +1,6 @@ 'use client'; -type Props = { +type 球員道具盤 = { playerName: string; metricLabel: string; line: number; @@ -22,7 +22,7 @@ export function PropValueCard({ impliedProb, edgePercent, topEdge, -}: Props) { +}: 球員道具盤) { return (

    {playerName} · {metricLabel}

    @@ -34,7 +34,7 @@ export function PropValueCard({
    {topEdge ? (

    - 極佳投注價值(Top Edge):預測機率高於市場定價,建議納入進階池列管。 + 高價值候選:模型預測機率高於目前市場定價,建議納入進階觀察清單。

    ) : (

    目前未到達極佳邊際門檻,建議依整體單場風險控管配置。

    diff --git a/platform/web/components/PwaBootstrap.tsx b/platform/web/components/PwaBootstrap.tsx index ac9ddee..dbd000d 100644 --- a/platform/web/components/PwaBootstrap.tsx +++ b/platform/web/components/PwaBootstrap.tsx @@ -2,6 +2,9 @@ import { useEffect } from 'react'; +const APP_VERSION = '2026-06-14-ux-realtime-v3'; +const APP_VERSION_KEY = 'fifa2026-app-version'; + function toUint8Array(base64: string): Uint8Array { const padding = '='.repeat((4 - (base64.length % 4)) % 4); const base64Safe = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); @@ -22,12 +25,22 @@ export function PwaBootstrap() { return; } - const setup = async () => { - try { - const registration = await navigator.serviceWorker.register('/sw.js'); - await registration.update(); + const setup = async () => { + try { + const previousVersion = window.localStorage.getItem(APP_VERSION_KEY); + const shouldPurgeOldCache = previousVersion !== APP_VERSION; + if (shouldPurgeOldCache && 'caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.filter((key) => key.startsWith('wc2026-') || key.includes('fifa2026')).map((key) => caches.delete(key))); + window.localStorage.setItem(APP_VERSION_KEY, APP_VERSION); + } - if (Notification.permission === 'granted') { + const registration = await navigator.serviceWorker.register(`/sw.js?v=${APP_VERSION}`); + await registration.update(); + registration.active?.postMessage({ type: 'app-version', version: APP_VERSION }); + registration.waiting?.postMessage({ type: 'skip-waiting' }); + + if (Notification.permission === 'granted') { const vapid = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; if (vapid) { const existing = await registration.pushManager.getSubscription(); diff --git a/platform/web/components/QuickBetButton.tsx b/platform/web/components/QuickBetButton.tsx index e224f02..a3f90d6 100644 --- a/platform/web/components/QuickBetButton.tsx +++ b/platform/web/components/QuickBetButton.tsx @@ -9,7 +9,7 @@ const logoMap: Record = { draftkings: 'DraftKings', }; -type Props = { +type 球員道具盤 = { bookmakerId: BookmakerCode; matchId: string; selection: string; @@ -17,7 +17,7 @@ type Props = { disabled?: boolean; }; -export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled = false }: Props) { +export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled = false }: 球員道具盤) { const [isRedirecting, setIsRedirecting] = useState(false); const label = logoMap[bookmakerId]; @@ -33,9 +33,10 @@ export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled }), [bookmakerId, matchId, selection, odds], ); + const isUnavailable = disabled || !href; const handleClick = () => { - if (disabled) { + if (disabled || !href) { return; } setIsRedirecting(true); @@ -47,12 +48,13 @@ export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled