chore: migrate deployment to Gitea Actions with zero-trust rsync
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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!"
|
||||
118
.github/workflows/deploy-production.yml
vendored
118
.github/workflows/deploy-production.yml
vendored
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,而不像台灣使用者真正會用的投研工具。
|
||||
|
||||
40
iwooos_javae_monitor.sh
Normal file
40
iwooos_javae_monitor.sh
Normal file
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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': [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""聯盟行銷博彩公司追蹤碼設定。"""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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<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 }],
|
||||
});
|
||||
const [result, setResult] = useState<BacktestResponse>(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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">自訂策略回測引擎</h2>
|
||||
<section className="panel-glow rounded-2xl border-dashed p-4">
|
||||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||||
目前尚無真實已結算推薦注單可回測,因此本頁不展示匿名範例勝率或模擬報酬率。正式績效只會來自「公開收益帳本」內已結算推薦。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">策略條件設定</h3>
|
||||
@@ -230,15 +238,15 @@ export default function BacktestingPage() {
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<p className="text-sm text-[#7a5b46]">樣本數</p>
|
||||
<p className="text-sm text-[#7a5b46]">已結算樣本數</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.matched}</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<p className="text-sm text-[#7a5b46]">勝率</p>
|
||||
<p className="text-sm text-[#7a5b46]">實證勝率</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.win_rate.toFixed(1)}%</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<p className="text-sm text-[#7a5b46]">ROI</p>
|
||||
<p className="text-sm text-[#7a5b46]">實證報酬率</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.roi_percent.toFixed(2)}%</p>
|
||||
</article>
|
||||
</section>
|
||||
@@ -255,7 +263,7 @@ export default function BacktestingPage() {
|
||||
</section>
|
||||
|
||||
<EquityCurveChart
|
||||
title="資金成長曲線(Equity Curve)"
|
||||
title="資金成長曲線"
|
||||
points={result.equity_curve}
|
||||
maxDrawdown={result.max_drawdown_percent}
|
||||
/>
|
||||
@@ -269,7 +277,7 @@ export default function BacktestingPage() {
|
||||
{trade.isWin ? '勝' : '敗'}|賠率 {trade.odds}
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 ? <li className="rounded-lg bg-white/70 p-3">此條件下目前無筆交易</li> : null}
|
||||
{filtered.length === 0 ? <li className="rounded-lg bg-white/70 p-3">尚無真實已結算交易,不顯示示範注單。</li> : null}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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<TabKey, string> = {
|
||||
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<TabKey>('all');
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getDailyCard>> | null>(null);
|
||||
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
|
||||
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
|
||||
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
|
||||
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<TabKey, DailyCardItem[]> = 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 (
|
||||
<div className="space-y-4">
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">每日操盤戰情室</h2>
|
||||
<p className="mt-1 text-xs text-[#8a6b58]">總曝險單位:{data ? data.total_daily_unit_recommendation.toFixed(2) : '-'}</p>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">日期:{targetDate}</p>
|
||||
<p className="mt-2 text-sm text-[#6f4f3c]">{briefing}</p>
|
||||
<p className="mt-2 text-xs text-[#8c2f2f]">已加入注單:{selectedCount} 單</p>
|
||||
<div className="space-y-6">
|
||||
<section className="panel-glow rounded-2xl p-6">
|
||||
<h2 className="dot-matrix text-2xl font-bold text-[#7d2a15]">每日作戰室:依日期選擇投注候選</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
這頁把外部已取得資料的賽事依日期攤開,按玩法分成單關、跨場串關、同場串關與小注高賠。今天與明天放在最前面,後面保留世界盃開踢後所有有賽事的日期。
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<Link key={item.step} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822]">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{item.step}</span>
|
||||
<div>
|
||||
<p className="font-bold text-[#3f2f25]">{item.title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">日期賽事推薦盤</p>
|
||||
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
|
||||
按鈕格式為「日期|幾場比賽|幾組推薦」。推薦數會依外部盤口與模型條件即時更新;沒有達標就顯示 0 推,不硬湊。
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-[#8a6b58]">今天 / 明天優先,其餘依世界盃日期排序</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{dateSummaries.map((item) => {
|
||||
const isPinned = item.date === sampleDate || item.date === tomorrowSampleDate;
|
||||
const missingSnapshot = item.marketDataStatus === 'snapshot_missing_after_kickoff';
|
||||
return (
|
||||
<button
|
||||
key={item.date}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDate(item.date);
|
||||
setActiveTab('all');
|
||||
}}
|
||||
className={`rounded-full border px-4 py-2 text-left text-xs font-black transition ${
|
||||
selectedDate === item.date
|
||||
? 'border-[#7d2a15] bg-[#7d2a15] text-white shadow-[0_10px_26px_rgba(125,42,21,0.20)]'
|
||||
: 'border-[#d8b58c] bg-[#fff8e6] text-[#5f4330] hover:border-[#b83822]'
|
||||
}`}
|
||||
>
|
||||
<span>{item.date}</span>
|
||||
{isPinned ? <span className="ml-1 opacity-80">{item.date === sampleDate ? '今天' : '明天'}</span> : null}
|
||||
<span className="ml-2 opacity-90">{item.matchCount} 場 / {item.recommendationCount} 推</span>
|
||||
{missingSnapshot ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">缺快照</span> : null}
|
||||
{!missingSnapshot && item.snapshotStatus === 'saved' ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">已鎖快照</span> : null}
|
||||
{!missingSnapshot && item.snapshotStatus === 'not_saved_yet' ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">待保存</span> : null}
|
||||
{!missingSnapshot && item.recommendationCount > 0 && item.liveCount <= 0 ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">僅監控</span> : null}
|
||||
{item.liveCount > 0 ? <span className="ml-2 rounded-full bg-white/25 px-2 py-0.5">{item.liveCount} 可檢查</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-[#7a5b46]">目前日期:{selectedDate}|當日賽事 {selectedMatchCount} 場|推薦 {tabCards.all.length} 組</p>
|
||||
<p className="text-sm font-semibold text-[#167a47]">
|
||||
參考上限:{selectedCard ? formatTwd(dailyAmountTwd(selectedCard)) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<p className="text-sm leading-6 text-[#5f4330]">{briefing}</p>
|
||||
{selectedCard?.summary ? <p className="mt-3 text-xs leading-5 text-[#7a5b46]">當日摘要:{plainBackendText(selectedCard.summary)}</p> : null}
|
||||
{selectedCard?.execution_policy ? <p className="mt-3 text-xs leading-5 text-[#8a6400]">執行規則:{plainBackendText(selectedCard.execution_policy)}</p> : null}
|
||||
</div>
|
||||
{selectedMissingSnapshot ? (
|
||||
<div className="mt-4 rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="dot-matrix text-sm font-black text-[#b83822]">此日期不補造投注推薦</p>
|
||||
<h3 className="mt-2 text-xl font-black text-[#7d2a15]">缺少賽前快照,保留紀錄但不事後報牌</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#6f4f3c]">
|
||||
這代表賽事已開賽或完賽,但系統沒有保存到可驗證的賽前推薦快照。專業做法是承認缺口,不用賽後結果倒推假高勝率。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row md:flex-col">
|
||||
{nextAvailableSummary ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]"
|
||||
onClick={() => {
|
||||
setSelectedDate(nextAvailableSummary.date);
|
||||
setActiveTab('all');
|
||||
}}
|
||||
>
|
||||
看 {nextAvailableSummary.date} 的 {nextAvailableSummary.recommendationCount} 筆候選
|
||||
</button>
|
||||
) : null}
|
||||
<Link href="/recommendation-performance" className="rounded-full border border-[#d8b58c] bg-white/75 px-5 py-3 text-center text-sm font-bold text-[#7d2a15] transition hover:border-[#b83822]">
|
||||
看賽後校準
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-5">
|
||||
{[
|
||||
{ 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) => (
|
||||
<article key={item.label} className="rounded-xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<p className="text-xs text-[#8a6b58]">{item.helper}</p>
|
||||
<p className="mt-2 text-lg font-black text-[#7d2a15]">{item.value}</p>
|
||||
<p className="mt-1 text-xs text-[#7a5b46]">{item.label}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">目前盤口來源</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
|
||||
{executionMetrics.sourceLabels}。若顯示「模型推算最低門檻」,代表該玩法尚未取得直接盤口;若顯示「台灣運彩參考盤」,代表可比對台灣盤賠率,但仍不是多莊家正式市場。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">怎麼看這些推薦</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
|
||||
先看「下注方式」確認是單關、跨場串關或同場串關;再看「最低可接受賠率」。
|
||||
如果平台賠率低於卡片上的數字,就不要下注。最後用建議單位控制單場風險。
|
||||
</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">AI 只負責提醒,不直接決定下注</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
|
||||
AI 會協助盯新聞、傷停、賠率異動與資料延遲;真正能不能列為推薦,仍要通過勝率、最低賠率、風險上限與資料新鮮度檢查,不能只靠直覺。
|
||||
</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-[#e7c89b] bg-[#fff8e6] p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">我們怎麼避免亂推</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">
|
||||
每張卡都會檢查賠率是否達標、資料是否夠新、串關是否互相牽動。沒有真實賠率或賠率太低時,只能列入觀察,不硬推下注。
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className={`status-led ${watchlist.length > 0 ? 'status-led-ok' : 'status-led-warn'}`} />
|
||||
<p className="text-xs text-[#7a5b46]">目前監控清單:{watchlist.length || selectedCount} 筆</p>
|
||||
</div>
|
||||
{feedbackMessage ? (
|
||||
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
|
||||
{feedbackMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<p className="dot-matrix text-lg text-[#7d2a15]">Hard Filters</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="dot-matrix text-lg font-bold text-[#7d2a15]">推薦分類</p>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">先用分類縮小範圍;如果不確定,直接看「全部候選」。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-sm ${
|
||||
activeTab === 'safe' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
|
||||
}`}
|
||||
className={tabButtonClass(activeTab === 'all')}
|
||||
onClick={() => setActiveTab('all')}
|
||||
>
|
||||
全部候選
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tabButtonClass(activeTab === 'safe')}
|
||||
onClick={() => setActiveTab('safe')}
|
||||
>
|
||||
Safe Singles(高穩)
|
||||
單關候選
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-sm ${
|
||||
activeTab === 'risk' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
|
||||
}`}
|
||||
className={tabButtonClass(activeTab === 'risk')}
|
||||
onClick={() => setActiveTab('risk')}
|
||||
>
|
||||
High-Risk Underdog(搏冷)
|
||||
小注高賠
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-sm ${
|
||||
activeTab === 'parlay' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
|
||||
}`}
|
||||
className={tabButtonClass(activeTab === 'parlay')}
|
||||
onClick={() => setActiveTab('parlay')}
|
||||
>
|
||||
Safe Parlays(跨場)
|
||||
跨場串關
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-sm ${
|
||||
activeTab === 'sgp' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
|
||||
}`}
|
||||
className={tabButtonClass(activeTab === 'sgp')}
|
||||
onClick={() => setActiveTab('sgp')}
|
||||
>
|
||||
SGP(同場關聯)
|
||||
同場串關
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? <p className="text-sm text-[#8a6b58]">載入中...</p> : null}
|
||||
{error ? <p className="text-sm text-[#8c2f2f]">{error}</p> : null}
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="dot-matrix text-lg font-bold text-[#7d2a15]">賠率監控清單</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
把等待開盤的條件與可檢查的候選集中管理。下注前逐一確認同一場、同一玩法、同一選項、賠率達標與注碼上限。
|
||||
</p>
|
||||
</div>
|
||||
{watchlist.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15] transition hover:border-[#b83822] hover:text-[#b83822]"
|
||||
onClick={handleClearWatchlist}
|
||||
>
|
||||
清空監控
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{watchlist.length ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{watchlist.map((item) => (
|
||||
<article key={pickKey(item)} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
|
||||
isConditionalPick(item) ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'
|
||||
}`}>
|
||||
{isConditionalPick(item) ? '等待賠率達標' : '實盤檢查'}
|
||||
</span>
|
||||
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7d2a15]">{recommendationText(item)}</span>
|
||||
<span className="rounded-full border border-[#d8b58c] bg-white px-3 py-1 text-xs font-bold text-[#5f4330]">
|
||||
{item.odds_source_label || sourceKindText(item)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-base font-black text-[#3f2f25]">{item.match_label}</p>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}|{item.selection}</p>
|
||||
<p className="mt-2 rounded-xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#7a5b46]">{sourceExplainText(item)}</p>
|
||||
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-4">
|
||||
<p>目標賠率 <span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||||
<p>模型勝率 <span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
<p>信心分數 <span className="font-bold text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : '-'}</span></p>
|
||||
<p>參考上限 <span className="font-bold text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-xs font-semibold text-[#7a5b46] transition hover:text-[#b83822]"
|
||||
onClick={() => handleRemoveWatchItem(item)}
|
||||
>
|
||||
移除這張
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-white/55 p-5 text-sm leading-6 text-[#7a5b46]">
|
||||
尚未加入監控。看到符合策略的預掛條件或實盤候選時,按下卡片底部按鈕即可加入;加入後會在這裡集中檢查。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{loading ? <p className="text-sm text-[#7a5b46] dot-matrix dot-matrix-loading">同步市場即時資料中...</p> : null}
|
||||
{error ? <p className="text-sm text-red-400">{error}</p> : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{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 ? (
|
||||
<p className="panel-glow rounded-2xl p-4 text-sm text-[#7a5b46]">目前此策略區塊沒有符合條件的建議。</p>
|
||||
<div className="panel-glow rounded-2xl p-6 text-center border-dashed">
|
||||
<p className="text-sm text-[#7a5b46]">
|
||||
{selectedMissingSnapshot
|
||||
? '此日期缺少賽前快照,因此不顯示事後補造的投注推薦。請切到下一個有候選的日期,或查看賽後校準。'
|
||||
: '這個分類目前沒有達標候選。沒有足夠資料或賠率不漂亮時,系統不會硬湊推薦。'}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">一鍵投注深度連結(Deep Linking)</h2>
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-sm text-[#7a5b46]">
|
||||
根據比賽與下注口袋,產生各大博彩公司可直接帶入投注單的快速連結,含追蹤碼(Affiliate)與
|
||||
即時金額提示。可直接跳轉至賠率已預選頁,減少下注落地時間。
|
||||
<div className="space-y-5">
|
||||
<section className="panel-glow rounded-2xl p-6">
|
||||
<p className="dot-matrix text-xs font-bold text-[#b83822]">授權功能 / 目前不開放一鍵帶入</p>
|
||||
<h2 className="mt-2 text-3xl font-black text-[#3f2f25]">一鍵帶入下注連結</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[#7a5b46]">
|
||||
這不是一般推薦頁,而是未來接合法投注平台時使用的操作工具。正式環境不放預設賽事、不放預設數字,也不把使用者導到未驗證連結。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
['目前狀態', '等待授權連結來源', '尚未接入合法且可驗證的一鍵帶入連結。'],
|
||||
['安全原則', '不預填假賠率', '必須同一場、同一玩法、同一選項、同一分線。'],
|
||||
['替代流程', '先手動核對卡片', '每日作戰室會告訴你最低賠率與參考上限。'],
|
||||
].map(([title, value, detail]) => (
|
||||
<article key={title} className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-xs font-bold text-[#8a6b58]">{title}</p>
|
||||
<p className="mt-2 text-lg font-black text-[#7d2a15]">{value}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">場次</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">{matchId}|{selection}</p>
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">現在請走這個流程</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
先到每日作戰室查看單關、跨場串關與同場串關;每張卡片會寫清楚下注方式、最低可接受賠率與新台幣參考上限。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<QuickBetButton bookmakerId="bet365" matchId={matchId} selection={selection} odds={1.92} />
|
||||
<QuickBetButton bookmakerId="pinnacle" matchId={matchId} selection={selection} odds={1.94} />
|
||||
<QuickBetButton bookmakerId="draftkings" matchId={matchId} selection={selection} odds={1.96} />
|
||||
<Link href="/daily-card" className="rounded-full bg-[#7d2a15] px-4 py-2 text-sm font-bold text-white">前往每日作戰室</Link>
|
||||
<Link href="/recommendation-readiness" className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15]">先看推薦就緒度</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { BetSizingSlider } from '@/components/BetSizingSlider';
|
||||
export default function KellyPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">凱利準則(Kelly Criterion)與下注分配</h2>
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">注碼風險控管與下注分配</h2>
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-sm text-[#7a5b46]">
|
||||
以「凱利準則」為核心,以市場期望值為主動態資金控管;系統同時支援分數凱利(0.25x、0.5x、0.75x)
|
||||
與風險容忍度調節,讓你不只知道該下、還知道「下多少」。
|
||||
</p>
|
||||
<p className="mt-3 text-xs leading-5 text-[#8a6b58]">
|
||||
此頁是資金配置計算器,不是獨立投注訊號。請先從每日作戰室取得模型勝率、最低可接受賠率與資料品質,再用本頁確認下注比例。
|
||||
</p>
|
||||
</section>
|
||||
<BetSizingSlider />
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
<PwaBootstrap />
|
||||
<div className="dashboard-shell min-h-screen p-4 md:p-8">
|
||||
<header className="mx-auto max-w-7xl rounded-2xl panel-glow px-5 py-4 md:px-8 md:py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="dot-matrix text-2xl md:text-3xl text-[#7e2417]">2026 World Cup Quantum Ops</h1>
|
||||
<p className="text-sm text-[#6d4d39]">台北時間(UTC+8) | 台灣賭盤實戰研究版</p>
|
||||
</div>
|
||||
<nav className="flex flex-wrap gap-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-full border border-[#b68a65]/70 bg-white/70 px-3 py-1.5 text-sm text-[#5f4330] transition hover:bg-white"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<HeaderNav />
|
||||
</header>
|
||||
<main className="mx-auto mt-6 mb-24 max-w-7xl">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,11 @@ export default function MatchConditionsPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">裁判與天候條件量化</h2>
|
||||
<section className="panel-glow rounded-2xl border-dashed p-4">
|
||||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||||
此頁是條件模型計算器。正式推薦必須由每日作戰室整合賽程、場館、盤口與資料品質後產生,這裡的手動輸入不會直接變成下注訊號。
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">條件輸入</h3>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -156,4 +161,3 @@ export default function MatchConditionsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MatchListItem[]> {
|
||||
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<MatchListItem[]> {
|
||||
|
||||
async function fetchMatchDetail(matchId: string): Promise<MatchDetail | null> {
|
||||
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<string> {
|
||||
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<Params> }):
|
||||
: `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<Params> }):
|
||||
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<Para
|
||||
const oddsRows = buildOddsChartRows(detail.odds_series);
|
||||
const scoreRows = buildScoreMatrixRows(detail.poisson.score_matrix);
|
||||
const kickoffLocal = formatToTaipeiTime(detail.match_time_utc, 'yyyy-MM-dd HH:mm:ss');
|
||||
const isFinished = detail.status === 'finished';
|
||||
const statusItem = {
|
||||
kickoff_utc: detail.match_time_utc,
|
||||
status: detail.status,
|
||||
home_score: (detail as { home_score?: number | null }).home_score ?? null,
|
||||
away_score: (detail as { away_score?: number | null }).away_score ?? null,
|
||||
};
|
||||
const statusLabel = matchStatusLabel(statusItem);
|
||||
const isFinished = matchStatusKind(statusItem) === 'finished';
|
||||
|
||||
const conditions: MatchConditionsReadout = detail.conditions;
|
||||
|
||||
@@ -274,11 +281,11 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
|
||||
開賽時間:{kickoffLocal}(台北)|場地:{detail.venue_name}
|
||||
{detail.venue_altitude_meters ? `(海拔 ${detail.venue_altitude_meters}m)` : null}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#8a6b58]">賽事狀態:{detail.status.toUpperCase()}</p>
|
||||
<p className="mt-1 text-xs text-[#8a6b58]">賽事狀態:{statusLabel}</p>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<article className="rounded-xl border border-[#e8cead] bg-white/70 p-3">
|
||||
<p className="text-xs text-[#7d4d39]">預估 xG</p>
|
||||
<p className="text-xs text-[#7d4d39]">預估進球</p>
|
||||
<p className="dot-matrix text-2xl text-[#7d2a15]">
|
||||
{detail.home_xg.toFixed(2)} : {detail.away_xg.toFixed(2)}
|
||||
</p>
|
||||
@@ -296,7 +303,7 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<OddsLineMovementChart data={oddsRows} />
|
||||
<OddsLineMovementChart data={oddsRows} teamName={detail.home_team} />
|
||||
</div>
|
||||
<MatchConditionsCard
|
||||
matchId={detail.match_id}
|
||||
@@ -305,7 +312,7 @@ export default async function MatchDetailPage({ params }: { params: Promise<Para
|
||||
cardsPressureAlert={conditions.cards_pressure_alert}
|
||||
secondHalfHomeAttack={conditions.second_half_home_attack}
|
||||
secondHalfAwayAttack={conditions.second_half_away_attack}
|
||||
second_half_under_recommendation={conditions.second_half_under_recommendation}
|
||||
secondHalfUnderRecommendation={conditions.second_half_under_recommendation}
|
||||
attackerDirection={conditions.attacker_direction}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -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<MatchListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <div className="p-8 text-[#8a6b58] dot-matrix">載入即時賽事中...</div>;
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export default function MatchesPage() {
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-[#7d2a15] dot-matrix">
|
||||
無法取得賽事資料。請確認後端已啟動且 THE_ODDS_API_KEY 已正確設定。
|
||||
{error || '目前沒有可顯示的賽事資料。'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,20 +52,32 @@ export default function MatchesPage() {
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">賽事中心</h2>
|
||||
<section className="grid gap-4">
|
||||
{matches.map((match) => (
|
||||
<article key={match.match_id} className="panel-glow rounded-2xl p-5">
|
||||
<Link
|
||||
key={match.match_id}
|
||||
href={`/matches/${encodeURIComponent(match.match_id)}`}
|
||||
className="panel-glow block rounded-2xl p-5 transition hover:-translate-y-0.5 hover:border-[#c58b63] hover:bg-[#fff8e6]"
|
||||
>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-lg font-semibold text-[#6b3f2d]">{match.home_team} vs {match.away_team}</p>
|
||||
<p className="text-sm text-[#8a6b58]">開賽:{formatToTaipeiTime(match.kickoff_utc)}</p>
|
||||
<p className="text-lg font-semibold text-[#6b3f2d]">{scoreLine(match)}</p>
|
||||
<span className="w-fit rounded-full bg-[#fff4df] px-3 py-1 text-xs font-semibold text-[#7d2a15] ring-1 ring-[#e8cead]">
|
||||
{matchStatusLabel(match)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[#8a6b58]">即時狀態:{match.status}</p>
|
||||
<p className="mt-3 text-sm text-[#8a6b58]">開賽:{formatToTaipeiTime(match.kickoff_utc)}</p>
|
||||
<p className="text-sm text-[#7c5340]">
|
||||
場地:{match.venue_name || '未定'} ({match.venue_city || '未定'})
|
||||
</p>
|
||||
</article>
|
||||
<p className="mt-3 text-xs font-semibold text-[#b83822]">查看單場詳情與量化資料 →</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<LiveMatchCenter timeline={liveTimeline} xgSeries={xgData} heatZones={zones} />
|
||||
<section className="panel-glow rounded-2xl border-dashed p-5">
|
||||
<h3 className="dot-matrix text-xl text-[#7d2a15]">即時事件與預期進球熱區資料</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
目前尚未接入可驗證的逐分鐘事件源,因此不再顯示假時間軸、假預期進球或假熱區。接入 Opta、FIFA 或授權事件源後,這裡才會開啟即時圖表。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ export default function MlEdgePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">ML Ensemble 量化預測(第 15 階段)</h2>
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">機器學習整合模型 量化預測(第 15 階段)</h2>
|
||||
<section className="panel-glow rounded-2xl border-dashed p-4">
|
||||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||||
此頁是模型推論工作台。未接入正式訓練集與即時盤口前,輸出只能作模型壓力測試,不會被標記為首頁投注推薦。
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">模型驅動參數</h3>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -143,7 +148,7 @@ export default function MlEdgePage() {
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
主隊近五場 xG
|
||||
主隊近五場預期進球
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
@@ -153,7 +158,7 @@ export default function MlEdgePage() {
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
客隊近五場 xG
|
||||
客隊近五場預期進球
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
@@ -201,7 +206,7 @@ export default function MlEdgePage() {
|
||||
onClick={runEdge}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '分析中…' : '執行 ML Edge 推論'}
|
||||
{loading ? '分析中…' : '執行 機器學習優勢 推論'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-[#d1432d] px-4 py-2 text-white"
|
||||
@@ -209,7 +214,7 @@ export default function MlEdgePage() {
|
||||
onClick={trainModel}
|
||||
disabled={trainLoading}
|
||||
>
|
||||
{trainLoading ? '訓練中…' : '快速訓練演示模型'}
|
||||
{trainLoading ? '訓練中…' : '快速訓練範例模型'}
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||||
@@ -223,25 +228,25 @@ export default function MlEdgePage() {
|
||||
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4">
|
||||
<p className="text-sm text-[#7a5b46]">模型機率</p>
|
||||
<p className="mt-1 text-sm text-[#5f4330]">主勝 {edgeResult.model_probs.home.toFixed(2)} / 和局 {edgeResult.model_probs.draw.toFixed(2)} / 客勝 {edgeResult.model_probs.away.toFixed(2)}</p>
|
||||
<p className="mt-3 text-xs text-[#6f4f3c]">模型大小:{edgeResult.is_fallback_model ? '規則回退' : 'ML Ensemble'}</p>
|
||||
<p className="mt-3 text-xs text-[#6f4f3c]">模型大小:{edgeResult.is_fallback_model ? '規則回退' : '機器學習整合模型'}</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4">
|
||||
<p className="text-sm text-[#7a5b46]">強烈偏差</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-[#b83822]">{edgeResult.strongest_outcome}</p>
|
||||
<p className="text-sm text-[#5f4330]">Edge:{edgeResult.strongest_edge_percent.toFixed(2)}%</p>
|
||||
<p className="mt-2 text-xs text-[#7a5b46]">是否 Strong Buy:{edgeResult.strong_buy ? '是' : '否'}</p>
|
||||
<p className="text-sm text-[#5f4330]">優勢:{edgeResult.strongest_edge_percent.toFixed(2)}%</p>
|
||||
<p className="mt-2 text-xs text-[#7a5b46]">是否 強烈候選:{edgeResult.strong_buy ? '是' : '否'}</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4 md:col-span-2">
|
||||
<p className="text-sm text-[#7a5b46]">各結果 Edge 估計</p>
|
||||
<p className="text-sm text-[#7a5b46]">各結果 優勢 估計</p>
|
||||
<ul className="mt-2 space-y-1 text-sm text-[#5f4330]">
|
||||
<li>主勝:{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? '(Strong Buy)' : ''}</li>
|
||||
<li>和局:{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? '(Strong Buy)' : ''}</li>
|
||||
<li>客勝:{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? '(Strong Buy)' : ''}</li>
|
||||
<li>主勝:{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? '(強烈候選)' : ''}</li>
|
||||
<li>和局:{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? '(強烈候選)' : ''}</li>
|
||||
<li>客勝:{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? '(強烈候選)' : ''}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[#7a5b46]">尚未提交分析,請先點擊「執行 ML Edge 推論」。</p>
|
||||
<p className="mt-3 text-sm text-[#7a5b46]">尚未提交分析,請先點擊「執行 機器學習優勢 推論」。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -259,4 +264,3 @@ export default function MlEdgePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">量化模型模組整合</h2>
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">量化工具箱</h2>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-sm text-[#7a5b46]">整合階段 1~14 的核心模型模組,涵蓋「賽前機率推估」「投注價值偵測」「下注控管」與「執行效率」。</p>
|
||||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||||
這裡是進階工具,不是第一入口。一般使用者先看首頁推薦與每日作戰室;需要檢查盤口、注碼、天候、模型或賽後績效時,再從這裡進入。
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{modules.map((module) => (
|
||||
<article key={module.href} className="rounded-xl border border-[#dbbea0] bg-white/70 p-4">
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">{module.label}</h3>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">{module.desc}</p>
|
||||
<p className="mt-2 text-xs text-[#9b5d3d]">路徑:{module.href}</p>
|
||||
</article>
|
||||
<Link key={module.href} href={module.href} className="rounded-xl border border-[#dbbea0] bg-white/70 p-4 transition hover:border-[#b83822] hover:bg-[#fff0e2]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">{module.label}</h3>
|
||||
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-xs font-bold text-[#8a6400]">{module.status}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{module.desc}</p>
|
||||
<p className="mt-3 text-xs font-bold text-[#b83822]">打開工具</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -30,15 +38,15 @@ export default function ModelsPage() {
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">泊松模型</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">根據進攻/防守強度計算主客隊 λ,推導大小球與波膽機率。</p>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">根據攻守強度估計雙方可能進幾球,再推估大小球與比分機率。</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">蒙地卡羅模擬</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">高保真隨機模擬賽前轉換、進球分布與讓球結果,不只給結果,也給穩健性區間。</p>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">把同一場比賽重複模擬很多次,看結果是否穩定,而不是只看單一次預測。</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">EV 檢測</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">計算理論機率與市場賠率差,輸出高價值投注候選與偏離警訊。</p>
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">是否值得下注</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">比較模型勝率與平台賠率,只有差距夠大、風險可控時才列為候選。</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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<MatchDetail | null>(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<string>();
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">跨平台賠率比較矩陣</h2>
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-sm text-[#7a5b46]">此頁面接入後端 odds API 與 Redis 快取後,可顯示即時最高賠率、套利空間與賠率走勢線。</p>
|
||||
<div className="mt-5 overflow-auto rounded-xl border border-[#d8b58c] bg-white/65">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#f3e3ca] text-left">
|
||||
<th className="px-3 py-2">場次</th>
|
||||
<th className="px-3 py-2">Bet365</th>
|
||||
<th className="px-3 py-2">Pinnacle</th>
|
||||
<th className="px-3 py-2">DraftKings</th>
|
||||
<th className="px-3 py-2">套利</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-3 py-2">德國 vs 西班牙</td>
|
||||
<td className="px-3 py-2">1.92</td>
|
||||
<td className="px-3 py-2">1.90</td>
|
||||
<td className="px-3 py-2">1.91</td>
|
||||
<td className="px-3 py-2 text-emerald-700">低</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<h2 className="dot-matrix text-2xl font-bold text-cyan-400">跨平台市場指數比較矩陣</h2>
|
||||
<section className="panel-glow rounded-2xl p-6">
|
||||
<p className="text-sm text-slate-300 mb-4">此模組匯聚全球頂級機構即時定價,透過動態比對尋找潛在無風險套利空間與定價偏差。</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-slate-400 dot-matrix dot-matrix-loading">同步即時定價庫中...</p>
|
||||
) : (
|
||||
<div className="overflow-auto rounded-xl border border-slate-700 bg-slate-900/50">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="bg-slate-800 text-slate-200">
|
||||
<th className="px-4 py-3 border-b border-slate-700">賽事</th>
|
||||
<th className="px-4 py-3 border-b border-slate-700">最新盤口 1</th>
|
||||
<th className="px-4 py-3 border-b border-slate-700">最新盤口 2</th>
|
||||
<th className="px-4 py-3 border-b border-slate-700">最新盤口 3</th>
|
||||
<th className="px-4 py-3 border-b border-slate-700">市場有效性</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{rows.map(({ match, detail }) => {
|
||||
const odds = latestBookOdds(detail);
|
||||
return (
|
||||
<tr key={match.match_id} className="hover:bg-slate-800/50 transition">
|
||||
<td className="px-4 py-3 text-slate-100">{match.home_team} vs {match.away_team}</td>
|
||||
<td className="px-4 py-3 text-cyan-400">{odds[0] ?? '等待盤口'}</td>
|
||||
<td className="px-4 py-3 text-cyan-400">{odds[1] ?? '等待盤口'}</td>
|
||||
<td className="px-4 py-3 text-cyan-400">{odds[2] ?? '等待盤口'}</td>
|
||||
<td className={odds.length ? 'px-4 py-3 text-emerald-400' : 'px-4 py-3 text-amber-300'}>
|
||||
{odds.length ? '已有可檢查盤口' : '等待資料源'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<OddsLineMovementChart data={samples} />
|
||||
{selectedMatch && chartData.length > 0 && (
|
||||
<OddsLineMovementChart data={chartData} teamName={selectedMatch.home_team} />
|
||||
)}
|
||||
{!loading && !rows.some((row) => row.detail?.odds_series.length) ? (
|
||||
<section className="panel-glow rounded-2xl border-dashed p-5 text-sm text-slate-400">
|
||||
目前沒有可驗證的即時賠率序列;系統不再以固定 1.90 類數字假裝跨平台比較。
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function OfflinePage() {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center p-4">
|
||||
<section className="max-w-xl w-full rounded-2xl border border-[#9b2e19] bg-[#1a100a] p-6 text-[#ffd5a8] shadow-2xl shadow-[#9b2e19]/35">
|
||||
<p className="dot-matrix text-2xl text-[#ffb67a]">OFFLINE: CONNECTION LOST</p>
|
||||
<p className="dot-matrix text-2xl text-[#ffb67a]">目前離線,正在保護最後資料</p>
|
||||
<h1 className="mt-2 text-3xl text-[#ffe2bd]">你已進入離線保護模式</h1>
|
||||
<p className="mt-3 text-sm text-[#f6c48f]">
|
||||
網路暫時中斷,已為你保留最後可用賠率快取,並暫時停用即時更新。
|
||||
@@ -63,7 +63,7 @@ export default function OfflinePage() {
|
||||
{snapshot.map((item) => (
|
||||
<li key={`${item.match_id}-${item.selection}`} className="text-sm">
|
||||
<span className="font-semibold text-[#ffd79b]">{item.home_team || item.match_id}</span>
|
||||
{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}` : ''}:
|
||||
<span className="font-semibold text-[#ff9f4c]"> {item.odds.toFixed(2)} </span>
|
||||
<span className="text-[#f4c89c]">({formatTs(item.captured_at)})</span>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<section className="panel-glow rounded-2xl p-6">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">當日最高 EV 投注建議</h2>
|
||||
<p className="mt-2 text-sm text-[#6d4d39]">所有時間欄位已統一轉為台北時間(UTC+8)。</p>
|
||||
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<DailyCardResponse | null>(null);
|
||||
const [tomorrowData, setTomorrowData] = useState<DailyCardResponse | null>(null);
|
||||
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
|
||||
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
|
||||
const [selectedRecommendationDate, setSelectedRecommendationDate] = useState('');
|
||||
const [matches, setMatches] = useState<MatchListItem[]>([]);
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [sourceHealth, setSourceHealth] = useState<SourceHealthResponse | null>(null);
|
||||
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
|
||||
const [feedbackMessage, setFeedbackMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<LoadErrors>({});
|
||||
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<string, DailyCardResponse> = {};
|
||||
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<string, MatchListItem[]>();
|
||||
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<DailyCardItem[]>(() => {
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="dot-matrix text-xs font-semibold text-[#b83822]">首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-black text-[#3f2f25] md:text-5xl">下一批可檢查投注候選</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
|
||||
一打開首頁先看這裡:系統會先看今天,若今天已無可下注賽事,就自動切到明天有資料的場次。若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。
|
||||
</p>
|
||||
<p className="mt-3 rounded-2xl border border-[#eadcb9] bg-white/75 px-4 py-3 text-sm font-bold text-[#5f4330]">
|
||||
目前主顯示日期:<span className="text-[#b83822]">{activeRecommendationDate}</span>
|
||||
。當日賽事 {selectedMatchCount} 場,推薦 {recommendationItems.length} 組。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-bold">
|
||||
<span className="rounded-full bg-[#e9f8ef] px-3 py-1 text-[#167a47]">可檢查 {liveRecommendationCount} 組</span>
|
||||
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-[#8a6400]">預掛監控 {watchOnlyRecommendationCount} 組</span>
|
||||
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-[#b83822]">60 秒刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
|
||||
{dateSummaries.map((item) => {
|
||||
const pinnedLabel = item.date === dateWindow.today ? '今天' : item.date === dateWindow.tomorrow ? '明天' : '';
|
||||
return (
|
||||
<button
|
||||
key={item.date}
|
||||
type="button"
|
||||
onClick={() => setSelectedRecommendationDate(item.date)}
|
||||
className={`rounded-full border px-3 py-1.5 text-left transition ${
|
||||
activeRecommendationDate === item.date
|
||||
? 'border-[#7d2a15] bg-[#7d2a15] text-white'
|
||||
: 'border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822]'
|
||||
}`}
|
||||
>
|
||||
{item.date}{pinnedLabel ? ` ${pinnedLabel}` : ''}|{item.matchCount} 場|{item.recommendationCount} 推
|
||||
{item.liveCount > 0 ? `|${item.liveCount} 可檢查` : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
|
||||
{categorySummary.map((item) => (
|
||||
<span key={item.label} className="rounded-full border border-[#d8b58c] bg-white/75 px-3 py-1 text-[#5f4330]">
|
||||
{item.label} {item.value} 組
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{feedbackMessage ? (
|
||||
<p role="status" className="mt-4 rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
|
||||
{feedbackMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="mt-5 rounded-3xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p className="dot-matrix text-xs font-bold text-[#b83822]">我的賠率監控清單</p>
|
||||
<h3 className="mt-1 text-xl font-black text-[#3f2f25]">
|
||||
{watchlist.length ? `已追蹤 ${watchlist.length} 組候選` : '還沒有加入候選'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
|
||||
加入後不是立刻下注,而是集中檢查來源、最低賠率、玩法與注碼上限。台灣盤參考價與模型推算門檻會分開標示。
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-[#fff8e6] px-4 py-2 text-sm font-black text-[#7d2a15] transition hover:border-[#b83822]">
|
||||
打開完整檢查清單
|
||||
</Link>
|
||||
</div>
|
||||
{watchlist.length ? (
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{watchlist.slice(0, 4).map((item) => {
|
||||
const conditional = isConditionalPick(item) || !item.has_market_odds;
|
||||
return (
|
||||
<article key={`watch-${pickKey(item)}`} className="rounded-2xl border border-[#e7c89b] bg-[#fff8e6]/90 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] font-black ${conditional ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'}`}>
|
||||
{conditional ? '先監控' : '可檢查'}
|
||||
</span>
|
||||
{item.odds_source_label ? (
|
||||
<span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-black text-[#3f2f25]">{item.match_label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.market_type}|{item.selection}</p>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-[#7a5b46]">
|
||||
<p>門檻<br /><b className="text-[#b83822]">{item.target_odds.toFixed(2)}</b></p>
|
||||
<p>信心<br /><b className="text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : item.win_prob.toFixed(1)}</b></p>
|
||||
<p>上限<br /><b className="text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</b></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 rounded-full border border-[#d8b58c] bg-white px-3 py-1.5 text-xs font-black text-[#7d2a15] transition hover:border-[#b83822]"
|
||||
onClick={() => handleRemoveTracked(item)}
|
||||
>
|
||||
移除這筆
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-[#fff8e6]/80 p-4 text-sm leading-6 text-[#7a5b46]">
|
||||
看到候選卡片時,先按「加入賠率監控」或「加入下注前檢查」。清單會保留在此瀏覽器中,方便你逐筆核對,不用重新找。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<p className="mt-6 dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">正在同步日期賽事與多玩法推薦...</p>
|
||||
) : topPicks.length ? (
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
{topPicks.slice(0, 9).map((item) => {
|
||||
const conditional = isConditionalPick(item) || !item.has_market_odds;
|
||||
const inList = watchlistKeys.has(pickKey(item));
|
||||
return (
|
||||
<article key={`hero-${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-white/80 p-5 shadow-[0_14px_36px_rgba(125,42,21,0.10)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
|
||||
<span className={`rounded-full border px-2 py-1 text-[11px] font-black ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
|
||||
{conditional ? '預掛觀察' : '下注前檢查'}
|
||||
</span>
|
||||
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-[#e9f8ef] px-2 py-1 text-[11px] font-black text-[#167a47]">信心分數 {item.confidence_score.toFixed(1)}</span> : null}
|
||||
{item.odds_source_label ? <span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-black text-[#3f2f25]">{item.match_label}</h3>
|
||||
<p className="mt-1 text-xs font-bold text-[#7a5b46]">台北日期:{activeDateLabel}</p>
|
||||
<p className="mt-2 text-sm font-bold text-[#b83822]">{item.market_type}|{item.selection}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。</p>
|
||||
<div className="mt-4 grid gap-2 text-sm text-[#5f4330]">
|
||||
<p>最低可接受賠率:<span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||||
<p>模型勝率:<span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
<p>參考上限:<span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||||
</div>
|
||||
<p className="mt-3 rounded-2xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#6f4f3c]">
|
||||
{nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={inList}
|
||||
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
|
||||
onClick={() => handleTrack(item)}
|
||||
>
|
||||
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
|
||||
</button>
|
||||
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
|
||||
完整分析
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-[#d8b58c] bg-white/65 p-5">
|
||||
<p className="text-sm leading-7 text-[#6f4f3c]">目前沒有通過模型勝率、最低賠率、資料新鮮度與倉位上限的候選。專業推薦不是硬湊數量,沒有優勢就保留資金,等下一輪資料刷新。</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{sample.map((item) => (
|
||||
<article key={item.match} className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="text-lg font-semibold text-[#7e3b1c]">{item.match}</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">推薦模型:{item.model}</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[#b83822]">EV {item.ev}</p>
|
||||
<p className="mt-2 text-sm text-[#6f4f3c]">當前賠率:{item.odds}</p>
|
||||
<p className="mt-1 text-xs text-[#8a6b58]">更新時間:{item.updatedAt}</p>
|
||||
<section className="relative overflow-hidden rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
|
||||
<div className="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_center,rgba(209,67,45,0.16),transparent_55%)]" />
|
||||
<div className="relative grid gap-8 lg:grid-cols-[1.08fr_0.92fr]">
|
||||
<div>
|
||||
<p className="dot-matrix text-xs font-semibold text-[#b83822]">台北即時投注研究台 / {activeDateState} / {activeRecommendationDate}</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight text-[#3f2f25] md:text-6xl">
|
||||
世界盃下注推薦主控台
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-base leading-7 text-[#6f4f3c]">
|
||||
首頁先顯示可研究的投注候選,再判斷能不能進場。{activeDateSummaryText} 資料不足時只開監控,不用漂亮空殼假裝高勝率。
|
||||
</p>
|
||||
<div className={`mt-6 rounded-3xl border p-5 ${gateClass(gate.tone)}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-black">{gate.label}</span>
|
||||
<span className="text-xs font-semibold">{loadedAt || '同步中'}</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-2xl font-black">{gate.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6">{gate.detail}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-bold">
|
||||
<span className="rounded-full bg-white/65 px-3 py-1">正式盤口:{sourceHealth?.odds_coverage_status === 'full_market' ? '已具備' : '不足'}</span>
|
||||
<span className="rounded-full bg-white/65 px-3 py-1">逾時賽果:{sourceHealth?.stale_unsettled_matches ?? 0} 場</span>
|
||||
<span className="rounded-full bg-white/65 px-3 py-1">目前日期:{activeDateLabel}</span>
|
||||
<span className="rounded-full bg-white/65 px-3 py-1">監控清單:{watchlist.length} 筆</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/daily-card" className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]">
|
||||
{gate.primaryAction}
|
||||
</Link>
|
||||
<Link href="/market-coverage" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
|
||||
檢查盤口覆蓋
|
||||
</Link>
|
||||
<Link href="/recommendation-readiness" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#1a9a57] hover:text-[#1a9a57]">
|
||||
推薦就緒度
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-[#e7c89b] bg-white/75 p-5 shadow-inner">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]">專業檢查順序</p>
|
||||
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7a5b46]">60 秒刷新</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{[
|
||||
['1', '先看狀態', '若是只監控或資料延遲,就不要直接下注。'],
|
||||
['2', '確認玩法', '單關、串關、同場串關要分開看,不混成同一種推薦。'],
|
||||
['3', '比對賠率', '平台賠率低於最低門檻就跳過,不能硬追。'],
|
||||
['4', '控制注碼', '只用卡片建議單位,尤其高賠與同場串關要小注。'],
|
||||
].map(([step, title, detail]) => (
|
||||
<article key={step} className="rounded-2xl border border-[#eadcb9] bg-[#fff8e6]/80 p-4">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{step}</span>
|
||||
<div>
|
||||
<p className="font-bold text-[#5f4031]">{title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<article key={item.label} className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{item.helper}</p>
|
||||
<p className="mt-3 text-3xl font-black text-[#7d2a15]">{item.value}</p>
|
||||
<p className="mt-1 text-sm text-[#6f4f3c]">{item.label}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl panel-glow p-6">
|
||||
<h2 className="dot-matrix text-xl text-[#7d2a15]">立即進入專業模組</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href="/matches" className="rounded-full border bg-white/80 px-4 py-2">賽事中心</Link>
|
||||
<Link href="/odds" className="rounded-full border bg-white/80 px-4 py-2">賠率矩陣</Link>
|
||||
<Link href="/sharp-money" className="rounded-full border bg-white/80 px-4 py-2">聰明錢</Link>
|
||||
<Link href="/models" className="rounded-full border bg-white/80 px-4 py-2">模型頁</Link>
|
||||
<Link href="/ml-edge" className="rounded-full border bg-white/80 px-4 py-2">ML Ensemble</Link>
|
||||
<Link href="/match-conditions" className="rounded-full border bg-white/80 px-4 py-2">環境與裁判</Link>
|
||||
<Link href="/rlm" className="rounded-full border bg-white/80 px-4 py-2">RLM 雷達</Link>
|
||||
<Link href="/proof-of-yield" className="rounded-full border bg-white/80 px-4 py-2">公開獲利帳本</Link>
|
||||
<Link href="/props" className="rounded-full border bg-white/80 px-4 py-2">球員道具盤</Link>
|
||||
<Link href="/kelly" className="rounded-full border bg-white/80 px-4 py-2">凱利下注</Link>
|
||||
<Link href="/backtesting" className="rounded-full border bg-white/80 px-4 py-2">回測引擎</Link>
|
||||
<Link href="/deep-bet" className="rounded-full border bg-white/80 px-4 py-2">一鍵下注</Link>
|
||||
<Link href="/portfolio" className="rounded-full border bg-white/80 px-4 py-2">投資組合</Link>
|
||||
<section className="grid gap-6 lg:grid-cols-[1.38fr_0.62fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="dot-matrix text-xs text-[#b83822]">依日期優先檢查 / {activeDateState}</p>
|
||||
<h2 className="text-2xl font-black text-[#3f2f25]">{activeDateLabel} 多玩法候選清單</h2>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">{activeDateSummaryText} 詳細下注方式與白話分析請進每日作戰室逐張檢查。</p>
|
||||
</div>
|
||||
<Link href="/daily-card" className="text-sm font-semibold text-[#b83822] hover:text-[#7d2a15]">看完整作戰室</Link>
|
||||
</div>
|
||||
|
||||
{feedbackMessage ? (
|
||||
<p role="status" className="rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
|
||||
{feedbackMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">同步市場即時資料中...</p>
|
||||
) : topPicks.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{topPicks.map((item) => {
|
||||
const conditional = isConditionalPick(item) || !item.has_market_odds;
|
||||
const inList = watchlistKeys.has(pickKey(item));
|
||||
return (
|
||||
<article key={`${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.10)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
|
||||
<span className={`rounded-full border px-2 py-1 text-[11px] font-bold ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
|
||||
{conditional ? '先監控' : '可檢查'}
|
||||
</span>
|
||||
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#167a47]">信心分數 {item.confidence_score.toFixed(1)}</span> : null}
|
||||
<span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7a5b46]">{activeDateLabel}</span>
|
||||
{item.confidence_band ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7d2a15]">{item.confidence_band}</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type} | {item.selection}</p>
|
||||
<div className="mt-4 grid gap-2 text-sm text-[#5f4330] md:grid-cols-3">
|
||||
<p>模型勝率 <span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
<p>最低賠率 <span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||||
<p>參考上限 <span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||||
</div>
|
||||
<p className="mt-3 rounded-2xl bg-white/70 p-3 text-sm leading-6 text-[#6f4f3c]">
|
||||
{nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={inList}
|
||||
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
|
||||
onClick={() => handleTrack(item)}
|
||||
>
|
||||
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
|
||||
</button>
|
||||
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white/70 px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
|
||||
看完整分析
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel-glow rounded-2xl border-dashed p-6">
|
||||
<p className="text-sm leading-7 text-[#6f4f3c]">目前沒有通過勝率、期望值、資料新鮮度與注碼上限的候選。沒有優勢就不硬推單,這才是專業紀律。</p>
|
||||
<p className="mt-2 text-xs text-[#8a6b58]">{errors.daily ?? selectedCard?.summary ?? '等待下一次資料刷新。'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]">資料源健康</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{healthItems.map((item) => (
|
||||
<article key={item.label} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`status-led ${item.ok ? 'status-led-ok' : 'status-led-warn'}`} />
|
||||
<p className="text-sm font-bold text-[#3f2f25]">{item.label}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]">近期賽程</h3>
|
||||
<Link href="/schedule" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]">完整賽程</Link>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nextMatches.length ? nextMatches.map((match) => (
|
||||
<Link key={match.match_id} href={`/matches/${match.match_id}`} className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
|
||||
<p className="text-sm font-bold text-[#3f2f25]">{match.home_team} vs {match.away_team}</p>
|
||||
<p className="mt-1 text-xs text-[#7a5b46]">{formatToTaipeiTime(match.kickoff_utc, 'MM/dd HH:mm')} | {match.status} | {match.venue_city || '場地待定'}</p>
|
||||
</Link>
|
||||
)) : (
|
||||
<p className="text-sm text-[#7a5b46]">{errors.matches ?? '尚無可顯示賽程。'}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]">新聞脈搏</h3>
|
||||
<Link href="/news" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]">新聞頁</Link>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{news.slice(0, 5).map((item) => (
|
||||
<a key={item.url} href={item.url} target="_blank" rel="noreferrer" className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
|
||||
<p className="text-sm font-semibold leading-5 text-[#3f2f25]">{item.title}</p>
|
||||
<p className="mt-2 text-xs text-[#7a5b46]">{item.source} | {item.publishedAt ? formatToTaipeiTime(item.publishedAt, 'MM/dd HH:mm') : '時間待定'}</p>
|
||||
</a>
|
||||
))}
|
||||
{!news.length ? <p className="text-sm text-[#7a5b46]">{errors.news ?? '尚無新聞資料。'}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-6">
|
||||
<h2 className="dot-matrix text-xl text-[#7d2a15]">核心頁面入口</h2>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">首頁不再把所有工具一次攤開;這裡只保留主流程,其他進階工具放在上方工具箱。</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{moduleLinks.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822] hover:bg-[#fff0e2]">
|
||||
<p className="font-bold text-[#3f2f25]">{item.label}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function PaywallPage() {
|
||||
return (
|
||||
<div className="mx-auto mt-10 max-w-xl">
|
||||
<section className="panel-glow rounded-2xl p-8 text-center">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">升級專業會員</h2>
|
||||
<p className="mt-3 text-sm text-[#6d4d39]">此區域為 PRO 會員專屬,提供量化高級模型與進階交易信號。</p>
|
||||
<button type="button" className="mt-6 rounded-full bg-[#b83822] px-6 py-2 text-white">立即升級</button>
|
||||
<div className="mx-auto mt-10 max-w-3xl space-y-5">
|
||||
<section className="panel-glow rounded-2xl p-8">
|
||||
<p className="dot-matrix text-xs font-bold text-[#b83822]">會員方案 / 尚未開放收費</p>
|
||||
<h2 className="mt-2 text-3xl font-black text-[#3f2f25]">專業會員訂閱方案</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[#6d4d39]">
|
||||
目前正式站先以資料品質、推薦透明度與賽後校準為優先,尚未開放付費訂閱。未來若開放,會清楚列出功能、資料來源與限制,不會用未驗證績效做銷售包裝。
|
||||
</p>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||
{[
|
||||
['目前可用', '首頁推薦、每日作戰室、賽程比分、AI 成本監控'],
|
||||
['暫不收費', '等待真實績效帳本與資料源穩定後才評估'],
|
||||
['付費前提', '每筆推薦可追蹤、可校準、可回看命中率'],
|
||||
].map(([title, detail]) => (
|
||||
<article key={title} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
|
||||
<p className="font-black text-[#7d2a15]">{title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/daily-card" className="rounded-full bg-[#b83822] px-5 py-2 text-sm font-bold text-white">先看今日推薦</Link>
|
||||
<Link href="/proof-of-yield" className="rounded-full border border-[#d8b58c] bg-white/75 px-5 py-2 text-sm font-bold text-[#7d2a15]">查看公開收益</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<PortfolioLeaksResponse>(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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -129,7 +123,7 @@ export default function PortfolioPage() {
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">貼上注單明細進行弱點診斷</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">
|
||||
欄位建議包含:市場、單/串關、建議賠率、賠率、注碼、是否已結算與結果,系統將輸出各類別 ROI/CLV 及危險盲點。
|
||||
欄位建議包含:市場、單/串關、建議賠率、賠率、注碼、是否已結算與結果。此頁不再預載範例績效,只有你貼入的真實注單才會進入弱點診斷。
|
||||
</p>
|
||||
<form className="mt-3" onSubmit={onSubmit}>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
@@ -153,7 +147,7 @@ export default function PortfolioPage() {
|
||||
className="rounded-lg bg-white/90 px-4 py-2 text-sm text-[#7d2a15]"
|
||||
onClick={() => setRawBetsText(JSON.stringify(DEFAULT_BETS, null, 2))}
|
||||
>
|
||||
恢復範例
|
||||
載入匿名格式範例
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -50,14 +50,14 @@ export default function ProofOfYieldPage() {
|
||||
{/* 標題與核心指標 */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-stone-900 uppercase tracking-widest border-b-2 border-quant-orange pb-2 inline-block">
|
||||
Proof of Yield <span className="text-stone-400 text-lg ml-2">公開獲利驗證</span>
|
||||
公開收益驗證 <span className="text-stone-400 text-lg ml-2">公開獲利驗證</span>
|
||||
</h1>
|
||||
<p className="text-stone-500 mt-2">所有預測皆為賽前發布,由區塊鏈/不可竄改日誌驗證,100% 透明。</p>
|
||||
<p className="text-stone-500 mt-2">只呈現已寫入帳本且已結算的推薦,不用空曲線或示意績效包裝尚未發生的獲利。</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<div className="bg-white p-6 rounded-lg border border-stone-200 shadow-sm">
|
||||
<p className="text-sm text-stone-500 uppercase font-semibold">Total ROI (累積報酬率)</p>
|
||||
<p className="text-sm text-stone-500 font-semibold">累積報酬率</p>
|
||||
<p className={`text-4xl font-dotmatrix mt-2 ${summary.roi_percent >= 0 ? 'text-quant-orange' : 'text-red-500'}`}>
|
||||
{summary.roi_percent > 0 ? '+' : ''}{summary.roi_percent.toFixed(2)}%
|
||||
</p>
|
||||
@@ -68,7 +68,7 @@ export default function ProofOfYieldPage() {
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg border border-stone-200 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute right-0 top-0 w-2 h-full bg-quant-red"></div>
|
||||
<p className="text-sm text-stone-500 uppercase font-semibold">Avg CLV (平均收盤線價值)</p>
|
||||
<p className="text-sm text-stone-500 font-semibold">平均收盤價差</p>
|
||||
<p className={`text-4xl font-dotmatrix mt-2 ${summary.avg_clv_percent >= 0 ? 'text-quant-red' : 'text-red-500'}`}>
|
||||
{summary.avg_clv_percent > 0 ? '+' : ''}{summary.avg_clv_percent.toFixed(2)}%
|
||||
</p>
|
||||
@@ -78,9 +78,14 @@ export default function ProofOfYieldPage() {
|
||||
|
||||
{/* 資金成長曲線 */}
|
||||
<div className="bg-white p-6 rounded-lg border border-stone-200 shadow-sm mb-10 h-96">
|
||||
<h3 className="text-lg font-bold text-stone-800 mb-6">Equity Curve (資產成長曲線)</h3>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={equityData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<h3 className="text-lg font-bold text-stone-800 mb-6">資產成長曲線</h3>
|
||||
{records.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-stone-500">
|
||||
尚無真實結算資料,因此不顯示模擬收益曲線。
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={equityData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorEquity" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ea580c" stopOpacity={0.4}/>
|
||||
@@ -93,7 +98,8 @@ export default function ProofOfYieldPage() {
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1A1A1A', borderColor: '#ea580c', color: '#FAF6F0', fontFamily: '"DotGothic16", monospace' }} />
|
||||
<Area type="monotone" dataKey="equity" stroke="#ea580c" strokeWidth={3} fillOpacity={1} fill="url(#colorEquity)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 歷史注單明細表 */}
|
||||
@@ -101,11 +107,11 @@ export default function ProofOfYieldPage() {
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-stone-100 text-stone-600 text-sm uppercase tracking-wider">
|
||||
<th className="p-4 font-semibold">Date</th>
|
||||
<th className="p-4 font-semibold">Match / Selection</th>
|
||||
<th className="p-4 font-semibold">CLV</th>
|
||||
<th className="p-4 font-semibold">Result</th>
|
||||
<th className="p-4 font-semibold">Profit/Loss</th>
|
||||
<th className="p-4 font-semibold">日期</th>
|
||||
<th className="p-4 font-semibold">賽事 / 選項</th>
|
||||
<th className="p-4 font-semibold">收盤價差</th>
|
||||
<th className="p-4 font-semibold">結果</th>
|
||||
<th className="p-4 font-semibold">損益</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-200 font-dotmatrix text-lg">
|
||||
@@ -118,7 +124,7 @@ export default function ProofOfYieldPage() {
|
||||
<td className="p-4 text-stone-500">{bet.settled_at.split('T')[0]}</td>
|
||||
<td className="p-4 text-stone-800 font-sans font-medium">{bet.match_id} <span className="text-stone-400 mx-2">|</span> <span className="text-quant-orange">{bet.selection}</span></td>
|
||||
<td className="p-4 text-quant-red">{bet.clv_percent !== null ? `${bet.clv_percent.toFixed(2)}%` : '-'}</td>
|
||||
<td className={`p-4 font-bold ${bet.is_win ? 'text-green-600' : 'text-stone-400'}`}>{bet.is_win ? 'WIN' : 'LOSS'}</td>
|
||||
<td className={`p-4 font-bold ${bet.is_win ? 'text-green-600' : 'text-stone-400'}`}>{bet.is_win ? '命中' : '未中'}</td>
|
||||
<td className={`p-4 ${bet.pnl > 0 ? 'text-green-600' : 'text-red-500'}`}>{bet.pnl > 0 ? '+' : ''}{bet.pnl.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -29,7 +29,7 @@ const metricLabelMap: Record<Metric, string> = {
|
||||
passes: '傳球',
|
||||
};
|
||||
|
||||
export default function PropsPage() {
|
||||
export default function PlayerPropsPage() {
|
||||
const [playerName, setPlayerName] = useState('Kylian Mbappé');
|
||||
const [opponentName, setOpponentName] = useState('墨西哥');
|
||||
const [metric, setMetric] = useState<Metric>('shots');
|
||||
@@ -132,7 +132,12 @@ export default function PropsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">球員道具盤(Player Props)專業模組</h2>
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">球員道具盤(球員道具盤)專業模組</h2>
|
||||
<section className="panel-glow rounded-2xl border-dashed p-4">
|
||||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||||
目前此頁是手動參數模擬器,不是即時球員道具盤報價。只有當球員出賽時間、先發、盤口與授權道具盤賠率接入後,才會標示為正式推薦。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">道具盤參數</h3>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">反向盤口移動(RLM)追蹤</h2>
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">反向盤口移動追蹤</h2>
|
||||
<section className="panel-glow rounded-2xl border-dashed p-4">
|
||||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||||
此頁只在同時有資金流與開收盤賠率時才會產生警示;預設欄位只是偵測器參數,不代表正式賽事訊號。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">偵測條件</h3>
|
||||
@@ -94,15 +99,22 @@ export default function RlmPage() {
|
||||
onClick={runRlm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '偵測中…' : '執行 RLM 偵測'}
|
||||
{loading ? '偵測中…' : '執行反向盤口偵測'}
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||||
</section>
|
||||
|
||||
<RLMRadarBoard
|
||||
matchId={matchId}
|
||||
alerts={result?.alerts || []}
|
||||
alerts={(result?.alerts || []).map((a) => ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<p className="text-sm text-[#7a5b46]">
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import { MoneyFlowBar } from '@/components/MoneyFlowBar';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function SharpMoneyPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">聰明錢流向監控</h2>
|
||||
<div className="space-y-5">
|
||||
<section className="panel-glow rounded-2xl p-6">
|
||||
<p className="dot-matrix text-xs font-bold text-[#b83822]">進階資料頁 / 尚未接入正式資金流</p>
|
||||
<h2 className="mt-2 text-3xl font-black text-[#3f2f25]">資金流向追蹤</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[#7a5b46]">
|
||||
這頁未來會顯示「投注人數比例」與「投注金額比例」的差異,用來判斷市場是否出現大額資金偏向。正式環境不得用示意賽事冒充資金流,因此在授權資料源接入前,只保留狀態說明與下一步。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
['目前狀態', '等待可驗證資金流資料源', '不顯示假票數、不顯示假資金比例。'],
|
||||
['現在該看', '每日作戰室與盤口覆蓋', '先以賠率門檻、資料新鮮度與注碼上限決策。'],
|
||||
['接入後會顯示', '票數、金額、異常移動時間', '只有同一場、同一玩法、同一選項才會比較。'],
|
||||
].map(([title, value, detail]) => (
|
||||
<article key={title} className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-xs font-bold text-[#8a6b58]">{title}</p>
|
||||
<p className="mt-2 text-lg font-black text-[#7d2a15]">{value}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-sm text-[#7a5b46]">示意:當投注筆數與投注金額走向反向時,提示 Sharp Money 可能存在。</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<MoneyFlowBar label="德國 vs 西班牙" ticketPct={73} handlePct={35} />
|
||||
<MoneyFlowBar label="巴西 vs 法國" ticketPct={42} handlePct={72} />
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">下一步</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
若要下注前檢查,先到每日作戰室加入監控清單;若要確認目前玩法是否有足夠盤口,先看盤口覆蓋。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href="/daily-card" className="rounded-full bg-[#7d2a15] px-4 py-2 text-sm font-bold text-white">前往每日作戰室</Link>
|
||||
<Link href="/market-coverage" className="rounded-full border border-[#d8b58c] bg-white/75 px-4 py-2 text-sm font-bold text-[#7d2a15]">檢查盤口覆蓋</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<article className={`group relative overflow-hidden rounded-2xl border border-[#dfc091] bg-[#fff8e6] p-4 transition ${className}`}>
|
||||
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]">{item.recommendation}</p>
|
||||
<h3 className="mt-1 text-lg text-[#6b3f2d]">{item.match_label}</h3>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}</p>
|
||||
<p className="mt-1 text-sm text-[#7d2a15]">選項:{item.selection}</p>
|
||||
<div className="mt-2 grid gap-1 text-sm text-[#6d4d39]">
|
||||
<p>目標賠率:<span className="font-semibold text-[#8c2f2f]">{item.target_odds.toFixed(2)}</span></p>
|
||||
<p>估計勝率:<span className="font-semibold text-[#7d2a15]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
<p>EV:<span className="font-semibold text-[#7d2a15]">{item.ev_percent.toFixed(2)}%</span></p>
|
||||
<p>建議籌碼:<span className="font-semibold text-[#7d2a15]">{item.stake_units.toFixed(2)} Units</span></p>
|
||||
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 (
|
||||
<article className={`group relative overflow-hidden rounded-2xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.12)] transition hover:border-[#d1432d] ${className}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="dot-matrix text-xs font-semibold tracking-wider text-[#b83822]">{recommendationLabel(item)}</p>
|
||||
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">{riskLabel(item.risk_level)}</span>
|
||||
<span className={`rounded-full border px-2 py-1 text-[11px] font-semibold ${
|
||||
isConditional
|
||||
? 'border-[#b88700]/40 bg-[#fff7d6] text-[#8a6400]'
|
||||
: referenceMarket
|
||||
? 'border-[#d8b58c] bg-[#fff0e2] text-[#7d2a15]'
|
||||
: 'border-[#1a9a57]/30 bg-[#e9f8ef] text-[#167a47]'
|
||||
}`}>
|
||||
{executionStatusLabel(item)}
|
||||
</span>
|
||||
{item.odds_source_label ? (
|
||||
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#5f4330]">
|
||||
{item.odds_source_label}
|
||||
</span>
|
||||
) : null}
|
||||
{confidence !== null ? (
|
||||
<span className="rounded-full border border-[#1a9a57]/30 bg-[#e9f8ef] px-2 py-1 text-[11px] font-semibold text-[#167a47]">
|
||||
信心 {confidence.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
{item.confidence_band ? (
|
||||
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">
|
||||
{item.confidence_band}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[#6a4f3a]">{item.rationale}</p>
|
||||
|
||||
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">投注市場:{item.market_type}</p>
|
||||
<p className="mt-1 text-base font-bold text-[#b83822]">投注選項:{item.selection}</p>
|
||||
|
||||
<section className={`mt-4 rounded-2xl border p-4 ${
|
||||
isConditional ? 'border-[#e7c462] bg-[#fff7d6]' : 'border-[#9bd8b0] bg-[#e9f8ef]'
|
||||
}`}>
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">推薦狀態</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{executionStatusDetail(item)}</p>
|
||||
<p className="mt-2 text-xs font-bold text-[#7a5b46]">{dataQualityLabel(item)}</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">下注方式</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{betMode(item)}</p>
|
||||
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
|
||||
<p>市場:<span className="font-bold text-[#3f2f25]">{item.market_type}</span></p>
|
||||
<p>選項:<span className="font-bold text-[#3f2f25]">{item.selection}</span></p>
|
||||
<p>最低可接受賠率:<span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||||
<p>建議上限:<span className="font-bold text-[#167a47]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||||
</div>
|
||||
<p className="mt-3 rounded-xl bg-[#fff0e2] p-3 text-sm leading-6 text-[#7d2a15]">{oddsRule(item)}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">{stakeGuide(item)}</p>
|
||||
</section>
|
||||
|
||||
{isCombo ? (
|
||||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">串關組成</p>
|
||||
<div className="mt-2 space-y-2 text-sm text-[#5f4330]">
|
||||
{item.legs?.map((leg, index) => (
|
||||
<p key={`${leg.match_id}-${leg.selection}`}>
|
||||
第 {index + 1} 腿:<span className="font-bold text-[#3f2f25]">{leg.selection}</span>,賠率至少 {leg.odds.toFixed(2)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">白話分析</p>
|
||||
<p className="mt-2 text-sm leading-7 text-[#5f4330]">{plainLanguage(item)}</p>
|
||||
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
|
||||
<p>模型勝率:<span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||||
{typeof item.market_implied_prob === 'number' ? (
|
||||
<p>{isConditional ? '賠率門檻換算機率' : '市場估計機率'}:<span className="font-bold text-[#3f2f25]">{item.market_implied_prob.toFixed(2)}%</span></p>
|
||||
) : null}
|
||||
{typeof item.edge_percent === 'number' ? <p>模型優勢:<span className="font-bold text-[#b88700]">{item.edge_percent.toFixed(2)} 百分點</span></p> : null}
|
||||
<p>預期期望值:<span className="font-bold text-[#167a47]">{item.ev_percent.toFixed(2)}%</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{confidenceFactors.length || checks.length ? (
|
||||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">信心與計算依據</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{confidenceFactors.slice(0, 5).map((factor) => (
|
||||
<span key={factor} className="rounded-full bg-[#e9f8ef] px-3 py-1 text-xs font-semibold text-[#167a47]">
|
||||
{plainCheckLabel(factor)}
|
||||
</span>
|
||||
))}
|
||||
{checks.slice(0, 6).map((check) => (
|
||||
<span key={check} className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs font-semibold text-[#7a5b46]">
|
||||
{plainCheckLabel(check)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-4 rounded-2xl border border-[#d8b58c] bg-[#fff0e2] p-4">
|
||||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">風險提醒</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
這是研究候選,不是結果承諾。若賠率低於最低可接受賠率、先發名單異常、傷停新聞改變、或資料源延遲,就不要下注,等待下一次刷新。
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||||
下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議單位。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 inline-block rounded-full bg-[#7d2a15] px-4 py-2 text-sm text-white opacity-0 transition hover:bg-[#5f1f11] group-hover:opacity-100"
|
||||
aria-pressed={isInSlip}
|
||||
className={`mt-4 inline-block w-full rounded-full px-4 py-3 text-sm font-black text-white transition ${
|
||||
isInSlip ? 'bg-[#167a47] hover:bg-[#11613a]' : 'bg-[#d1432d] hover:bg-[#b83822]'
|
||||
}`}
|
||||
onClick={() => onAddToSlip(item)}
|
||||
>
|
||||
Add to Slip
|
||||
{actionLabel}
|
||||
</button>
|
||||
|
||||
<div className="absolute right-[-24px] top-1/2 h-24 w-2/5 -translate-y-1/2 rotate-6 rounded-full bg-gradient-to-r from-[#d1432d]/35 to-transparent opacity-0 blur-[12px] transition group-hover:opacity-100" />
|
||||
{isInSlip ? (
|
||||
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-center text-xs font-bold text-[#167a47]">
|
||||
已加入清單,請到本頁上方「賠率監控清單」集中檢查。
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{data.total_bet_count}</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-xl p-3">
|
||||
<p className="text-xs text-[#7a5b46]">整體 ROI</p>
|
||||
<p className="text-xs text-[#7a5b46]">整體報酬率</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${data.overall_roi_percent >= 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}>
|
||||
{data.overall_roi_percent.toFixed(2)}%
|
||||
</p>
|
||||
@@ -106,7 +105,7 @@ export function BettingLeaksDashboard({ data }: Props) {
|
||||
onlyPositiveAndHigh ? 'bg-[#7d2a15] text-white' : 'bg-white/80 text-[#5f4330]'
|
||||
}`}
|
||||
>
|
||||
僅看「正 EV 且個人勝率高」
|
||||
僅看「正 期望值 且個人勝率高」
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -153,7 +152,7 @@ export function BettingLeaksDashboard({ data }: Props) {
|
||||
<th className="py-2 text-left">賽段</th>
|
||||
<th className="py-2 text-left">筆數</th>
|
||||
<th className="py-2 text-left">成交金額</th>
|
||||
<th className="py-2 text-left">ROI</th>
|
||||
<th className="py-2 text-left">報酬率</th>
|
||||
<th className="py-2 text-left">勝率</th>
|
||||
<th className="py-2 text-left">狀態</th>
|
||||
</tr>
|
||||
|
||||
@@ -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 (
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">{title}</h3>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
|
||||
<div className="flex justify-between items-center border-b border-stone-200 pb-4 mb-6">
|
||||
<div>
|
||||
<h3 className="text-sm text-stone-500 uppercase tracking-wider font-semibold">
|
||||
Kelly Criterion
|
||||
注碼風險控管
|
||||
</h3>
|
||||
<h2 className="text-xl text-stone-900 font-bold">量化注碼建議</h2>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
|
||||
{/* 控制區:總資金 */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-stone-600 font-medium mb-2">
|
||||
當前總資金 (Bankroll): <span className="font-dotmatrix ml-2 text-lg">${bankroll.toLocaleString()}</span>
|
||||
當前可用資金:<span className="font-dotmatrix ml-2 text-lg">${bankroll.toLocaleString()}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -65,7 +65,7 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
|
||||
{/* 控制區:風險偏好 (分數凱利) */}
|
||||
<div className="mb-2">
|
||||
<label className="block text-sm text-stone-600 font-medium mb-2">
|
||||
風險偏好 (Kelly Multiplier):
|
||||
風險偏好:
|
||||
<span className={`font-dotmatrix ml-2 text-lg ${isHighRisk ? 'text-quant-red' : 'text-stone-900'}`}>
|
||||
{kellyFraction}x
|
||||
</span>
|
||||
@@ -80,9 +80,9 @@ export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizing
|
||||
className="w-full h-2 bg-stone-200 rounded-lg appearance-none cursor-pointer accent-stone-700"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-stone-400 mt-2 font-dotmatrix uppercase">
|
||||
<span>Conservative (0.1x)</span>
|
||||
<span>Optimal (0.25x)</span>
|
||||
<span className="text-quant-red">Full Risk (1.0x)</span>
|
||||
<span>保守 0.1x</span>
|
||||
<span>一般 0.25x</span>
|
||||
<span className="text-quant-red">高風險 1.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full bg-stone-900 p-6 rounded-lg border border-stone-700 shadow-xl">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl text-stone-100 font-dotmatrix uppercase font-bold tracking-wider border-b border-stone-700 pb-2">
|
||||
🏆 量化大神排行榜 (Social Trading)
|
||||
量化跟單排行榜
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm mt-1">根據 CLV (收盤線價值) 與近 30 天 ROI 進行排名。一鍵跟單頂級操盤手。</p>
|
||||
<p className="text-stone-400 text-sm mt-1">
|
||||
尚未接入真實、可審計的投注帳本與收盤價差資料前,不顯示模擬高手、不提供一鍵跟單。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-stone-800 text-stone-400 text-sm font-dotmatrix uppercase">
|
||||
<th className="p-3 font-semibold">Rank</th>
|
||||
<th className="p-3 font-semibold">Bettor</th>
|
||||
<th className="p-3 font-semibold">Avg CLV</th>
|
||||
<th className="p-3 font-semibold">30D ROI</th>
|
||||
<th className="p-3 font-semibold text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-800 text-stone-200">
|
||||
{leaderboardData.map((bettor) => (
|
||||
<tr key={bettor.rank} className="hover:bg-stone-800 transition-colors">
|
||||
<td className="p-3 font-bold font-dotmatrix text-lg">
|
||||
{bettor.rank <= 3 ? <span className="text-quant-orange">#{bettor.rank}</span> : `#${bettor.rank}`}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`font-semibold ${bettor.isSharp ? 'text-quant-red drop-shadow-[0_0_8px_rgba(234,88,12,0.8)]' : 'text-stone-300'}`}>
|
||||
{bettor.name}
|
||||
</span>
|
||||
{bettor.isSharp && <span className="text-xs bg-quant-orange/20 text-quant-orange px-2 py-0.5 rounded uppercase font-dotmatrix">Sharp</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 font-dotmatrix text-quant-orange">{bettor.clv}</td>
|
||||
<td className="p-3 font-dotmatrix text-green-500">{bettor.roi}</td>
|
||||
<td className="p-3 text-right">
|
||||
<button
|
||||
onClick={() => handleCopyBet(bettor.name)}
|
||||
className="bg-stone-700 hover:bg-quant-orange hover:text-stone-900 text-stone-300 font-dotmatrix uppercase text-sm px-4 py-2 rounded transition-all font-bold"
|
||||
>
|
||||
Copy Bet
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="rounded-lg border border-stone-700 bg-stone-950/70 p-4 text-sm leading-6 text-stone-300">
|
||||
等待資料:已結算注單、下注時間、進場賠率、收盤賠率、收盤價差、報酬率、最大回撤與風險分層。資料未齊前,本模組維持關閉。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<section className="space-y-4">
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
@@ -45,7 +45,7 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
|
||||
</article>
|
||||
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-xl text-[#7d2a15]">xG 累積走勢</h3>
|
||||
<h3 className="dot-matrix text-xl text-[#7d2a15]">預期進球累積走勢</h3>
|
||||
<div className="mt-2 h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={xgSeries}>
|
||||
@@ -56,8 +56,8 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
|
||||
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
|
||||
itemStyle={{ color: '#5f4031' }}
|
||||
/>
|
||||
<Bar dataKey="xgHome" fill="#b83822" name="主隊xG" />
|
||||
<Bar dataKey="xgAway" fill="#7a4a2c" name="客隊xG" />
|
||||
<Bar dataKey="xgHome" fill="#b83822" name="主隊預期進球" />
|
||||
<Bar dataKey="xgAway" fill="#7a4a2c" name="客隊預期進球" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -82,4 +82,3 @@ export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,4 +58,3 @@ export function MatchConditionsCard({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-[#e7cfa7] bg-[#f6f0e1]/95 shadow-[0_-8px_24px_rgba(98,58,34,0.12)] backdrop-blur md:hidden">
|
||||
<div className="mx-auto flex max-w-7xl justify-between px-2 py-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = path === item.href;
|
||||
const isActive = item.href === '/' ? path === '/' : path === item.href || path.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
type MoneyFlowProps = {
|
||||
type MoneyFlowBarProps = {
|
||||
label: string;
|
||||
ticketPct: number;
|
||||
handlePct: number;
|
||||
};
|
||||
|
||||
export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowProps) {
|
||||
export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowBarProps) {
|
||||
const alert = handlePct - ticketPct >= 20;
|
||||
|
||||
return (
|
||||
@@ -40,4 +40,3 @@ export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowProps) {
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ interface OddsLineMovementChartProps {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export default function OddsLineMovementChart({ data, teamName }: OddsLineMovementChartProps) {
|
||||
export function OddsLineMovementChart({ data, teamName }: OddsLineMovementChartProps) {
|
||||
return (
|
||||
<div className="w-full h-72 bg-stone-50 p-4 rounded-lg border border-stone-200 shadow-sm">
|
||||
<div className="mb-4">
|
||||
|
||||
@@ -17,12 +17,12 @@ type Point = {
|
||||
pnl: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type PerformanceLedgerProps = {
|
||||
summary: ProofOfYieldSummary;
|
||||
records: ProofOfYieldRecord[];
|
||||
};
|
||||
|
||||
export function PerformanceLedger({ summary, records }: Props) {
|
||||
export function PerformanceLedger({ summary, records }: PerformanceLedgerProps) {
|
||||
const sorted = useMemo(
|
||||
() => [...records].sort((a, b) => new Date(a.settled_at).getTime() - new Date(b.settled_at).getTime()),
|
||||
[records],
|
||||
@@ -60,13 +60,13 @@ export function PerformanceLedger({ summary, records }: Props) {
|
||||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{summary.win_rate_percent.toFixed(1)}%</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-xl p-3">
|
||||
<p className="text-xs text-[#7a5b46]">ROI</p>
|
||||
<p className="text-xs text-[#7a5b46]">報酬率</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${summary.roi_percent >= 10 ? 'text-[#d1432d]' : 'text-[#7d2a15]'}`}>
|
||||
{summary.roi_percent.toFixed(2)}%
|
||||
</p>
|
||||
</article>
|
||||
<article className="panel-glow rounded-xl p-3">
|
||||
<p className="text-xs text-[#7a5b46]">平均 CLV</p>
|
||||
<p className="text-xs text-[#7a5b46]">平均收盤價差</p>
|
||||
<p className="mt-2 dot-matrix text-2xl font-semibold text-[#7d2a15]">
|
||||
{summary.avg_clv_percent.toFixed(2)}%
|
||||
</p>
|
||||
@@ -101,7 +101,7 @@ export function PerformanceLedger({ summary, records }: Props) {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#8c2f2f]">最大 CLV(歷史):{maxClv.toFixed(2)}%</p>
|
||||
<p className="mt-2 text-xs text-[#8c2f2f]">歷史最佳收盤價差:{maxClv.toFixed(2)}%</p>
|
||||
</article>
|
||||
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
@@ -116,7 +116,7 @@ export function PerformanceLedger({ summary, records }: Props) {
|
||||
<th className="py-2 text-left">投入</th>
|
||||
<th className="py-2 text-left">推薦賠率</th>
|
||||
<th className="py-2 text-left">收盤賠率</th>
|
||||
<th className="py-2 text-left">CLV</th>
|
||||
<th className="py-2 text-left">收盤價差</th>
|
||||
<th className="py-2 text-left">結果</th>
|
||||
<th className="py-2 text-left">P/L</th>
|
||||
</tr>
|
||||
@@ -153,4 +153,3 @@ export function PerformanceLedger({ summary, records }: Props) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.運球) },
|
||||
|
||||
@@ -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 (
|
||||
<article className={`panel-glow rounded-2xl border p-4 ${topEdge ? 'prop-top-edge' : ''}`}>
|
||||
<p className="dot-matrix text-sm text-[#7d2a15]">{playerName} · {metricLabel}</p>
|
||||
@@ -34,7 +34,7 @@ export function PropValueCard({
|
||||
</div>
|
||||
{topEdge ? (
|
||||
<p className="mt-3 rounded-lg bg-[#f5c6b4]/60 p-2 text-xs text-[#8a2e17] dot-matrix">
|
||||
極佳投注價值(Top Edge):預測機率高於市場定價,建議納入進階池列管。
|
||||
高價值候選:模型預測機率高於目前市場定價,建議納入進階觀察清單。
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-[#6f4f3c]">目前未到達極佳邊際門檻,建議依整體單場風險控管配置。</p>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -9,7 +9,7 @@ const logoMap: Record<BookmakerCode, string> = {
|
||||
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
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
disabled={isUnavailable}
|
||||
title={href ? '開啟已驗證投注平台連結' : '尚未接入合法且可驗證的投注平台一鍵帶入連結'}
|
||||
className={`group relative inline-flex min-w-40 items-center justify-center rounded-full bg-[#7d2a15] px-4 py-2 text-sm text-white transition ${
|
||||
disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-[#5f1f11]'
|
||||
isUnavailable ? 'cursor-not-allowed opacity-60' : 'hover:bg-[#5f1f11]'
|
||||
}`}
|
||||
>
|
||||
<span className="dot-matrix text-sm">{isRedirecting ? '輸入中...' : `${label} ${oddsText}`}</span>
|
||||
<span className="dot-matrix text-sm">{!href ? `${label} 待接入` : isRedirecting ? '帶入中...' : `${label} ${oddsText}`}</span>
|
||||
{!isRedirecting ? (
|
||||
<span className="absolute -top-1 -right-2 hidden rounded-full bg-[#d1432d] px-2 py-0.5 text-[10px] text-white transition group-hover:inline">{oddsText}</span>
|
||||
) : null}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface RlmRadarBoardProps {
|
||||
alerts: RlmAlertData[];
|
||||
}
|
||||
|
||||
export default function RlmRadarBoard({ alerts }: RlmRadarBoardProps) {
|
||||
export function RLMRadarBoard({ alerts }: RlmRadarBoardProps) {
|
||||
if (!alerts || alerts.length === 0) {
|
||||
return (
|
||||
<div className="w-full bg-stone-900 p-6 rounded-lg border border-stone-700 shadow-xl flex items-center justify-center min-h-[200px]">
|
||||
@@ -35,7 +35,7 @@ export default function RlmRadarBoard({ alerts }: RlmRadarBoardProps) {
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl text-quant-red font-dotmatrix animate-blink uppercase font-bold tracking-wider">
|
||||
🚨 警告:偵測到反向盤口移動 (RLM)
|
||||
🚨 警告:偵測到反向盤口移動 (反向盤口)
|
||||
</h2>
|
||||
<p className="text-stone-400 text-sm mt-1">莊家正在轉移風險,聰明錢流向已確認。</p>
|
||||
</div>
|
||||
|
||||
@@ -49,15 +49,17 @@ export function useLiveMatchData(matchId: string | null) {
|
||||
}
|
||||
|
||||
if (message.eventType === 'odds') {
|
||||
const payload = message.payload as OddsPayload;
|
||||
setOdds((prev) => {
|
||||
const next = [...prev.filter((item) => item.market !== message.payload.market), message.payload];
|
||||
const next = [...prev.filter((item) => item.market !== payload.market), payload];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.eventType === 'event') {
|
||||
setEvents((prev) => [message.payload, ...prev].slice(0, 80));
|
||||
const payload = message.payload as MatchEventPayload;
|
||||
setEvents((prev) => [payload, ...prev].slice(0, 80));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -202,7 +202,11 @@ export type RlmAlert = {
|
||||
odds_change_pct: number;
|
||||
smart_money_to: string;
|
||||
is_triggered: boolean;
|
||||
rationale: string;
|
||||
confidence_score?: number;
|
||||
risk_level?: 'core' | 'speculative' | 'parlay' | 'sgp' | string;
|
||||
market_implied_prob?: number;
|
||||
edge_percent?: number;
|
||||
data_checks?: string[];
|
||||
};
|
||||
|
||||
export type RlmResponse = {
|
||||
@@ -333,15 +337,35 @@ export type DailyCardItem = {
|
||||
win_prob: number;
|
||||
ev_percent: number;
|
||||
stake_units: number;
|
||||
stake_amount_twd?: number;
|
||||
unit_size_twd?: number;
|
||||
recommendation: string;
|
||||
rationale: string;
|
||||
confidence_score?: number;
|
||||
confidence_band?: string;
|
||||
confidence_factors?: string[];
|
||||
data_quality?: string;
|
||||
has_market_odds?: boolean;
|
||||
odds_source_label?: string;
|
||||
odds_source_kind?: string;
|
||||
risk_level?: 'core' | 'speculative' | 'parlay' | 'sgp' | string;
|
||||
market_implied_prob?: number;
|
||||
edge_percent?: number;
|
||||
data_checks?: string[];
|
||||
legs?: DailyCardLeg[];
|
||||
};
|
||||
|
||||
export type DailyCardResponse = {
|
||||
date: string;
|
||||
generated_at?: string;
|
||||
total_daily_unit_recommendation: number;
|
||||
total_daily_amount_twd?: number;
|
||||
unit_size_twd?: number;
|
||||
summary: string;
|
||||
market_data_status?: string | null;
|
||||
data_quality_summary?: Record<string, number>;
|
||||
execution_policy?: string | null;
|
||||
auto_refresh_seconds?: number;
|
||||
safe_singles: DailyCardItem[];
|
||||
high_risk_singles: DailyCardItem[];
|
||||
safe_parlays: DailyCardItem[];
|
||||
@@ -350,10 +374,184 @@ export type DailyCardResponse = {
|
||||
stage_distribution: Record<string, number>;
|
||||
};
|
||||
|
||||
export type DailyCardCalendarDate = {
|
||||
date: string;
|
||||
match_count: number;
|
||||
matched_matches: number;
|
||||
recommendation_count: number;
|
||||
live_count: number;
|
||||
watch_count: number;
|
||||
safe_single_count: number;
|
||||
high_risk_single_count: number;
|
||||
safe_parlay_count: number;
|
||||
sgp_lottery_count: number;
|
||||
total_amount_twd: number;
|
||||
market_data_status?: string | null;
|
||||
snapshot_status?: string | null;
|
||||
snapshot_item_count?: number | null;
|
||||
snapshot_preserved_count?: number | null;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type DailyCardCalendarResponse = {
|
||||
generated_at: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
dates: DailyCardCalendarDate[];
|
||||
};
|
||||
|
||||
export type RecommendationPerformanceItem = {
|
||||
match_id: string;
|
||||
match_label: string;
|
||||
market_type: string;
|
||||
selection: string;
|
||||
recommendation: string;
|
||||
result_score: string;
|
||||
outcome: 'hit' | 'miss' | 'push' | 'not_evaluable' | string;
|
||||
outcome_label: string;
|
||||
target_odds: number;
|
||||
win_prob: number;
|
||||
ev_percent: number;
|
||||
stake_units: number;
|
||||
stake_amount_twd?: number | null;
|
||||
confidence_score?: number | null;
|
||||
confidence_band?: string | null;
|
||||
has_market_odds?: boolean | null;
|
||||
odds_source_label?: string | null;
|
||||
odds_source_kind?: string | null;
|
||||
lesson: string;
|
||||
};
|
||||
|
||||
export type RecommendationPerformanceBucket = {
|
||||
market_type: string;
|
||||
recommendation_count: number;
|
||||
settled_count: number;
|
||||
hit_count: number;
|
||||
miss_count: number;
|
||||
push_count: number;
|
||||
hit_rate_percent: number;
|
||||
};
|
||||
|
||||
export type RecommendationPerformanceSourceBucket = {
|
||||
source_label: string;
|
||||
source_kind: string;
|
||||
recommendation_count: number;
|
||||
settled_count: number;
|
||||
hit_count: number;
|
||||
miss_count: number;
|
||||
push_count: number;
|
||||
hit_rate_percent: number;
|
||||
};
|
||||
|
||||
export type RecommendationPerformanceResponse = {
|
||||
generated_at: string;
|
||||
days_back: number;
|
||||
finished_match_count: number;
|
||||
rebuilt_recommendation_count: number;
|
||||
settled_recommendation_count: number;
|
||||
hit_count: number;
|
||||
miss_count: number;
|
||||
push_count: number;
|
||||
hit_rate_percent: number;
|
||||
summary: string;
|
||||
methodology_note: string;
|
||||
improvement_actions: string[];
|
||||
by_market_type: RecommendationPerformanceBucket[];
|
||||
by_odds_source: RecommendationPerformanceSourceBucket[];
|
||||
items: RecommendationPerformanceItem[];
|
||||
};
|
||||
|
||||
export type AgentVerificationCheck = {
|
||||
agent: string;
|
||||
role: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
evidence: string[];
|
||||
next_action?: string | null;
|
||||
last_checked_at: string;
|
||||
};
|
||||
|
||||
export type AgentVerificationResponse = {
|
||||
generated_at: string;
|
||||
overall_status: string;
|
||||
overall_label: string;
|
||||
production_ready: boolean;
|
||||
decision_policy: string;
|
||||
calibration_summary: Record<string, unknown>;
|
||||
checks: AgentVerificationCheck[];
|
||||
};
|
||||
|
||||
export type GeminiUsageResponse = {
|
||||
generated_at: string;
|
||||
month: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
paused: boolean;
|
||||
cap_usd: number;
|
||||
estimated_cost_usd: number;
|
||||
remaining_usd: number;
|
||||
request_count: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
grounded_query_count: number;
|
||||
pricing_note: string;
|
||||
next_action?: string | null;
|
||||
};
|
||||
|
||||
export type SourceHealthResponse = {
|
||||
status: string;
|
||||
odds_coverage_status?: string;
|
||||
upcoming_odds_matches?: number;
|
||||
stale_unsettled_matches?: number;
|
||||
stale_unsettled_threshold_hours?: number;
|
||||
odds_rows: number;
|
||||
matches: number;
|
||||
finished_matches: number;
|
||||
venues: number;
|
||||
high_altitude_venues: number;
|
||||
latest_odds_recorded_at: string | null;
|
||||
latest_result_synced_at: string | null;
|
||||
ingestion_status?: {
|
||||
status?: string;
|
||||
source?: string;
|
||||
run_at?: string;
|
||||
events?: number;
|
||||
odds_rows?: number;
|
||||
bookmakers?: number;
|
||||
message?: string;
|
||||
} | null;
|
||||
fixtures_status?: {
|
||||
status?: string;
|
||||
source?: string;
|
||||
run_at?: string;
|
||||
fixtures?: number;
|
||||
upserted?: number;
|
||||
skipped?: number;
|
||||
interval_seconds?: number;
|
||||
message?: string;
|
||||
} | null;
|
||||
news_status?: {
|
||||
status?: string;
|
||||
source?: string;
|
||||
run_at?: string;
|
||||
items?: number;
|
||||
interval_seconds?: number;
|
||||
message?: string;
|
||||
} | null;
|
||||
provider_requirements?: {
|
||||
primary_odds_provider?: string;
|
||||
required_markets?: string[];
|
||||
taiwan_sports_lottery?: string;
|
||||
current_limitation?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatchListItem = {
|
||||
match_id: string;
|
||||
home_team: string;
|
||||
away_team: string;
|
||||
home_score: number | null;
|
||||
away_score: number | null;
|
||||
kickoff_utc: string;
|
||||
status: string;
|
||||
venue_name: string | null;
|
||||
@@ -400,6 +598,8 @@ export type MatchDetail = {
|
||||
match_id: string;
|
||||
home_team: string;
|
||||
away_team: string;
|
||||
home_score: number | null;
|
||||
away_score: number | null;
|
||||
home_xg: number;
|
||||
away_xg: number;
|
||||
match_time_utc: string;
|
||||
@@ -409,6 +609,8 @@ export type MatchDetail = {
|
||||
venue_country: string | null;
|
||||
venue_altitude_meters: number | null;
|
||||
odds_series: MatchOddsPoint[];
|
||||
odds_quality?: string;
|
||||
xg_quality?: string;
|
||||
poisson: MatchPoisson;
|
||||
conditions: MatchConditionsReadout;
|
||||
quant_summary: string;
|
||||
@@ -416,6 +618,11 @@ export type MatchDetail = {
|
||||
|
||||
type ApiErrorPayload = {
|
||||
message?: string;
|
||||
detail?: string;
|
||||
error?: {
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ANALYTICS_API_BASE = '/api/analytics';
|
||||
@@ -426,22 +633,35 @@ async function requestAnalytics<T>(
|
||||
path: string,
|
||||
payload: unknown,
|
||||
method: HttpMethod = 'POST',
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${ANALYTICS_API_BASE}/${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: method === 'POST' ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Mock Mode] Fetch failed for ${path}, returning mock data.`, error);
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${ANALYTICS_API_BASE}/${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: method === 'POST' ? JSON.stringify(payload) : undefined,
|
||||
...(method === 'GET' ? { cache: 'no-store' as RequestCache } : {}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const errorPayload = contentType.includes('application/json')
|
||||
? ((await response.json().catch(() => null)) as ApiErrorPayload | null)
|
||||
: null;
|
||||
const textPayload = errorPayload ? '' : await response.text().catch(() => '');
|
||||
const backendMessage =
|
||||
errorPayload?.error?.message ||
|
||||
errorPayload?.error?.detail ||
|
||||
errorPayload?.message ||
|
||||
errorPayload?.detail ||
|
||||
textPayload ||
|
||||
'資料服務暫時無法回應';
|
||||
throw new Error(`${backendMessage}(狀態碼 ${response.status})`);
|
||||
} catch (error) {
|
||||
console.error(`[API Error] Fetch failed for ${path}`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The mock data fallback has been removed to enforce real API usage
|
||||
}
|
||||
|
||||
export function calculatePlayerProps(payload: PlayerPropsRequestPayload): Promise<PlayerPropsResponse> {
|
||||
@@ -492,6 +712,28 @@ export function getDailyCard(date: string): Promise<DailyCardResponse> {
|
||||
return requestAnalytics<DailyCardResponse>(`daily-card/${date}`, {}, 'GET');
|
||||
}
|
||||
|
||||
export function getDailyCardCalendar(startDate = '2026-06-11', endDate?: string): Promise<DailyCardCalendarResponse> {
|
||||
const params = new URLSearchParams({ start_date: startDate });
|
||||
if (endDate) params.set('end_date', endDate);
|
||||
return requestAnalytics<DailyCardCalendarResponse>(`daily-card-calendar?${params.toString()}`, {}, 'GET');
|
||||
}
|
||||
|
||||
export function getRecommendationPerformance(daysBack = 7): Promise<RecommendationPerformanceResponse> {
|
||||
return requestAnalytics<RecommendationPerformanceResponse>(`recommendation-performance?days_back=${daysBack}`, {}, 'GET');
|
||||
}
|
||||
|
||||
export function getAgentVerification(): Promise<AgentVerificationResponse> {
|
||||
return requestAnalytics<AgentVerificationResponse>('agent-verification', {}, 'GET');
|
||||
}
|
||||
|
||||
export function getGeminiUsage(): Promise<GeminiUsageResponse> {
|
||||
return requestAnalytics<GeminiUsageResponse>('gemini-usage', {}, 'GET');
|
||||
}
|
||||
|
||||
export function getSourceHealth(): Promise<SourceHealthResponse> {
|
||||
return requestAnalytics<SourceHealthResponse>('source-health', {}, 'GET');
|
||||
}
|
||||
|
||||
export function getAllMatches(): Promise<MatchListItem[]> {
|
||||
return requestAnalytics<MatchListItem[]>('matches', {}, 'GET');
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ declare module 'next-auth' {
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma) as any,
|
||||
providers: [
|
||||
Credentials({
|
||||
name: 'Email',
|
||||
@@ -79,6 +79,3 @@ const handlers = NextAuth({
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
export const { auth, signIn, signOut, handlers } = handlers;
|
||||
|
||||
|
||||
@@ -158,22 +158,22 @@ export function generateBetSlipUrl(params: {
|
||||
selection: string;
|
||||
odds: number;
|
||||
trackingId?: string;
|
||||
}): string {
|
||||
}): string | null {
|
||||
const configuredBaseUrls: Partial<Record<BookmakerCode, string | undefined>> = {
|
||||
bet365: process.env.NEXT_PUBLIC_BET365_BETSLIP_URL,
|
||||
pinnacle: process.env.NEXT_PUBLIC_PINNACLE_BETSLIP_URL,
|
||||
draftkings: process.env.NEXT_PUBLIC_DRAFTKINGS_BETSLIP_URL,
|
||||
};
|
||||
const baseUrl = configuredBaseUrls[params.bookmakerId];
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const partner = encodeURIComponent(params.trackingId || process.env.NEXT_PUBLIC_AFFILIATE_ID || 'fifa2026');
|
||||
const odds = encodeURIComponent(String(params.odds));
|
||||
const selection = encodeURIComponent(params.selection);
|
||||
const matchId = encodeURIComponent(params.matchId);
|
||||
|
||||
if (params.bookmakerId === 'bet365') {
|
||||
// Bet365:以 hash path + query 的混合方式組成,對應其「快速下單」導流機制。
|
||||
return `https://www.bet365.com/#/AC/B1/C1/D13/Ep1/F2/?affiliate=${partner}&match=${matchId}&selection=${selection}&odds=${odds}`;
|
||||
}
|
||||
|
||||
if (params.bookmakerId === 'pinnacle') {
|
||||
// Pinnacle:通常以 REST 風格 query string 直達預填。
|
||||
return `https://www.pinnacle.com/en/betting/football/${matchId}?selection=${selection}&odds=${odds}&aff=${partner}`;
|
||||
}
|
||||
|
||||
// fallback to DraftKings pattern
|
||||
return `https://www.draftkings.com/sportsbook/odds?event=${matchId}&selection=${selection}&odds=${odds}&affid=${partner}`;
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('affiliate', partner);
|
||||
url.searchParams.set('match', params.matchId);
|
||||
url.searchParams.set('selection', params.selection);
|
||||
url.searchParams.set('odds', String(params.odds));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "2026 FIFA Quantum Ops",
|
||||
"short_name": "FIFA 2026 Ops",
|
||||
"name": "2026 世界盃量化投注研究中心",
|
||||
"short_name": "世界盃 2026",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const CACHE_NAME = 'wc2026-quant-v1';
|
||||
const CACHE_NAME = 'wc2026-quant-20260615-network-realtime-v1';
|
||||
const OFFLINE_URL = '/offline';
|
||||
const ASSETS = ['/', '/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
|
||||
const ASSETS = ['/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
|
||||
const ODDS_KEYS = 'wc2026:lastOddsSnapshot';
|
||||
const MATCHES_API_PREFIX = '/api/analytics/matches';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
|
||||
@@ -20,7 +21,7 @@ self.addEventListener('activate', (event) => {
|
||||
|
||||
function cacheSafeCopy(request, response) {
|
||||
const cacheable = request.method === 'GET' &&
|
||||
(request.url.includes('/api/analytics') || request.url.includes('/_next') || request.url.endsWith('.png') || request.url.endsWith('.svg') || request.url.endsWith('.css') || request.url.endsWith('.js'));
|
||||
(request.url.endsWith('.png') || request.url.endsWith('.svg'));
|
||||
if (!cacheable) {
|
||||
return;
|
||||
}
|
||||
@@ -100,6 +101,41 @@ self.addEventListener('fetch', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith(MATCHES_API_PREFIX)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(request, { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
const payload = await response.clone().json().catch(() => null);
|
||||
if (payload && !Array.isArray(payload)) {
|
||||
await writeOddsSnapshotFromDetail(payload).catch(() => {});
|
||||
}
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({
|
||||
message: '賽事資料暫時無法即時更新,請稍後重新整理。',
|
||||
offline: true,
|
||||
source: 'service-worker-network-only',
|
||||
}), {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match(OFFLINE_URL)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cached = await caches.match(request);
|
||||
@@ -110,14 +146,7 @@ self.addEventListener('fetch', (event) => {
|
||||
return cached || Promise.reject(new TypeError('response-not-ok'));
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/analytics/matches')) {
|
||||
const clone = response.clone();
|
||||
cacheSafeCopy(request, response);
|
||||
const payload = await clone.json().catch(() => null);
|
||||
await writeOddsSnapshotFromDetail(payload).catch(() => {});
|
||||
} else {
|
||||
cacheSafeCopy(request, response);
|
||||
}
|
||||
cacheSafeCopy(request, response);
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
@@ -129,6 +158,18 @@ self.addEventListener('fetch', (event) => {
|
||||
return caches.match(OFFLINE_URL);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return new Response(JSON.stringify({
|
||||
message: '資料服務暫時無法連線,請稍後重新整理。',
|
||||
offline: true,
|
||||
source: 'service-worker',
|
||||
}), {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Service unavailable', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
@@ -141,7 +182,15 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'clear-cache') {
|
||||
caches.delete(CACHE_NAME);
|
||||
caches.keys().then((keys) => {
|
||||
keys
|
||||
.filter((key) => key.startsWith('wc2026-') || key.startsWith('fifa2026'))
|
||||
.forEach((key) => caches.delete(key));
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data?.type === 'skip-waiting') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data?.type === 'persist-odds' && event.data?.payload) {
|
||||
|
||||
Reference in New Issue
Block a user