Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
||||
PORT=3000
|
||||
# The Odds API is used as the primary data source for fixtures + odds.
|
||||
THE_ODDS_API_KEY=your_the_odds_api_key
|
||||
THE_ODDS_BASE=https://api.the-odds-api.com
|
||||
THE_ODDS_SPORT_KEY=soccer_fifa_world_cup
|
||||
THE_ODDS_REGIONS=eu
|
||||
# 1x2 / spreads / totals / btts are commonly available
|
||||
THE_ODDS_MARKETS=h2h,spreads,totals,btts
|
||||
REFRESH_MINUTES=10
|
||||
LIVE_REFRESH_SECONDS=45
|
||||
FAST_REFRESH_HOURS=6
|
||||
MATCH_LOOKBACK_HOURS=12
|
||||
APP_TIME_ZONE=Asia/Taipei
|
||||
APP_PUBLIC_ORIGIN=https://2026fifa.wooo.work
|
||||
TZ=Asia/Taipei
|
||||
KELLY_SCALE=0.6
|
||||
|
||||
# Optional news provider key (if you have one) or set for RSS-only mode.
|
||||
NEWS_API_KEY=
|
||||
NEWS_PROVIDER=google-rss
|
||||
|
||||
# Optional tuning
|
||||
ODDS_REFRESH_TIMEOUT_MS=15000
|
||||
NEWS_FETCH_CONCURRENCY=3
|
||||
NEWS_LOOKBACK_DAYS=14
|
||||
MAX_MATCHES_FOR_NEWS=64
|
||||
|
||||
# 量化分析/資料平台參數(預留)
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
POSTGRES_URL=postgres://user:password@127.0.0.1:5432/fifa2026
|
||||
ANALYTICS_MODE=js
|
||||
ANALYTICS_MC_SAMPLES=10000
|
||||
118
.github/workflows/deploy-production.yml
vendored
Normal file
118
.github/workflows/deploy-production.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: deploy-2026fifa-platform
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 取回原始碼
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 設定 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: 安裝前端套件
|
||||
working-directory: platform/web
|
||||
run: npm install
|
||||
|
||||
- name: 前端 Lint
|
||||
working-directory: platform/web
|
||||
run: npm run lint
|
||||
|
||||
- name: 前端建置檢查
|
||||
working-directory: platform/web
|
||||
run: npm run build
|
||||
|
||||
- name: 設定 Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: 安裝後端與警報套件
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r platform/backend/requirements.txt
|
||||
pip install -r platform/alerts/requirements.txt
|
||||
|
||||
- name: 編譯 Python 程式碼
|
||||
run: |
|
||||
python -m compileall platform/backend/app
|
||||
python -m compileall platform/alerts
|
||||
|
||||
build-and-push:
|
||||
needs: test-and-build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: 取回原始碼
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 登入容器倉庫
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build 並推送前端映像
|
||||
run: |
|
||||
docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} platform/web
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-web:latest
|
||||
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }}
|
||||
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-web:latest
|
||||
|
||||
- name: Build 並推送後端映像
|
||||
run: |
|
||||
docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} platform/backend
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-backend:latest
|
||||
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }}
|
||||
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-backend:latest
|
||||
|
||||
- name: Build 並推送警報服務映像
|
||||
run: |
|
||||
docker build -t ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} platform/alerts
|
||||
docker tag ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:latest
|
||||
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }}
|
||||
docker push ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:latest
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 取回原始碼
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安裝 kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: 'v1.30.0'
|
||||
|
||||
- name: 建立 KubeConfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.K3S_KUBECONFIG }}" > ~/.kube/config
|
||||
|
||||
- name: 替換映像版本
|
||||
run: |
|
||||
sed -i "s#ghcr.io/your-org/2026fifa-web:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-web:${{ github.sha }}#g" platform/deploy/k3s/web-deployment.yaml
|
||||
sed -i "s#ghcr.io/your-org/2026fifa-backend:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-backend:${{ github.sha }}#g" platform/deploy/k3s/backend-deployment.yaml
|
||||
sed -i "s#ghcr.io/your-org/2026fifa-alerts:latest#ghcr.io/${{ github.repository_owner }}/2026fifa-alerts:${{ github.sha }}#g" platform/deploy/k3s/alerts-deployment.yaml
|
||||
|
||||
- name: 套用 K3s Manifest
|
||||
run: |
|
||||
kubectl apply -f platform/deploy/k3s/namespace.yaml
|
||||
kubectl apply -f platform/deploy/k3s/secret.yaml
|
||||
kubectl apply -f platform/deploy/k3s/configmap.yaml
|
||||
kubectl apply -f platform/deploy/k3s/postgres-deployment.yaml
|
||||
kubectl apply -f platform/deploy/k3s/redis-deployment.yaml
|
||||
kubectl apply -f platform/deploy/k3s/backend-deployment.yaml
|
||||
kubectl apply -f platform/deploy/k3s/web-deployment.yaml
|
||||
kubectl apply -f platform/deploy/k3s/alerts-deployment.yaml
|
||||
kubectl apply -f platform/deploy/k3s/ingress.yaml
|
||||
74
.github/workflows/deploy.yml
vendored
Normal file
74
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: 2026 World Cup Quant Platform - Production Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test-and-lint:
|
||||
name: Code Quality & Testing
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Backend Dependencies
|
||||
run: pip install -r backend/requirements.txt pytest
|
||||
|
||||
- name: Run Backend Quant Engine Tests
|
||||
run: pytest backend/app/analytics/
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Run Frontend Linting
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run lint
|
||||
|
||||
deploy-docker:
|
||||
name: Deploy to Production VM via Docker Compose
|
||||
needs: test-and-lint
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Deploy to Ubuntu Server via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.PROD_SERVER_IP }}
|
||||
username: ${{ secrets.PROD_SERVER_USER }}
|
||||
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
echo "🚀 [Deploy] Starting deployment for 2026fifa.wooo.work"
|
||||
|
||||
# 進入專案目錄
|
||||
cd /opt/worldcup-quant-2026
|
||||
|
||||
# 抓取最新程式碼
|
||||
git pull origin main
|
||||
|
||||
# 使用 Docker Compose 重新建置並平滑重啟容器
|
||||
# --build: 強制重新編譯 Dockerfile (前端靜態檔與後端依賴)
|
||||
# -d: 背景執行
|
||||
docker-compose -f docker-compose.prod.yml up --build -d
|
||||
|
||||
# 執行資料庫遷移 (若使用 Alembic)
|
||||
# docker exec quant_backend alembic upgrade head
|
||||
|
||||
# 清理閒置的舊 Image 釋放伺服器空間
|
||||
docker image prune -f
|
||||
|
||||
echo "✅ [Deploy] Deployment completed successfully!"
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
/ops/shared
|
||||
/ops/*.log
|
||||
25
Makefile
Normal file
25
Makefile
Normal file
@@ -0,0 +1,25 @@
|
||||
.PHONY: up down logs db-shell test build deploy redis-cli
|
||||
|
||||
up:
|
||||
docker-compose up -d
|
||||
|
||||
down:
|
||||
docker-compose down
|
||||
|
||||
logs:
|
||||
docker-compose logs -f
|
||||
|
||||
db-shell:
|
||||
docker-compose exec fifa2026-postgres psql -U fifa_user -d fifa2026
|
||||
|
||||
redis-cli:
|
||||
docker-compose exec fifa2026-redis redis-cli
|
||||
|
||||
test:
|
||||
docker-compose exec fifa2026-backend pytest app/analytics/ -v
|
||||
|
||||
build:
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
deploy:
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
115
README.md
Normal file
115
README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 2026 FIFA 世界盃投注研究站(緊急版)
|
||||
|
||||
這是一個可直接啟動的網站:
|
||||
- 抓取所有 2026 世界盃賽事(以 The Odds API 為主)
|
||||
- 抓取各場賠率(1X2、讓球、大小球、BTTS)
|
||||
- 解析前端新聞連動(Google RSS / NewsAPI 可切換)
|
||||
- 將賠率轉換為市場共識機率,輸出高勝率投注玩法與組合建議(單場、2 串、3 串)
|
||||
- 做賽程與新聞熱度對照(按日期統計、休息日比較、近期報導監控)
|
||||
|
||||
## 快速啟動
|
||||
|
||||
1. 安裝套件
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 複製環境參數
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. 設定金鑰(至少要有 The Odds API Key)
|
||||
|
||||
- `THE_ODDS_API_KEY`:必填,取自 The Odds API
|
||||
- 可選:`NEWS_API_KEY` + `NEWS_PROVIDER=newsapi`(更穩定)
|
||||
- 可選:`KELLY_SCALE`(0~1,預設 0.6,控制 Kelly 參數保守度)
|
||||
|
||||
4. 啟動服務
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
開啟 `http://localhost:3000`。
|
||||
正式上線請使用:`https://2026fifa.wooo.work`
|
||||
|
||||
> 時間設定:全站以 `APP_TIME_ZONE=Asia/Taipei`(台北時區,UTC+8)為主,API 回應與前端時間展示都會沿用此時區。
|
||||
|
||||
## API 簡介
|
||||
|
||||
- `GET /api/health`:服務狀態
|
||||
- `GET /api/matches?force=1`:抓取賽事與賠率(含新聞)
|
||||
- `GET /api/analyze`:回傳每場賽事策略與 2 串/3 串建議(含 `expectedValue`、`kellyFraction`、`modelGrade`)
|
||||
- `GET /api/today-insights`:回傳台北時間今日場次之「高/中/低勝率+爆冷」摘要(首頁主題視覺)
|
||||
- `GET /api/schedule-comparison`:回傳賽程比對與新聞熱點
|
||||
- `GET /api/refresh`:手動刷新快取資料
|
||||
- `GET /api/source-registry`:回傳主流資料來源台帳、整合狀態、權重與健康檢查(含 2026 世界盃主流官方/賠率/新聞/統計/天氣來源)
|
||||
|
||||
目前台帳已落地覆蓋:
|
||||
- 官方(FIFA)
|
||||
- 賠率主幹(The Odds API)與備援規劃源
|
||||
- 新聞/事件(Reuters、BBC、ESPN、Goal、Sky Sports、Google RSS、NewsAPI)
|
||||
- 統計與球隊技術(SofaScore、WhoScored、FBref、Understat、Soccerway、Transfermarkt 等)
|
||||
- 天氣與場地條件(Open-Meteo、WeatherAPI、OpenWeather 規劃中)
|
||||
|
||||
## 正式環境部署建議(4 台候選主機)
|
||||
|
||||
已做基礎可達性盤點後,建議優先使用 `192.168.0.188` 作為正式主機:
|
||||
- 110/188:22/80 可到達
|
||||
- 110 回應為 301,188 為 200,188 對直接上線友善
|
||||
- 120/121 目前未開放 80
|
||||
|
||||
建議流程:
|
||||
1. 先把金鑰和站台參數放到目標機器 `/opt/fifa2026/.env`
|
||||
2. 用部署腳本推版:
|
||||
|
||||
```bash
|
||||
./ops/deploy-production.sh 192.168.0.188
|
||||
```
|
||||
|
||||
3. 健康檢查:
|
||||
|
||||
```bash
|
||||
./ops/healthcheck-production.sh 192.168.0.188 3108 2026fifa.wooo.work
|
||||
```
|
||||
|
||||
5. 正式對外網址與健康資訊可直接由 `/api/health` 確認:
|
||||
|
||||
```bash
|
||||
curl https://2026fifa.wooo.work/api/health
|
||||
```
|
||||
|
||||
6. 查看 `ops/pm2.config.js` 與 `ops/deploy-host-assessment.md` 進行壓力/切換演練
|
||||
7. 正式域名反代範例可套用 `ops/nginx-2026fifa.conf`(含 80 跳轉 443)
|
||||
|
||||
```bash
|
||||
sudo cp ops/nginx-2026fifa.conf /etc/nginx/conf.d/2026fifa.wooo.work.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 專業版本擴展
|
||||
|
||||
- 外部資料參考與權重策略見:`docs/professional-data-reference.md`
|
||||
- 目前已將分析節奏做為「賽前高峰」加速更新(`LIVE_REFRESH_SECONDS`)
|
||||
- 若你要全量參考外部主流網站的模型,建議下一步接入「多來源資料源抽象層」(賠率 + 賽事 + 新聞 + 天氣 + xG)
|
||||
|
||||
## 專案結構
|
||||
|
||||
- `src/server.js`:Express 後端 + 資料抓取 + 分析引擎 + 排程更新
|
||||
- `public/index.html`:管理面版
|
||||
- `public/app.js`:前端互動與卡片渲染
|
||||
- `public/style.css`:界面樣式
|
||||
|
||||
## 重要提示
|
||||
|
||||
- 本系統側重「研究」與「風險參考」,非保證收益工具。
|
||||
- 資料來源可能有授權或速率限制,實戰前請先驗證 API 顯示條件。
|
||||
- 若要擴充更多市場,可在 `THE_ODDS_MARKETS` 增加對應 market key(例如 `btts,corners`)並調整前端顯示/分析規則。
|
||||
|
||||
## 資料安全
|
||||
|
||||
- `.env` 不要提交到版本庫。
|
||||
- 生產環境建議加上反向代理與速率限制、API key 保護。
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
97
docker-compose.prod.yml
Normal file
97
docker-compose.prod.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
fifa2026-postgres:
|
||||
image: timescale/timescaledb:latest-pg16
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: fifa_user
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me}
|
||||
POSTGRES_DB: fifa2026
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- pg-data:/var/lib/postgresql/data
|
||||
- ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- wc2026-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fifa_user -d fifa2026"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
fifa2026-redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- wc2026-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
fifa2026-backend:
|
||||
build:
|
||||
context: ./platform/backend
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:-change_me}@fifa2026-postgres:5432/fifa2026
|
||||
- REDIS_URL=redis://fifa2026-redis:6379/0
|
||||
- THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-}
|
||||
depends_on:
|
||||
fifa2026-postgres:
|
||||
condition: service_healthy
|
||||
fifa2026-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- wc2026-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
fifa2026-web:
|
||||
build:
|
||||
context: ./platform/web
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=https://2026fifa.wooo.work/api
|
||||
depends_on:
|
||||
fifa2026-backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- wc2026-net
|
||||
|
||||
fifa2026-alerts:
|
||||
build:
|
||||
context: ./platform/alerts
|
||||
restart: always
|
||||
environment:
|
||||
- REDIS_URL=redis://fifa2026-redis:6379/0
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
depends_on:
|
||||
fifa2026-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- wc2026-net
|
||||
|
||||
networks:
|
||||
wc2026-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
redis-data:
|
||||
94
docker-compose.yml
Normal file
94
docker-compose.yml
Normal file
@@ -0,0 +1,94 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
fifa2026-postgres:
|
||||
image: timescale/timescaledb:latest-pg16
|
||||
environment:
|
||||
POSTGRES_USER: fifa_user
|
||||
POSTGRES_PASSWORD: change_me
|
||||
POSTGRES_DB: fifa2026
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pg-data:/var/lib/postgresql/data
|
||||
- ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- wc2026-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fifa_user -d fifa2026"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
fifa2026-redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- wc2026-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
fifa2026-backend:
|
||||
build:
|
||||
context: ./platform/backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./platform/backend:/app
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026
|
||||
- REDIS_URL=redis://fifa2026-redis:6379/0
|
||||
- THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-}
|
||||
depends_on:
|
||||
fifa2026-postgres:
|
||||
condition: service_healthy
|
||||
fifa2026-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- wc2026-net
|
||||
|
||||
fifa2026-web:
|
||||
build:
|
||||
context: ./platform/web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./platform/web:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
depends_on:
|
||||
- fifa2026-backend
|
||||
networks:
|
||||
- wc2026-net
|
||||
|
||||
fifa2026-alerts:
|
||||
build:
|
||||
context: ./platform/alerts
|
||||
volumes:
|
||||
- ./platform/alerts:/app
|
||||
environment:
|
||||
- REDIS_URL=redis://fifa2026-redis:6379/0
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
depends_on:
|
||||
fifa2026-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- wc2026-net
|
||||
|
||||
networks:
|
||||
wc2026-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
redis-data:
|
||||
241
docs/professional-data-reference.md
Normal file
241
docs/professional-data-reference.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# 專業外部資料參考台帳(完整版)
|
||||
|
||||
本頁定義 2026 世界盃投注研究站的「參考來源矩陣」,目標是從官方公告、賠率市場、新聞訊號、球隊統計、場地天氣五大軸建立可追溯的高勝率研究底盤。
|
||||
所有時區均以 **Asia/Taipei(UTC+8)** 對齊顯示。
|
||||
|
||||
## 一、來源接入分級
|
||||
|
||||
- `active`:已在系統中可直接抓取並回報健康狀態
|
||||
- `conditional`:有條件接入(依環境金鑰)
|
||||
- `planned`:規劃中,作為未來接入候選
|
||||
- `reference_only`:僅作人工/參考核對,不列為即時抓取來源
|
||||
|
||||
## 二、主流外部來源矩陣
|
||||
|
||||
### 1. 官方與賽事主體
|
||||
|
||||
1. FIFA 官方 — `fifa_official`
|
||||
- 網址:`https://www.fifa.com/fifa-world-cup`
|
||||
- 類型:官方主辦
|
||||
- 狀態:reference_only
|
||||
- 權重:1.00
|
||||
- 用途:規則、公告、賽制、場地政策、重大異動參考
|
||||
|
||||
2. FIFA 官方賽程與賽果 — `fifa_world_cup_fixtures`
|
||||
- 網址:`https://www.fifa.com/fifa-world-cup/fixtures-and-results`
|
||||
- 類型:官方主辦
|
||||
- 狀態:reference_only
|
||||
- 權重:0.98
|
||||
- 用途:核對開賽時間、場次順序、場地異動
|
||||
|
||||
### 2. 賠率與市場資料
|
||||
|
||||
1. The Odds API — `the_odds_api`
|
||||
- 網址:`https://api.the-odds-api.com`
|
||||
- 類型:API
|
||||
- 狀態:active
|
||||
- 權重:0.96
|
||||
- 用途:主體市場抓取(1X2、讓球、大小球、BTTS)
|
||||
|
||||
2. Sportradar — `sportradar`
|
||||
- 網址:`https://developer.sportradar.com`
|
||||
- 類型:API(企業授權)
|
||||
- 狀態:planned
|
||||
- 權重:0.90
|
||||
- 用途:賽事事件與場內關鍵事件補強
|
||||
|
||||
3. API-FOOTBALL Odds — `apifootball_odds`
|
||||
- 網址:`https://www.api-football.com`
|
||||
- 類型:API(商用授權)
|
||||
- 狀態:planned
|
||||
- 權重:0.85
|
||||
- 用途:賠率備援與市場交叉驗證
|
||||
|
||||
4. Odds API 社群備援 — `odds_api_fallback`
|
||||
- 網址:`https://www.odds-api.com`
|
||||
- 類型:聚合參考
|
||||
- 狀態:reference_only
|
||||
- 權重:0.75
|
||||
- 用途:主幹與外部市場口徑偏差觀察
|
||||
|
||||
### 3. 新聞與事件訊號
|
||||
|
||||
1. NewsAPI — `newsapi`
|
||||
- 網址:`https://newsapi.org`
|
||||
- 類型:API
|
||||
- 狀態:conditional
|
||||
- 權重:0.82
|
||||
- 用途:有金鑰時抓取英文新聞與情緒信號
|
||||
|
||||
2. Google News RSS — `google_news_rss`
|
||||
- 網址:`https://news.google.com`
|
||||
- 類型:RSS
|
||||
- 狀態:active
|
||||
- 權重:0.70
|
||||
- 用途:無金鑰新聞備援,保證資訊補充持續可用
|
||||
|
||||
3. Reuters — `reuters`
|
||||
- 網址:`https://www.reuters.com/sports`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.90
|
||||
- 用途:突發與敏感事件高可信核對
|
||||
|
||||
4. BBC Sport — `bbc_sport`
|
||||
- 網址:`https://www.bbc.com/sport`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.84
|
||||
- 用途:主流新聞來源交叉核對
|
||||
|
||||
5. ESPN FC — `espn`
|
||||
- 網址:`https://www.espn.com/soccer/`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.80
|
||||
- 用途:前瞻、輪替、戰術文本信號
|
||||
|
||||
6. Goal.com — `goal`
|
||||
- 網址:`https://www.goal.com`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.74
|
||||
- 用途:傷病與賽前動態補充
|
||||
|
||||
7. Sky Sports — `skysports`
|
||||
- 網址:`https://www.skysports.com/football`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.69
|
||||
- 用途:歐洲戰術與賽前觀察補充
|
||||
|
||||
8. The Guardian — `guardian_sport`
|
||||
- 網址:`https://www.theguardian.com/football`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.71
|
||||
- 用途:深度報導、風險語意交叉核對
|
||||
|
||||
9. Associated Press — `associated_press`
|
||||
- 網址:`https://apnews.com`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.77
|
||||
- 用途:突發事件高可見度校驗
|
||||
|
||||
10. OneFootball — `onefootball`
|
||||
- 網址:`https://onefootball.com`
|
||||
- 類型:官方媒體
|
||||
- 狀態:reference_only
|
||||
- 權重:0.66
|
||||
- 用途:跨區域社群與賽事摘要補充
|
||||
|
||||
### 4. 球隊/球員與高階數據
|
||||
|
||||
1. SofaScore — `sofascore`
|
||||
- 網址:`https://www.sofascore.com`
|
||||
- 類型:Web
|
||||
- 狀態:planned
|
||||
- 權重:0.76
|
||||
- 用途:先發與戰況補強觀測
|
||||
|
||||
2. WhoScored — `whoscored`
|
||||
- 網址:`https://www.whoscored.com`
|
||||
- 類型:Web
|
||||
- 狀態:planned
|
||||
- 權重:0.81
|
||||
- 用途:場面效率與球員表現深度指標
|
||||
|
||||
3. FBref — `fbref`
|
||||
- 網址:`https://fbref.com`
|
||||
- 類型:Web
|
||||
- 狀態:planned
|
||||
- 權重:0.78
|
||||
- 用途:xG、節奏、每90分鐘效率
|
||||
|
||||
4. Understat — `understat`
|
||||
- 網址:`https://understat.com`
|
||||
- 類型:Web
|
||||
- 狀態:planned
|
||||
- 權重:0.79
|
||||
- 用途:xG 分佈與得分質量
|
||||
|
||||
5. WorldFootball.net — `worldfootball`
|
||||
- 網址:`https://www.worldfootball.net`
|
||||
- 類型:Web
|
||||
- 狀態:reference_only
|
||||
- 權重:0.64
|
||||
- 用途:歷史交手、賽事流程補充
|
||||
|
||||
6. Soccerway — `soccerway`
|
||||
- 網址:`https://int.soccerway.com`
|
||||
- 類型:Web
|
||||
- 狀態:reference_only
|
||||
- 權重:0.62
|
||||
- 用途:賽程與歷史進程交叉
|
||||
|
||||
7. Transfermarkt — `transfermarkt`
|
||||
- 網址:`https://www.transfermarkt.com`
|
||||
- 類型:Web
|
||||
- 狀態:reference_only
|
||||
- 權重:0.70
|
||||
- 用途:球員輪值、傷停、出場補充
|
||||
|
||||
8. Statbunker — `statbunker`
|
||||
- 網址:`https://www.statbunker.com`
|
||||
- 類型:Web
|
||||
- 狀態:reference_only
|
||||
- 權重:0.58
|
||||
- 用途:場均效率與歷史指標補充
|
||||
|
||||
9. Footystats — `footystats`
|
||||
- 網址:`https://footystats.org`
|
||||
- 類型:Web
|
||||
- 狀態:reference_only
|
||||
- 權重:0.60
|
||||
- 用途:效率模型參考
|
||||
|
||||
### 5. 比賽環境(天氣)
|
||||
|
||||
1. Open-Meteo — `open_meteo`
|
||||
- 網址:`https://open-meteo.com`
|
||||
- 類型:氣象 API
|
||||
- 狀態:planned
|
||||
- 權重:0.68
|
||||
- 用途:風速、溫濕度、降雨條件修正
|
||||
|
||||
2. WeatherAPI — `weatherapi`
|
||||
- 網址:`https://www.weatherapi.com`
|
||||
- 類型:氣象 API
|
||||
- 狀態:planned
|
||||
- 權重:0.64
|
||||
- 用途:天候補強與模型對照
|
||||
|
||||
3. OpenWeather — `openweathermap`
|
||||
- 網址:`https://openweathermap.org`
|
||||
- 類型:氣象 API
|
||||
- 狀態:planned
|
||||
- 權重:0.60
|
||||
- 用途:天氣交叉比對備援
|
||||
|
||||
## 三、資料使用原則(專業投注研究)
|
||||
|
||||
1. 先用官方公告與賽程基準定義「比賽可比性」。
|
||||
2. 以 The Odds API 作為主幹,計算市場共識機率與賠率偏離。
|
||||
3. 將新聞事件(Reuter/ESPN/Goal/Google RSS)轉為「風險熱度」與「臨場訊號」。
|
||||
4. 在 2026 世界盃短天數高密度賽事下,優先看多源一致方向,降低單源誤差對單場判斷影響。
|
||||
5. 使用官方/高可信來源權重作為優先裁決條件,reference_only 僅作核查,不與 active 源同等作決策。
|
||||
|
||||
## 四、系統落地映射
|
||||
|
||||
- `GET /api/source-registry`:回傳本台帳中的每個來源、整合層級、目前 runtime 狀態、延遲與錯誤摘要。
|
||||
- `GET /api/today-insights`:回傳台北時區「今日可下注賽事」高/中/低/爆冷摘要,做為首頁主題化投研入口資料。
|
||||
- 主幹賠率:`the_odds_api`(失敗會直接反映為 source-registry error)
|
||||
- 新聞雙通道:`newsapi`(有金鑰)與 `google_news_rss` fallback
|
||||
- 參考層:`reference_only` 僅保留為研究證據,不列為即時決策主因
|
||||
|
||||
## 五、建議的下一階段高價值接入
|
||||
|
||||
1. 將 `weatherapi/open_meteo/openweathermap` 其中一個正式接入,做場地環境修正模型。
|
||||
2. 接入至少一個可授權備援 odds provider(如 Sportradar 或 API-FOOTBALL)降低單點失效。
|
||||
3. 訓練新聞關鍵字模型(`injury`、`rotation`、`rest`)到權重輸入,提升短期決策穩定性。
|
||||
86
docs/professional-market-architecture.md
Normal file
86
docs/professional-market-architecture.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 2026 世界盃對標市場主流版:資料驅動量化架構藍圖(Gemini 路線)
|
||||
|
||||
## 一、目標
|
||||
將網站從「賠率列表」提升為「市場主流級決策中心」:
|
||||
- 跨平台賠率矩陣化(多書比較)
|
||||
- 走勢與成交量監測(Line Movement + Sharp/Public 分流)
|
||||
- 量化模型直接回傳投注信號(EV / Poisson / Monte Carlo)
|
||||
- 個人資金回報與 CLV 監控(Portfolio Tracker)
|
||||
|
||||
## 二、資料分層
|
||||
- **Ingestion(來源層)**:主力 Odds API + 可擴展的 Odds/Stats/News Feed。
|
||||
- **Normalization(正規化層)**:統一 `matchId / teamId / market / outcome`。
|
||||
- **Feature Store(特徵層)**:暫存賽事快照、盤口快取、賽中事件。
|
||||
- **Analytics Engine(分析層)**:EV、Poisson、Monte Carlo 的模型輸出。
|
||||
- **Presentation Layer(展示層)**:儀表板頁面 + API。
|
||||
|
||||
## 三、資料庫建議(PostgreSQL)
|
||||
以下為最小可用表結構:
|
||||
|
||||
### teams
|
||||
- `id` UUID
|
||||
- `name` TEXT
|
||||
- `fifa_code` TEXT
|
||||
|
||||
### matches
|
||||
- `id` UUID
|
||||
- `provider_match_id` TEXT
|
||||
- `stage` TEXT
|
||||
- `kickoff_at` TIMESTAMPTZ
|
||||
- `venue` TEXT
|
||||
- `city` TEXT
|
||||
- `altitude` NUMERIC
|
||||
- `home_team_id` UUID
|
||||
- `away_team_id` UUID
|
||||
|
||||
### odds_history`(建議以 `match_id, provider, market, updated_at` 分區)
|
||||
- `id` BIGSERIAL
|
||||
- `match_id` UUID
|
||||
- `provider` TEXT
|
||||
- `market` TEXT
|
||||
- `outcome` TEXT
|
||||
- `point` NUMERIC
|
||||
- `odds` NUMERIC
|
||||
- `updated_at` TIMESTAMPTZ
|
||||
|
||||
### betting_tickets
|
||||
- `id` BIGSERIAL
|
||||
- `match_id` UUID
|
||||
- `market` TEXT
|
||||
- `selection` TEXT
|
||||
- `point` NUMERIC
|
||||
- `tickets_pct` NUMERIC
|
||||
- `handle_pct` NUMERIC
|
||||
- `provider` TEXT
|
||||
- `snapshot_at` TIMESTAMPTZ
|
||||
|
||||
### portfolio_bets(本機用量化追蹤)
|
||||
- `id` UUID
|
||||
- `match_id` UUID
|
||||
- `market` TEXT
|
||||
- `selection` TEXT
|
||||
- `odds` NUMERIC
|
||||
- `stake` NUMERIC
|
||||
- `result` TEXT
|
||||
- `settled_at` TIMESTAMPTZ
|
||||
|
||||
## 四、快取與即時性
|
||||
- **Redis key 建議**
|
||||
- `live:match:{matchId}:latest`
|
||||
- `odds:market:{matchId}:{market}`
|
||||
- `stats:line:{matchId}:{market}:{outcome}`
|
||||
|
||||
## 五、量化分析模型
|
||||
- **EV Detector**:以模型機率與市場隱含機率對照,EV > 0 直接產生 `value_bet=true`。
|
||||
- **Poisson**:推估雙方進球分佈與比分機率,輸出 1-3 機率最高 score。
|
||||
- **Monte Carlo**:10k 次模擬輸出「淨勝 2 球以上」、「高波動場次」
|
||||
|
||||
## 六、實作進度接軸
|
||||
本次提交已完成:
|
||||
1. 量化模組核心引擎(Node)
|
||||
2. 市場矩陣 + 線路變化 API
|
||||
3. 資金流偏差與走勢資料快照 API
|
||||
4. 組合/組合式投資組合 CLV API(in-memory prototype)
|
||||
5. 量化儀表頁樣式與 API 串接骨架
|
||||
|
||||
後續可無縫接入 PostgreSQL/Redis:將 in-memory 資料改為 DB Repository 注入。
|
||||
35
ops/deploy-host-assessment.md
Normal file
35
ops/deploy-host-assessment.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 192.168.0.XXX 主機快速評估與正式上線建議
|
||||
|
||||
## 目前可觀測結果(已完成)
|
||||
|
||||
檢查指標:
|
||||
- ping 可達性
|
||||
- SSH(22)/HTTP(80) 開放
|
||||
- HTTP 回應(首頁)
|
||||
- 若你要正式流量,另外請再補 `CPU/MEM/磁碟/I/O/端口衝突/服務穩定性` 實測
|
||||
|
||||
結果:
|
||||
- `192.168.0.110`: HTTP 301(可能有既有反向代理/導向)
|
||||
- `192.168.0.188`: HTTP 200(直接可回應)
|
||||
- `192.168.0.120`: port 80 未開
|
||||
- `192.168.0.121`: port 80 未開
|
||||
|
||||
對外正式網址建議:`2026fifa.wooo.work`
|
||||
|
||||
## 建議
|
||||
|
||||
- **主站上線:`192.168.0.188`**
|
||||
- 優先原因:已能直接回 HTTP 200,網頁入口可立即承接。
|
||||
- 作為正式環境主節點建議。
|
||||
- 備援方案:
|
||||
- `192.168.0.110` 可作為備援節點,需先確認 301 重導目標與既有服務衝突後再掛上。
|
||||
- `120` / `121` 建議先做服務預配置(開放 80/443 + 反代),再參與正式流量。
|
||||
|
||||
## 正式上線前最小實測
|
||||
|
||||
1. CPU 平均負載、記憶體、磁碟 I/O、開放端口檢查
|
||||
2. 24 小時壓測與 API 快速刷新測試(以 20~60 秒輪詢為基準)
|
||||
3. `The Odds API` 呼叫量與速率配額驗證
|
||||
4. 新聞源可用率(RSS/NewsAPI)失敗 fallback 驗證
|
||||
5. 自動重啟(PM2)與 crash 回復演練
|
||||
6. 回滾流程:新版本失敗 2 分鐘內可回到上版前容器
|
||||
62
ops/deploy-production.sh
Executable file
62
ops/deploy-production.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-192.168.0.188}"
|
||||
PROJECT_ROOT="${2:-/opt/fifa2026}"
|
||||
PUBLIC_ORIGIN="${3:-https://2026fifa.wooo.work}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-${USER}}"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${LOCAL_DIR}/.env"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "[deploy] ERROR: .env not found at ${ENV_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy] target=${HOST} path=${PROJECT_ROOT}"
|
||||
echo "[deploy] rsync source=${LOCAL_DIR}"
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new "${DEPLOY_USER}@${HOST}" "mkdir -p ${PROJECT_ROOT}/shared ${PROJECT_ROOT}/logs"
|
||||
|
||||
rsync -avz --delete \
|
||||
--exclude node_modules \
|
||||
--exclude .git \
|
||||
--exclude .env \
|
||||
"${LOCAL_DIR}/" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/current/"
|
||||
|
||||
scp "${ENV_FILE}" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/.env"
|
||||
|
||||
ssh "${DEPLOY_USER}@${HOST}" "PROJECT_ROOT='${PROJECT_ROOT}'; \
|
||||
mkdir -p ${PROJECT_ROOT}/current; \
|
||||
cd ${PROJECT_ROOT}/current; \
|
||||
if [[ -f .env ]]; then :; else cp ${PROJECT_ROOT}/.env .env; fi; \
|
||||
if ! grep -q '^APP_PUBLIC_ORIGIN=' ${PROJECT_ROOT}/.env; then \
|
||||
echo \"APP_PUBLIC_ORIGIN=${PUBLIC_ORIGIN}\" >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
if ! grep -q '^APP_TIME_ZONE=' ${PROJECT_ROOT}/.env; then \
|
||||
echo 'APP_TIME_ZONE=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
if ! grep -q '^TZ=' ${PROJECT_ROOT}/.env; then \
|
||||
echo 'TZ=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
if ! grep -q '^KELLY_SCALE=' ${PROJECT_ROOT}/.env; then \
|
||||
echo 'KELLY_SCALE=0.6' >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
node -v >/tmp/node-version.txt 2>/dev/null || true; \
|
||||
if ! command -v npm >/dev/null; then \
|
||||
echo '[deploy] ERROR: npm not found'; exit 1; \
|
||||
fi; \
|
||||
npm install --omit=dev; \
|
||||
if ! command -v pm2 >/dev/null; then \
|
||||
npm install pm2 >/tmp/pm2-install.log 2>&1; \
|
||||
fi; \
|
||||
PM2_BIN=\"\$(command -v pm2 || true)\"; \
|
||||
if [ -z \"\${PM2_BIN}\" ]; then \
|
||||
PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \
|
||||
fi; \
|
||||
[ -x \"\${PM2_BIN}\" ] || PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \
|
||||
[ -x \"\${PM2_BIN}\" ] || (echo '[deploy] ERROR: pm2 not found' && exit 1); \
|
||||
TZ=Asia/Taipei APP_TIME_ZONE=Asia/Taipei NODE_ENV=production \"\${PM2_BIN}\" startOrReload \"\${PROJECT_ROOT}/current/ops/pm2.config.js\" --env production 2>&1 | cat; \
|
||||
\"\${PM2_BIN}\" save"
|
||||
|
||||
echo "[deploy] deploy done"
|
||||
62
ops/healthcheck-production.sh
Executable file
62
ops/healthcheck-production.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-192.168.0.188}"
|
||||
PORT="${2:-3108}"
|
||||
PUBLIC_HOST="${3:-2026fifa.wooo.work}"
|
||||
|
||||
normalize_host() {
|
||||
local host="$1"
|
||||
if [[ "$host" == http://* ]] || [[ "$host" == https://* ]]; then
|
||||
printf '%s' "$host"
|
||||
return
|
||||
fi
|
||||
printf 'https://%s' "$host"
|
||||
}
|
||||
|
||||
PUBLIC_URL="$(normalize_host "$PUBLIC_HOST")"
|
||||
[[ "$PUBLIC_URL" == */ ]] && PUBLIC_URL="${PUBLIC_URL%/}"
|
||||
PUBLIC_URL="${PUBLIC_URL}/api/health"
|
||||
INTERNAL_URL="http://${HOST}:${PORT}/api/health"
|
||||
|
||||
echo "[health] check public=${PUBLIC_URL}"
|
||||
echo "[health] check internal=${INTERNAL_URL} (fallback)"
|
||||
for i in {1..30}; do
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
data=""
|
||||
if resp="$(curl -sS --max-time 6 "${PUBLIC_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then
|
||||
data="${resp}"
|
||||
elif resp="$(curl -sS --max-time 6 "${INTERNAL_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then
|
||||
data="${resp}"
|
||||
fi
|
||||
|
||||
if [[ -z "${data}" ]]; then
|
||||
sleep 4
|
||||
continue
|
||||
fi
|
||||
status="$(echo "${data}" | jq -r '.status')"
|
||||
count="$(echo "${data}" | jq -r '.matchCount')"
|
||||
publicOrigin="$(echo "${data}" | jq -r '.publicOrigin // "-"')"
|
||||
tz="$(echo "${data}" | jq -r '.timeZone // "unknown"')"
|
||||
updated="$(echo "${data}" | jq -r '.lastUpdatedTaipei // "-"')"
|
||||
echo "[health] #${i} status=${status} matches=${count} public=${publicOrigin} timezone=${tz} updated=${updated}"
|
||||
if [[ "${status}" == "ready" && "${count}" != "0" ]]; then
|
||||
echo "[health] production api ready"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${PUBLIC_URL}")"
|
||||
if [[ "${code}" != "200" ]]; then
|
||||
code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${INTERNAL_URL}")"
|
||||
fi
|
||||
echo "[health] #${i} http=${code}"
|
||||
if [[ "${code}" == "200" ]]; then
|
||||
echo "[health] production endpoint returns ok"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
|
||||
echo "[health] ERROR: not healthy"
|
||||
exit 1
|
||||
37
ops/nginx-2026fifa.conf
Normal file
37
ops/nginx-2026fifa.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name 2026fifa.wooo.work;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name 2026fifa.wooo.work;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/2026fifa.wooo.work/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/2026fifa.wooo.work/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header Referrer-Policy same-origin;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
client_max_body_size 16m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3108;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
25
ops/pm2.config.js
Normal file
25
ops/pm2.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'fifa2026-betting-desk',
|
||||
script: './src/server.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
TZ: 'Asia/Taipei',
|
||||
APP_TIME_ZONE: 'Asia/Taipei',
|
||||
APP_PUBLIC_ORIGIN: 'https://2026fifa.wooo.work',
|
||||
},
|
||||
env_file: '/opt/fifa2026/.env',
|
||||
out_file: '/opt/fifa2026/logs/out.log',
|
||||
error_file: '/opt/fifa2026/logs/error.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_memory_restart: '700M',
|
||||
max_restarts: 15,
|
||||
restart_delay: 2000,
|
||||
merge_logs: true,
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "fifa2026-betting-desk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"description": "2026 FIFA World Cup odds + professional betting strategy dashboard",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.8",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
7
platform/alerts/.env.example
Normal file
7
platform/alerts/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
TELEGRAM_BOT_TOKEN=replace-me
|
||||
TELEGRAM_CHAT_ID=replace-me
|
||||
EV_THRESHOLD=0.05
|
||||
SHARP_THRESHOLD=0.80
|
||||
ALERT_POLL_SECONDS=10
|
||||
|
||||
9
platform/alerts/Dockerfile
Normal file
9
platform/alerts/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY alert_worker.py ./
|
||||
CMD ["python", "alert_worker.py"]
|
||||
|
||||
177
platform/alerts/alert_worker.py
Normal file
177
platform/alerts/alert_worker.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
def calc_expected_value(win_prob: float, odds: float, stake: float = 1.0) -> float:
|
||||
if odds <= 1 or not (0 <= win_prob <= 1):
|
||||
return 0.0
|
||||
loss = (1 - win_prob) * stake
|
||||
return win_prob * (odds * stake - stake) - loss
|
||||
|
||||
|
||||
def is_ev_plus(ev: float, threshold: float = 0.05) -> bool:
|
||||
return ev > threshold
|
||||
|
||||
|
||||
def is_sharp_money_anomaly(handle_share: float, threshold: float = 0.8) -> bool:
|
||||
return handle_share >= threshold
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlertRecord:
|
||||
match_id: str
|
||||
market: str
|
||||
odds: float
|
||||
ev: float
|
||||
handle_share: float
|
||||
|
||||
|
||||
class TelegramNotifier:
|
||||
def __init__(self, token: str, chat_id: str, *, timeout: float = 10.0) -> None:
|
||||
self.token = token
|
||||
self.chat_id = chat_id
|
||||
self.client = httpx.AsyncClient(timeout=timeout)
|
||||
|
||||
async def send_alert(self, message: str) -> bool:
|
||||
if not self.token or not self.chat_id:
|
||||
return False
|
||||
|
||||
url = f'https://api.telegram.org/bot{self.token}/sendMessage'
|
||||
payload = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': message,
|
||||
'parse_mode': 'Markdown',
|
||||
}
|
||||
response = await self.client.post(url, json=payload)
|
||||
return response.is_success
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
class AlertService:
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str,
|
||||
notifier: TelegramNotifier,
|
||||
*,
|
||||
poll_interval_seconds: int = 10,
|
||||
sample_interval_seconds: int = 900,
|
||||
ev_threshold: float = 0.05,
|
||||
sharp_threshold: float = 0.80,
|
||||
) -> None:
|
||||
self.redis = Redis.from_url(redis_url, decode_responses=True)
|
||||
self.notifier = notifier
|
||||
self.poll_interval_seconds = poll_interval_seconds
|
||||
self.sample_interval_seconds = sample_interval_seconds
|
||||
self.ev_threshold = ev_threshold
|
||||
self.sharp_threshold = sharp_threshold
|
||||
|
||||
async def _format_alert(self, alert: AlertRecord) -> str:
|
||||
return (
|
||||
'🚨 *高價值投注警報*\n'
|
||||
f'賽事:{alert.match_id}\n'
|
||||
f'市場:{alert.market}\n'
|
||||
f'目前賠率:{alert.odds:.2f}\n'
|
||||
f'EV:{alert.ev * 100:.2f}%\n'
|
||||
f'聰明錢資金占比:{alert.handle_share * 100:.1f}%\n'
|
||||
'建議:檢視盤口失衡與新聞情緒是否同步變動。'
|
||||
)
|
||||
|
||||
async def _should_send(self, match_id: str, market: str) -> bool:
|
||||
key = f'alert:sent:{match_id}:{market}'
|
||||
result = await self.redis.set(name=key, value='1', ex=self.sample_interval_seconds, nx=True)
|
||||
return bool(result)
|
||||
|
||||
async def _scan_candidates(self) -> list[tuple[str, dict[str, object]]]:
|
||||
# 標準 key 形狀:odds:latest:{match_id}:{market}
|
||||
keys = await self.redis.keys('odds:latest:*')
|
||||
out: list[tuple[str, dict[str, object]]] = []
|
||||
for key in keys:
|
||||
raw = await self.redis.get(key)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
out.append((key, payload))
|
||||
return out
|
||||
|
||||
async def evaluate_loop(self) -> None:
|
||||
def to_float(value: object, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def normalize_share(value: float) -> float:
|
||||
# 支援 0~1 或 0~100 兩種輸入格式
|
||||
if value > 1:
|
||||
return value / 100
|
||||
return value
|
||||
|
||||
try:
|
||||
while True:
|
||||
candidates = await self._scan_candidates()
|
||||
for _, payload in candidates:
|
||||
match_id = payload.get('matchId') or payload.get('match_id')
|
||||
market = payload.get('market', 'unknown')
|
||||
if not match_id:
|
||||
continue
|
||||
|
||||
odds = to_float(payload.get('odds', 0.0))
|
||||
win_prob = to_float(payload.get('winProbability', payload.get('probability', 0.0)))
|
||||
handle_share = normalize_share(
|
||||
to_float(payload.get('handleShare', payload.get('handle_ratio', 0.0))),
|
||||
)
|
||||
|
||||
ev = calc_expected_value(win_prob, odds, stake=1.0)
|
||||
if not (is_ev_plus(ev, self.ev_threshold) or is_sharp_money_anomaly(handle_share, self.sharp_threshold)):
|
||||
continue
|
||||
|
||||
should_send = await self._should_send(match_id, market)
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
message = await self._format_alert(AlertRecord(
|
||||
match_id=match_id,
|
||||
market=market,
|
||||
odds=odds,
|
||||
ev=ev,
|
||||
handle_share=handle_share,
|
||||
))
|
||||
|
||||
await self.notifier.send_alert(message)
|
||||
await asyncio.sleep(self.poll_interval_seconds)
|
||||
finally:
|
||||
await self.notifier.close()
|
||||
await self.redis.close()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
bot_token = os.environ.get('TELEGRAM_BOT_TOKEN', '')
|
||||
chat_id = os.environ.get('TELEGRAM_CHAT_ID', '')
|
||||
ev_threshold = float(os.environ.get('EV_THRESHOLD', '0.05'))
|
||||
sharp_threshold = float(os.environ.get('SHARP_THRESHOLD', '0.80'))
|
||||
poll_seconds = int(os.environ.get('ALERT_POLL_SECONDS', '10'))
|
||||
|
||||
notifier = TelegramNotifier(bot_token, chat_id)
|
||||
svc = AlertService(
|
||||
redis_url,
|
||||
notifier,
|
||||
poll_interval_seconds=poll_seconds,
|
||||
ev_threshold=ev_threshold,
|
||||
sharp_threshold=sharp_threshold,
|
||||
)
|
||||
await svc.evaluate_loop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
2
platform/alerts/requirements.txt
Normal file
2
platform/alerts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
httpx==0.28.1
|
||||
redis==5.2.1
|
||||
5
platform/backend/.env.example
Normal file
5
platform/backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
PORT=8000
|
||||
DATABASE_URL=postgresql://fifa_user:change_me@localhost:5432/fifa2026
|
||||
LOG_LEVEL=info
|
||||
|
||||
22
platform/backend/Dockerfile
Normal file
22
platform/backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /build
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/usr/local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY app /app/app
|
||||
|
||||
WORKDIR /app/app
|
||||
CMD ["python", "main.py"]
|
||||
1
platform/backend/app/__init__.py
Normal file
1
platform/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
95
platform/backend/app/analytics/__init__.py
Normal file
95
platform/backend/app/analytics/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""量化分析模組匯總。"""
|
||||
|
||||
from .engine import calculate_value_bet, PoissonPredictor, adjust_away_defense_for_altitude
|
||||
from .ev_calculator import calculate_expected_value
|
||||
from .feature_engineering import MatchFeatureExtractor, MatchFeatureVector
|
||||
from .kelly import KellyResult, calculate_kelly_fraction
|
||||
from .ml_inference import XGBoostPredictor, XGBoostPrediction
|
||||
from .player_props import (
|
||||
PlayerPropsProfile,
|
||||
PlayerPropsSimulationResult,
|
||||
PropMetric,
|
||||
evaluate_top_edge,
|
||||
simulate_player_prop_probability,
|
||||
)
|
||||
from .ml_ensemble import (
|
||||
FEATURE_COLUMNS,
|
||||
EnsembleModelArtifact,
|
||||
build_default_ensemble_artifact,
|
||||
calculate_model_edges,
|
||||
model_predict_probabilities,
|
||||
normalize_feature_payload,
|
||||
train_match_outcome_ensemble,
|
||||
)
|
||||
from .backtesting import BacktestTradeRecord, StrategyFilter, filter_trades, run_flat_stake_backtest
|
||||
from .poisson_model import PoissonMatchPredictor
|
||||
from .referee_analyzer import calculate_cards_ev
|
||||
from .environment_model import adjust_team_strength_for_environment
|
||||
from .referee_weather import MatchConditionSignal, evaluate_match_conditions
|
||||
from .rlm import ReverseLineMovementAlert, evaluate_reverse_line_movement
|
||||
from .proof_of_yield import LedgerSummary, ProofOfYieldStore, ProofYieldRecord, compute_clv, compute_pnl
|
||||
from .player_props_sim import PlayerPropsDistribution, evaluate_prop_bet, simulate_player_stats
|
||||
from .sgp_engine import calculate_joint_probability, find_sgp_value
|
||||
from .portfolio_analyzer import analyze_user_leaks
|
||||
from .hedging_calculator import calculate_hedge_amount
|
||||
from .daily_card_generator import generate_daily_card
|
||||
from .vig_remover import (
|
||||
calculate_overround,
|
||||
compare_bookmaker_true_prob,
|
||||
prob_to_decimal_odds,
|
||||
remove_margin_basic,
|
||||
remove_margin_shin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'KellyResult',
|
||||
'BacktestTradeRecord',
|
||||
'PropMetric',
|
||||
'calculate_expected_value',
|
||||
'calculate_value_bet',
|
||||
'calculate_kelly_fraction',
|
||||
'evaluate_top_edge',
|
||||
'PoissonPredictor',
|
||||
'PlayerPropsProfile',
|
||||
'PlayerPropsSimulationResult',
|
||||
'PoissonMatchPredictor',
|
||||
'adjust_away_defense_for_altitude',
|
||||
'adjust_team_strength_for_environment',
|
||||
'filter_trades',
|
||||
'run_flat_stake_backtest',
|
||||
'simulate_player_prop_probability',
|
||||
'StrategyFilter',
|
||||
'FEATURE_COLUMNS',
|
||||
'build_default_ensemble_artifact',
|
||||
'calculate_model_edges',
|
||||
'model_predict_probabilities',
|
||||
'normalize_feature_payload',
|
||||
'train_match_outcome_ensemble',
|
||||
'MatchConditionSignal',
|
||||
'evaluate_match_conditions',
|
||||
'ReverseLineMovementAlert',
|
||||
'evaluate_reverse_line_movement',
|
||||
'LedgerSummary',
|
||||
'ProofOfYieldStore',
|
||||
'ProofYieldRecord',
|
||||
'compute_clv',
|
||||
'compute_pnl',
|
||||
'MatchFeatureExtractor',
|
||||
'MatchFeatureVector',
|
||||
'XGBoostPredictor',
|
||||
'XGBoostPrediction',
|
||||
'PlayerPropsDistribution',
|
||||
'simulate_player_stats',
|
||||
'evaluate_prop_bet',
|
||||
'calculate_joint_probability',
|
||||
'find_sgp_value',
|
||||
'calculate_cards_ev',
|
||||
'calculate_overround',
|
||||
'remove_margin_basic',
|
||||
'remove_margin_shin',
|
||||
'prob_to_decimal_odds',
|
||||
'compare_bookmaker_true_prob',
|
||||
'analyze_user_leaks',
|
||||
'calculate_hedge_amount',
|
||||
'generate_daily_card',
|
||||
]
|
||||
181
platform/backend/app/analytics/backtesting.py
Normal file
181
platform/backend/app/analytics/backtesting.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""自訂策略回測引擎。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BacktestTradeRecord:
|
||||
"""單筆策略投注歷史資料。"""
|
||||
|
||||
trade_id: str
|
||||
settled_at: datetime
|
||||
odds: float
|
||||
is_win: bool
|
||||
stake: float = 100.0
|
||||
altitude_meters: int | None = None
|
||||
handicap: float | None = None
|
||||
weather: str | None = None
|
||||
recent_form_win_rate: float | None = None
|
||||
market_type: str = '1x2'
|
||||
selection: str = 'home'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyFilter:
|
||||
"""回測條件(前端 JSON 可直接對映)。"""
|
||||
|
||||
weather: str | None = None
|
||||
altitude_min_meters: int | None = None
|
||||
altitude_max_meters: int | None = None
|
||||
handicap_min: float | None = None
|
||||
handicap_max: float | None = None
|
||||
recent_win_rate_min: float | None = None
|
||||
recent_win_rate_max: float | None = None
|
||||
market_types: list[str] | None = None
|
||||
start_at: datetime | None = None
|
||||
end_at: datetime | None = None
|
||||
|
||||
|
||||
def _match_filter(record: BacktestTradeRecord, condition: StrategyFilter) -> bool:
|
||||
"""判斷單筆交易是否符合使用者條件。"""
|
||||
|
||||
if condition.weather and (record.weather or '').lower() != condition.weather.lower():
|
||||
return False
|
||||
if condition.altitude_min_meters is not None and (
|
||||
record.altitude_meters is None or record.altitude_meters < condition.altitude_min_meters
|
||||
):
|
||||
return False
|
||||
if condition.altitude_max_meters is not None and (
|
||||
record.altitude_meters is None or record.altitude_meters > condition.altitude_max_meters
|
||||
):
|
||||
return False
|
||||
if condition.handicap_min is not None and (
|
||||
record.handicap is None or record.handicap < condition.handicap_min
|
||||
):
|
||||
return False
|
||||
if condition.handicap_max is not None and (
|
||||
record.handicap is None or record.handicap > condition.handicap_max
|
||||
):
|
||||
return False
|
||||
if condition.recent_win_rate_min is not None and (
|
||||
record.recent_form_win_rate is None or record.recent_form_win_rate < condition.recent_win_rate_min
|
||||
):
|
||||
return False
|
||||
if condition.recent_win_rate_max is not None and (
|
||||
record.recent_form_win_rate is None or record.recent_form_win_rate > condition.recent_win_rate_max
|
||||
):
|
||||
return False
|
||||
if condition.market_types and record.market_type not in condition.market_types:
|
||||
return False
|
||||
if condition.start_at is not None and record.settled_at < condition.start_at:
|
||||
return False
|
||||
if condition.end_at is not None and record.settled_at > condition.end_at:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def filter_trades(
|
||||
trades: list[BacktestTradeRecord],
|
||||
condition: StrategyFilter,
|
||||
) -> list[BacktestTradeRecord]:
|
||||
"""回傳符合條件的策略明細子集合。"""
|
||||
|
||||
return [t for t in trades if _match_filter(t, condition)]
|
||||
|
||||
|
||||
def compute_max_drawdown(equity_curve: list[float]) -> float:
|
||||
"""計算最大回撤(百分比)。"""
|
||||
|
||||
if not equity_curve:
|
||||
return 0.0
|
||||
peak = equity_curve[0]
|
||||
max_drawdown = 0.0
|
||||
for value in equity_curve:
|
||||
if value > peak:
|
||||
peak = value
|
||||
continue
|
||||
drawdown = (peak - value) / peak if peak else 0.0
|
||||
max_drawdown = max(max_drawdown, drawdown)
|
||||
return round(max_drawdown * 100, 4)
|
||||
|
||||
|
||||
def run_flat_stake_backtest(
|
||||
trades: list[BacktestTradeRecord],
|
||||
initial_capital: float = 10000,
|
||||
) -> dict[str, float | int | list[dict[str, float | str]]]:
|
||||
"""固定單注本金(Flat betting)回測。
|
||||
|
||||
回傳:
|
||||
- trade_count:總注單數
|
||||
- hit_count:中獎注數
|
||||
- win_rate:中獎率
|
||||
- final_capital:最終資金
|
||||
- net_profit:淨利潤
|
||||
- roi_percent:ROI
|
||||
- max_drawdown_percent:最大回撤百分比
|
||||
- equity_curve:資產曲線
|
||||
"""
|
||||
|
||||
if initial_capital <= 0:
|
||||
raise ValueError('initial_capital 必須大於 0')
|
||||
|
||||
if not trades:
|
||||
return {
|
||||
'trade_count': 0,
|
||||
'hit_count': 0,
|
||||
'win_rate': 0.0,
|
||||
'final_capital': initial_capital,
|
||||
'net_profit': 0.0,
|
||||
'roi_percent': 0.0,
|
||||
'max_drawdown_percent': 0.0,
|
||||
'equity_curve': [{'ts': datetime.utcnow().isoformat() + 'Z', 'capital': initial_capital}],
|
||||
}
|
||||
|
||||
# 確保輸入依賴的時序,回測才有金融合理性
|
||||
ordered = sorted(trades, key=lambda row: row.settled_at)
|
||||
equity = float(initial_capital)
|
||||
equity_curve: list[dict[str, float | str]] = [
|
||||
{'ts': ordered[0].settled_at.isoformat(), 'capital': equity},
|
||||
]
|
||||
hit = 0
|
||||
total_stake = 0.0
|
||||
|
||||
for trade in ordered:
|
||||
if trade.odds <= 1:
|
||||
raise ValueError(f'賠率錯誤 trade={trade.trade_id}, odds={trade.odds}')
|
||||
stake = trade.stake
|
||||
profit = stake * (trade.odds - 1) if trade.is_win else -stake
|
||||
equity += profit
|
||||
total_stake += stake
|
||||
if trade.is_win:
|
||||
hit += 1
|
||||
equity_curve.append({'ts': trade.settled_at.isoformat(), 'capital': equity})
|
||||
|
||||
if total_stake <= 0:
|
||||
roi = 0.0
|
||||
else:
|
||||
roi = (equity - initial_capital) / total_stake * 100
|
||||
|
||||
win_rate = round(hit / len(ordered) * 100, 4) if ordered else 0.0
|
||||
|
||||
return {
|
||||
'trade_count': len(ordered),
|
||||
'hit_count': hit,
|
||||
'win_rate': win_rate,
|
||||
'final_capital': round(equity, 4),
|
||||
'net_profit': round(equity - initial_capital, 4),
|
||||
'roi_percent': round(roi, 4),
|
||||
'max_drawdown_percent': compute_max_drawdown([float(point['capital']) for point in equity_curve]),
|
||||
'equity_curve': equity_curve,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
'BacktestTradeRecord',
|
||||
'StrategyFilter',
|
||||
'filter_trades',
|
||||
'run_flat_stake_backtest',
|
||||
]
|
||||
188
platform/backend/app/analytics/daily_card_generator.py
Normal file
188
platform/backend/app/analytics/daily_card_generator.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""每日智能注單生成器(Daily Smart Card)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _ev_percent(true_prob: float, decimal_odds: float) -> float:
|
||||
if decimal_odds <= 1:
|
||||
return 0.0
|
||||
implied = 1.0 / decimal_odds
|
||||
# EV = P*(odds-1) - (1-P)*1
|
||||
return ((true_prob * (decimal_odds - 1.0)) - (1.0 - true_prob)) * 100
|
||||
|
||||
|
||||
def _guess_stage(match_index: int) -> str:
|
||||
return '小組賽' if match_index <= 48 else '淘汰賽'
|
||||
|
||||
|
||||
def generate_daily_card(target_date: str, matches: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""
|
||||
依賽事快照回傳 4 大區塊策略建議(安全單關、搏冷、高勝率串關、同場串關)。
|
||||
|
||||
回傳的格式會被前端 /daily-card 與手機版報表一致消化。
|
||||
"""
|
||||
|
||||
safe_singles: list[dict[str, Any]] = []
|
||||
high_risk_singles: list[dict[str, Any]] = []
|
||||
safe_parlays: list[dict[str, Any]] = []
|
||||
sgp_lotteries: list[dict[str, Any]] = []
|
||||
|
||||
total_unit = 0.0
|
||||
|
||||
for idx, match in enumerate(matches):
|
||||
match_id = str(match.get('match_id', f'fallback-{idx+1}'))
|
||||
home_team = str(match.get('home_team', '主隊'))
|
||||
away_team = str(match.get('away_team', '客隊'))
|
||||
odds_home = _safe_float(match.get('odds_home'), default=0)
|
||||
odds_away = _safe_float(match.get('odds_away'), default=0)
|
||||
|
||||
# 用 xG 或開盤機率估算真實機率,若無資料則回退到 0.5
|
||||
home_xg = _safe_float(match.get('home_xg'), default=1.0)
|
||||
away_xg = _safe_float(match.get('away_xg'), default=1.0)
|
||||
xg_sum = max(home_xg + away_xg, 0.01)
|
||||
true_home_prob = home_xg / xg_sum
|
||||
|
||||
stage = _guess_stage(idx + 1)
|
||||
|
||||
# 安全單關:偏向高勝率市場
|
||||
if odds_home > 1 and true_home_prob > 0.55:
|
||||
ev = _ev_percent(true_home_prob, odds_home)
|
||||
if ev > 3:
|
||||
safe_unit = 1.8
|
||||
total_unit += safe_unit
|
||||
safe_singles.append(
|
||||
{
|
||||
'match_id': match_id,
|
||||
'match_label': f'{home_team} vs {away_team}',
|
||||
'market_type': '亞洲讓球',
|
||||
'selection': f'{home_team} -0.25',
|
||||
'target_odds': round(odds_home, 2),
|
||||
'win_prob': round(true_home_prob * 100, 2),
|
||||
'ev_percent': round(ev, 2),
|
||||
'stake_units': round(safe_unit, 2),
|
||||
'recommendation': 'SAFE_SINGLE',
|
||||
'rationale': '高勝率 + 正EV,適合作為核心穩健下注。',
|
||||
},
|
||||
)
|
||||
|
||||
# 高風險搏冷:低勝率但盤口偏高且 EV 過濾
|
||||
away_true = 1.0 - true_home_prob
|
||||
if odds_away > 1 and away_true < 0.35:
|
||||
ev = _ev_percent(away_true, odds_away)
|
||||
if ev > 8:
|
||||
high_risk_unit = 0.35
|
||||
total_unit += high_risk_unit
|
||||
high_risk_singles.append(
|
||||
{
|
||||
'match_id': match_id,
|
||||
'match_label': f'{home_team} vs {away_team}',
|
||||
'market_type': '大小球',
|
||||
'selection': f'{away_team} 不敗',
|
||||
'target_odds': round(odds_away, 2),
|
||||
'win_prob': round(away_true * 100, 2),
|
||||
'ev_percent': round(ev, 2),
|
||||
'stake_units': round(high_risk_unit, 2),
|
||||
'recommendation': 'HIGH_RISK_SINGLE',
|
||||
'rationale': '冷門高賠率,只有在高勝率組合中保留小倉位。',
|
||||
},
|
||||
)
|
||||
|
||||
# 2 串 1 串關:選取高勝率的兩個 SAFE 單關,若連乘機率符合條件
|
||||
if len(safe_singles) >= 2:
|
||||
legs = safe_singles[:2]
|
||||
combined_odds = 1.0
|
||||
combined_prob = 1.0
|
||||
for leg in legs:
|
||||
combined_odds *= leg['target_odds']
|
||||
combined_prob *= leg['win_prob'] / 100
|
||||
|
||||
if combined_prob >= 0.28: # 高勝率門檻(保守)
|
||||
ev = _ev_percent(combined_prob, combined_odds)
|
||||
if ev > 2:
|
||||
stake_units = 1.0
|
||||
total_unit += stake_units
|
||||
safe_parlays.append(
|
||||
{
|
||||
'match_id': 'PARLAY-SAFE',
|
||||
'match_label': ' + '.join(item['match_label'] for item in legs),
|
||||
'market_type': '跨場串關',
|
||||
'selection': '2串1 安全組合',
|
||||
'legs': [
|
||||
{
|
||||
'match_id': item['match_id'],
|
||||
'selection': item['selection'],
|
||||
'odds': item['target_odds'],
|
||||
}
|
||||
for item in legs
|
||||
],
|
||||
'target_odds': round(combined_odds, 2),
|
||||
'win_prob': round(combined_prob * 100, 2),
|
||||
'ev_percent': round(ev, 2),
|
||||
'stake_units': round(stake_units, 2),
|
||||
'recommendation': 'SAFE_PARLAY',
|
||||
'rationale': '同風險組合加總,目標追求高穩健率 + 控制回撤。',
|
||||
'match_stage': _guess_stage(1),
|
||||
},
|
||||
)
|
||||
|
||||
# 同場 SGP:取出 1 個安全 + 1 個搏冷,形成關聯爆擊模板
|
||||
if safe_singles and high_risk_singles:
|
||||
s = safe_singles[0]
|
||||
h = high_risk_singles[0]
|
||||
combo_odds = s['target_odds'] * h['target_odds']
|
||||
combo_prob = (s['win_prob'] / 100) * (h['win_prob'] / 100)
|
||||
if combo_prob > 0:
|
||||
ev = _ev_percent(combo_prob, combo_odds)
|
||||
sgp_lotteries.append(
|
||||
{
|
||||
'match_id': s['match_id'],
|
||||
'match_label': f"{s['match_label']}【同場】",
|
||||
'market_type': 'SGP',
|
||||
'selection': f"{s['selection']} + {h['selection']}",
|
||||
'target_odds': round(combo_odds, 2),
|
||||
'win_prob': round(combo_prob * 100, 2),
|
||||
'ev_percent': round(ev, 2),
|
||||
'stake_units': 0.5,
|
||||
'recommendation': 'SGP_LOTTERY',
|
||||
'rationale': '同場串關需監控相關性,避免同向風險重疊。',
|
||||
'legs': [
|
||||
{'match_id': s['match_id'], 'selection': s['selection'], 'odds': s['target_odds']},
|
||||
{'match_id': h['match_id'], 'selection': h['selection'], 'odds': h['target_odds']},
|
||||
],
|
||||
'match_stage': _guess_stage(1),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
'date': target_date,
|
||||
'total_daily_unit_recommendation': round(total_unit, 2),
|
||||
'summary': (
|
||||
'系統以當日賽程、赔率變動、xG 進攻強度與場次權重回填,'
|
||||
'優先輸出高穩定性單關與可控風險的串關建議。'
|
||||
),
|
||||
'safe_singles': safe_singles,
|
||||
'high_risk_singles': high_risk_singles,
|
||||
'safe_parlays': safe_parlays,
|
||||
'sgp_lotteries': sgp_lotteries,
|
||||
'matched_matches': len(matches),
|
||||
'stage_distribution': {
|
||||
'小組賽': min(len(matches), 48),
|
||||
'淘汰賽': max(0, len(matches) - 48),
|
||||
},
|
||||
}
|
||||
112
platform/backend/app/analytics/engine.py
Normal file
112
platform/backend/app/analytics/engine.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""量化投注引擎(EV、泊松預測、海拔修正)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import poisson
|
||||
|
||||
|
||||
def calculate_value_bet(true_prob: float, decimal_odds: float, *, stake: float = 1.0) -> tuple[float, bool]:
|
||||
"""計算期望值(EV)並判斷是否屬於 Value Bet。
|
||||
|
||||
EV 計算:EV = (勝率 * 利潤) - (敗率 * 本金)
|
||||
其中利潤 = decimal_odds - 1。
|
||||
|
||||
Returns
|
||||
-------
|
||||
ev_pct: float
|
||||
以本金為基底的 EV 百分比(EV / stake)。
|
||||
is_value_bet: bool
|
||||
當 EV > 0.03(3%)回傳 True。
|
||||
"""
|
||||
|
||||
prob = float(true_prob)
|
||||
odds = float(decimal_odds)
|
||||
if not 0 <= prob <= 1 or odds <= 1 or stake <= 0:
|
||||
return 0.0, False
|
||||
|
||||
profit = odds - 1
|
||||
ev = prob * profit - (1 - prob) * stake
|
||||
ev_pct = ev / stake
|
||||
return round(ev_pct, 6), ev_pct > 0.03
|
||||
|
||||
|
||||
class PoissonPredictor:
|
||||
"""球員進球分佈預測器(2x2 進球建模)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home_attack: float,
|
||||
home_defense: float,
|
||||
away_attack: float,
|
||||
away_defense: float,
|
||||
league_avg_goals: float,
|
||||
) -> None:
|
||||
self.home_attack = float(home_attack)
|
||||
self.home_defense = float(home_defense)
|
||||
self.away_attack = float(away_attack)
|
||||
self.away_defense = float(away_defense)
|
||||
self.league_avg_goals = float(league_avg_goals)
|
||||
|
||||
# 以攻守乘積估算 λ,並限制在合理範圍避免極端值發散。
|
||||
home_lambda = league_avg_goals * (self.home_attack / max(self.away_defense, 0.01))
|
||||
away_lambda = league_avg_goals * (self.away_attack / max(self.home_defense, 0.01))
|
||||
self.home_lambda = float(np.clip(home_lambda, 0.02, 6.5))
|
||||
self.away_lambda = float(np.clip(away_lambda, 0.02, 6.5))
|
||||
|
||||
def predict_exact_score(self, home_goals: int, away_goals: int) -> float:
|
||||
"""回傳指定波膽(home_goals, away_goals)發生機率。"""
|
||||
|
||||
p_home = poisson.pmf(home_goals, self.home_lambda)
|
||||
p_away = poisson.pmf(away_goals, self.away_lambda)
|
||||
return float(p_home * p_away)
|
||||
|
||||
def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 10) -> tuple[float, float]:
|
||||
"""回傳(under, over)機率。"""
|
||||
|
||||
goals = pd.MultiIndex.from_product(
|
||||
[range(max_goals + 1), range(max_goals + 1)],
|
||||
names=['home', 'away'],
|
||||
).to_frame(index=False)
|
||||
|
||||
def joint_prob(r: pd.Series) -> float:
|
||||
return float(poisson.pmf(r['home'], self.home_lambda) * poisson.pmf(r['away'], self.away_lambda))
|
||||
|
||||
probs = goals.apply(joint_prob, axis=1)
|
||||
total_goals = goals['home'] + goals['away']
|
||||
under = float(probs[total_goals <= line].sum())
|
||||
over = float(probs[total_goals > line].sum())
|
||||
return under, over
|
||||
|
||||
|
||||
def adjust_away_defense_for_altitude(
|
||||
base_defense_rating: float,
|
||||
venue_altitude_meters: float,
|
||||
*,
|
||||
is_second_half: bool,
|
||||
penalty_factor: float = 0.35,
|
||||
) -> float:
|
||||
"""高海拔下修正客隊防守能力。
|
||||
|
||||
當場地海拔高於 1500m 且處於下半場,套用對數懲罰,
|
||||
代表客隊在氧氣濃度降低下體能下降導致防守效率衰退。
|
||||
"""
|
||||
|
||||
base = float(base_defense_rating)
|
||||
if venue_altitude_meters <= 1500 or not is_second_half:
|
||||
return base
|
||||
|
||||
# 以 log(1 + altitude/1000) 做平滑遞增函式,避免低海拔時劇烈改變。
|
||||
altitude_penalty = penalty_factor * math.log1p(venue_altitude_meters / 1000)
|
||||
return base * (1 - min(max(altitude_penalty, 0), 0.45))
|
||||
|
||||
|
||||
__all__ = [
|
||||
'calculate_value_bet',
|
||||
'PoissonPredictor',
|
||||
'adjust_away_defense_for_altitude',
|
||||
]
|
||||
|
||||
43
platform/backend/app/analytics/environment_model.py
Normal file
43
platform/backend/app/analytics/environment_model.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""比賽環境衰減模型(高海拔與高溫)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def adjust_team_strength_for_environment(
|
||||
base_strength: float,
|
||||
venue_altitude: float,
|
||||
venue_heat_index: float,
|
||||
is_second_half: bool,
|
||||
team_acclimatized: bool,
|
||||
) -> float:
|
||||
"""調整球隊能力值,反映環境壓力。
|
||||
|
||||
- 海拔 > 1500m 且球隊未適應,第二節時套用疲勞衰退。
|
||||
- 熱指數(Heat Index)越高,衰退越明顯。
|
||||
"""
|
||||
|
||||
if base_strength < 0:
|
||||
raise ValueError('base_strength 必須大於等於 0')
|
||||
|
||||
adjusted = float(base_strength)
|
||||
if not is_second_half:
|
||||
return adjusted
|
||||
|
||||
altitude_penalty = 0.0
|
||||
heat_penalty = 0.0
|
||||
|
||||
if not team_acclimatized and venue_altitude >= 1500:
|
||||
# 以對數遞增,1500m 為轉折,3000m 接近上限。
|
||||
altitude_factor = math.log1p((venue_altitude - 1500.0) / 300.0)
|
||||
altitude_penalty = 0.025 + 0.045 * min(altitude_factor, 2.8)
|
||||
|
||||
# 熱指數高於 30,逐步加入疲勞因子,超過 38 非常明顯。
|
||||
if venue_heat_index > 30:
|
||||
heat_excess = min(max(venue_heat_index - 30.0, 0.0), 30.0)
|
||||
heat_penalty = 0.0012 * heat_excess
|
||||
|
||||
total_penalty = altitude_penalty + heat_penalty
|
||||
adjusted *= max(0.2, 1.0 - total_penalty)
|
||||
return adjusted
|
||||
63
platform/backend/app/analytics/ev_calculator.py
Normal file
63
platform/backend/app/analytics/ev_calculator.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""EV(期望值)運算模組。
|
||||
|
||||
本模組提供最基本、可復用的賠率價值判斷邏輯:給定真實勝率與小數賠率,計算期望值與是否為優勢投注。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def calculate_expected_value(
|
||||
true_win_prob: float,
|
||||
decimal_odds: float,
|
||||
stake: float = 100.0,
|
||||
suggested_kelly_fraction: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""計算期望值(EV)並回傳報價建議。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
true_win_prob:
|
||||
模型估計的真實勝率,必須在 0 到 1 之間。
|
||||
decimal_odds:
|
||||
小數制賠率,必須大於 1(否則不具可投注意義)。
|
||||
stake:
|
||||
本次下注本金;同時也是 EV 百分比的基準。
|
||||
suggested_kelly_fraction:
|
||||
由外部凱利公式模組預留的建議資金比例;若未提供則回傳 None。
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
{
|
||||
'ev_value': 實際 EV 金額,
|
||||
'ev_percentage': EV / stake * 100,
|
||||
'is_value_bet': 當 EV% 大於 3% 時為 True,
|
||||
'suggested_kelly_fraction': 傳入值或 None
|
||||
}
|
||||
"""
|
||||
|
||||
if not 0.0 <= true_win_prob <= 1.0:
|
||||
raise ValueError('true_win_prob 必須介於 0 到 1 之間')
|
||||
if decimal_odds <= 1:
|
||||
raise ValueError('decimal_odds 必須大於 1')
|
||||
if stake <= 0:
|
||||
raise ValueError('stake 必須大於 0')
|
||||
|
||||
win_prob = float(true_win_prob)
|
||||
odds = float(decimal_odds)
|
||||
stake_amount = float(stake)
|
||||
|
||||
profit_when_win = odds - 1.0
|
||||
lose_prob = 1.0 - win_prob
|
||||
|
||||
ev = win_prob * profit_when_win * stake_amount - lose_prob * stake_amount
|
||||
ev_percentage = ev / stake_amount * 100
|
||||
|
||||
return {
|
||||
'ev_value': round(ev, 6),
|
||||
'ev_percentage': round(ev_percentage, 4),
|
||||
'is_value_bet': ev_percentage > 3.0,
|
||||
'suggested_kelly_fraction': suggested_kelly_fraction,
|
||||
}
|
||||
163
platform/backend/app/analytics/feature_engineering.py
Normal file
163
platform/backend/app/analytics/feature_engineering.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""進階特徵工程:從資料庫抽取多維比賽特徵。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from math import radians, sin, cos, asin, sqrt
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Match, Team
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchFeatureVector:
|
||||
rest_days_advantage: float
|
||||
travel_distance_km: float
|
||||
recent_5_xg_diff: float
|
||||
elo_rating_diff: float
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Haversine 地球大圓距離(公里)。"""
|
||||
|
||||
R = 6371.0
|
||||
dlat = radians(lat2 - lat1)
|
||||
dlon = radians(lon2 - lon1)
|
||||
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
|
||||
return 2 * R * asin(min(1.0, sqrt(a)))
|
||||
|
||||
|
||||
class MatchFeatureExtractor:
|
||||
"""抽取並生成賽前特徵。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory,
|
||||
*,
|
||||
team_locations: dict[str, tuple[float, float]] | None = None,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
# 可選:{team_id: (lat, lon)},若缺資料則 fallback 為 0 距離。
|
||||
self.team_locations = team_locations or {}
|
||||
|
||||
async def _previous_match(self, session: AsyncSession, team_id: str, match_time: datetime) -> Match | None:
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(
|
||||
and_(
|
||||
(Match.home_team_id == team_id) | (Match.away_team_id == team_id),
|
||||
Match.match_time_utc < match_time,
|
||||
Match.home_xg.is_not(None),
|
||||
Match.away_xg.is_not(None),
|
||||
),
|
||||
)
|
||||
.order_by(desc(Match.match_time_utc))
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _recent_xg_series(self, session: AsyncSession, team_id: str, as_of_match_id: str, count: int = 5) -> list[float]:
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(
|
||||
(Match.home_team_id == team_id) | (Match.away_team_id == team_id),
|
||||
Match.home_xg.is_not(None),
|
||||
Match.away_xg.is_not(None),
|
||||
Match.id != as_of_match_id,
|
||||
)
|
||||
.order_by(desc(Match.match_time_utc))
|
||||
.limit(count)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
out: list[float] = []
|
||||
|
||||
for row in rows:
|
||||
home_xg = float(row.home_xg or 0.0)
|
||||
away_xg = float(row.away_xg or 0.0)
|
||||
out.append(home_xg)
|
||||
out.append(away_xg)
|
||||
|
||||
return out[:count]
|
||||
|
||||
async def extract_features(self, match_id: str) -> MatchFeatureVector:
|
||||
"""產生四個關鍵特徵。
|
||||
|
||||
1) rest_days_advantage
|
||||
2) travel_distance_km
|
||||
3) recent_5_xg_diff
|
||||
4) elo_rating_diff
|
||||
"""
|
||||
|
||||
async with self.session_factory() as session: # type: ignore[assignment]
|
||||
current_match = await session.get(Match, match_id)
|
||||
if current_match is None:
|
||||
raise ValueError(f'找不到 match_id={match_id}')
|
||||
|
||||
home_team = await session.get(Team, current_match.home_team_id)
|
||||
away_team = await session.get(Team, current_match.away_team_id)
|
||||
if home_team is None or away_team is None:
|
||||
raise ValueError('比賽球隊資料不完整')
|
||||
|
||||
home_prev = await self._previous_match(session, home_team.id, current_match.match_time_utc)
|
||||
away_prev = await self._previous_match(session, away_team.id, current_match.match_time_utc)
|
||||
|
||||
rest_home = (
|
||||
(current_match.match_time_utc - home_prev.match_time_utc).days
|
||||
if home_prev is not None
|
||||
else 0
|
||||
)
|
||||
rest_away = (
|
||||
(current_match.match_time_utc - away_prev.match_time_utc).days
|
||||
if away_prev is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
travel_distance = self._distance_between_teams(home_team.id, away_team.id)
|
||||
|
||||
home_xg = await self._recent_xg_series(session, home_team.id, current_match.id)
|
||||
away_xg = await self._recent_xg_series(session, away_team.id, current_match.id)
|
||||
recent_diff = sum(home_xg[:5]) / max(len(home_xg[:5]) or 1, 1) - sum(away_xg[:5]) / max(
|
||||
len(away_xg[:5]) or 1,
|
||||
1,
|
||||
)
|
||||
|
||||
home_elo = float(home_team.current_elo_rating or 1500)
|
||||
away_elo = float(away_team.current_elo_rating or 1500)
|
||||
|
||||
return MatchFeatureVector(
|
||||
rest_days_advantage=float(rest_home - rest_away),
|
||||
travel_distance_km=float(travel_distance),
|
||||
recent_5_xg_diff=float(recent_diff),
|
||||
elo_rating_diff=float(home_elo - away_elo),
|
||||
)
|
||||
|
||||
def _distance_between_teams(self, home_team_id: str, away_team_id: str) -> float:
|
||||
home_loc = self.team_locations.get(home_team_id)
|
||||
away_loc = self.team_locations.get(away_team_id)
|
||||
|
||||
if home_loc is None or away_loc is None:
|
||||
return 0.0
|
||||
|
||||
return float(_haversine_km(home_loc[0], home_loc[1], away_loc[0], away_loc[1]))
|
||||
|
||||
@staticmethod
|
||||
def to_model_payload(features: MatchFeatureVector, columns: Iterable[str] | None = None) -> dict:
|
||||
"""輸出可直接餵進 XGBoost 的特徵字典。"""
|
||||
|
||||
payload = {
|
||||
'rest_days_advantage': features.rest_days_advantage,
|
||||
'travel_distance_km': features.travel_distance_km,
|
||||
'recent_5_xg_diff': features.recent_5_xg_diff,
|
||||
'elo_rating_diff': features.elo_rating_diff,
|
||||
}
|
||||
|
||||
if columns is None:
|
||||
return payload
|
||||
cols = list(columns)
|
||||
return {c: float(payload[c]) for c in cols if c in payload}
|
||||
39
platform/backend/app/analytics/hedging_calculator.py
Normal file
39
platform/backend/app/analytics/hedging_calculator.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""串關動態對沖(Dynamic Hedging)計算器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def calculate_hedge_amount(
|
||||
original_stake: float,
|
||||
parlay_total_odds: float,
|
||||
final_leg_hedge_odds: float,
|
||||
) -> dict[str, float]:
|
||||
"""
|
||||
在 1 場或 2/3 場連贏快到最終局,計算對沖下注金額。
|
||||
|
||||
將原始串關保本化:
|
||||
目標是「串關全過的淨利」與「對沖走向中的淨利」在最後同值。
|
||||
|
||||
設原始串關到位後保底淨利 = S * (O_parlay - 1)
|
||||
對沖選項淨利 = H * (O_hedge - 1)
|
||||
求 H * (O_hedge - 1) = S * (O_parlay - 1) - H
|
||||
=> H = (S * (O_parlay - 1)) / O_hedge
|
||||
"""
|
||||
|
||||
if original_stake <= 0:
|
||||
raise ValueError('original_stake 必須大於 0')
|
||||
if parlay_total_odds <= 1:
|
||||
raise ValueError('parlay_total_odds 必須大於 1')
|
||||
if final_leg_hedge_odds <= 1:
|
||||
raise ValueError('final_leg_hedge_odds 必須大於 1')
|
||||
|
||||
expected_parlay_net = original_stake * (parlay_total_odds - 1)
|
||||
hedge_stake = expected_parlay_net / final_leg_hedge_odds
|
||||
profit_after_hedge = hedge_stake * (final_leg_hedge_odds - 1)
|
||||
|
||||
return {
|
||||
'hedge_stake': round(hedge_stake, 4),
|
||||
'locked_profit': round(profit_after_hedge, 4),
|
||||
'parlay_net_after_hedge_if_win': round(expected_parlay_net - hedge_stake, 4),
|
||||
'hedge_net_if_win': round(profit_after_hedge, 4),
|
||||
}
|
||||
64
platform/backend/app/analytics/kelly.py
Normal file
64
platform/backend/app/analytics/kelly.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""凱利準則(Kelly Criterion)工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KellyResult:
|
||||
"""凱利投注建議結果。"""
|
||||
|
||||
decimal_odds: float
|
||||
win_probability: float
|
||||
raw_kelly_fraction: float
|
||||
fractional_kelly_factor: float
|
||||
risk_tolerance_factor: float
|
||||
final_fraction: float
|
||||
stake_fraction: float
|
||||
|
||||
|
||||
def calculate_kelly_fraction(
|
||||
decimal_odds: float,
|
||||
true_prob: float,
|
||||
*,
|
||||
bankroll: float,
|
||||
fractional_kelly_factor: float = 1.0,
|
||||
risk_tolerance_factor: float = 1.0,
|
||||
) -> KellyResult:
|
||||
"""依凱利準則估算下注比例與建議金額。
|
||||
|
||||
凱利公式:
|
||||
f* = (b * p - q) / b
|
||||
其中 b = odds - 1,p 為勝率,q = 1 - p。
|
||||
"""
|
||||
|
||||
if decimal_odds <= 1:
|
||||
raise ValueError('decimal_odds 必須大於 1')
|
||||
if bankroll <= 0:
|
||||
raise ValueError('bankroll 必須大於 0')
|
||||
if not 0 <= true_prob <= 1:
|
||||
raise ValueError('true_prob 需介於 0 到 1')
|
||||
if not 0 <= fractional_kelly_factor <= 5:
|
||||
raise ValueError('fractional_kelly_factor 須介於 0 到 5')
|
||||
if not 0 <= risk_tolerance_factor <= 2:
|
||||
raise ValueError('risk_tolerance_factor 須介於 0 到 2')
|
||||
|
||||
b = decimal_odds - 1
|
||||
raw_kelly = (b * true_prob - (1 - true_prob)) / b
|
||||
final_fraction = raw_kelly * fractional_kelly_factor * risk_tolerance_factor
|
||||
# 保守處理:避免負值與超過總資金比例(100%)的極端輸出。
|
||||
final_fraction = max(0.0, min(final_fraction, 1.0))
|
||||
|
||||
return KellyResult(
|
||||
decimal_odds=decimal_odds,
|
||||
win_probability=true_prob,
|
||||
raw_kelly_fraction=raw_kelly,
|
||||
fractional_kelly_factor=fractional_kelly_factor,
|
||||
risk_tolerance_factor=risk_tolerance_factor,
|
||||
final_fraction=final_fraction,
|
||||
stake_fraction=final_fraction,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['KellyResult', 'calculate_kelly_fraction']
|
||||
435
platform/backend/app/analytics/ml_ensemble.py
Normal file
435
platform/backend/app/analytics/ml_ensemble.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""機器學習賽果預測引擎(Ensemble)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.model_selection import train_test_split
|
||||
except Exception: # pragma: no cover - 缺少 scikit-learn 時的 fallback
|
||||
GradientBoostingClassifier = None
|
||||
train_test_split = None
|
||||
|
||||
|
||||
FEATURE_COLUMNS = ('rest_days_advantage', 'travel_distance_km', 'recent_5_xg_diff')
|
||||
OUTCOMES = ('home', 'draw', 'away')
|
||||
|
||||
|
||||
def _sigmoid(value: float) -> float:
|
||||
return 1.0 / (1.0 + np.exp(-value))
|
||||
|
||||
|
||||
def _softmax(values: np.ndarray) -> np.ndarray:
|
||||
shifted = values - np.max(values)
|
||||
exp_values = np.exp(shifted)
|
||||
return exp_values / exp_values.sum()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnsembleModelArtifact:
|
||||
"""已訓練的 ML 模組與中繼資料。"""
|
||||
|
||||
model: Any
|
||||
feature_columns: tuple[str, ...]
|
||||
model_id: str
|
||||
training_size: int
|
||||
is_fallback: bool
|
||||
training_accuracy: float | None = None
|
||||
|
||||
|
||||
class _FallbackMatchModel:
|
||||
"""缺少 ML 套件時的保底模型(規則式)。"""
|
||||
|
||||
feature_columns = FEATURE_COLUMNS
|
||||
|
||||
def predict_proba(self, row_df: pd.DataFrame) -> np.ndarray:
|
||||
if row_df.empty:
|
||||
return np.zeros((0, 3))
|
||||
|
||||
x = row_df[self.feature_columns].to_numpy(float)
|
||||
raw_scores = []
|
||||
|
||||
for rest_days_advantage, travel_distance_km, recent_5_xg_diff in x:
|
||||
home_score = 0.6 + rest_days_advantage * 0.022 + recent_5_xg_diff * 0.34 - travel_distance_km * 0.0012
|
||||
draw_score = 0.30 - abs(rest_days_advantage) * 0.015 - abs(recent_5_xg_diff) * 0.22
|
||||
away_score = 0.1 - rest_days_advantage * 0.022 - recent_5_xg_diff * 0.34 + travel_distance_km * 0.0012
|
||||
|
||||
scores = np.array(
|
||||
[
|
||||
_sigmoid(home_score),
|
||||
_sigmoid(draw_score) * 0.9,
|
||||
_sigmoid(away_score),
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
raw_scores.append(_softmax(scores))
|
||||
|
||||
return np.vstack(raw_scores)
|
||||
|
||||
|
||||
def _as_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def normalize_feature_payload(payload: Mapping[str, Any]) -> dict[str, float]:
|
||||
"""從前端或資料庫欄位,萃取核心三大特徵。"""
|
||||
|
||||
home_rest = _as_float(payload.get('home_rest_days'))
|
||||
away_rest = _as_float(payload.get('away_rest_days'))
|
||||
home_travel = _as_float(payload.get('home_travel_distance_km'))
|
||||
away_travel = _as_float(payload.get('away_travel_distance_km'))
|
||||
recent_home = _as_float(payload.get('recent_5_xg_home'))
|
||||
recent_away = _as_float(payload.get('recent_5_xg_away'))
|
||||
|
||||
return {
|
||||
'home_rest_days': home_rest,
|
||||
'away_rest_days': away_rest,
|
||||
'home_travel_distance_km': home_travel,
|
||||
'away_travel_distance_km': away_travel,
|
||||
'recent_5_xg_home': recent_home,
|
||||
'recent_5_xg_away': recent_away,
|
||||
'rest_days_advantage': home_rest - away_rest,
|
||||
'travel_distance_km': home_travel - away_travel,
|
||||
'recent_5_xg_diff': recent_home - recent_away,
|
||||
}
|
||||
|
||||
|
||||
def _validation_frame(rows: list[Mapping[str, Any]]) -> pd.DataFrame:
|
||||
if len(rows) < 5:
|
||||
raise ValueError('訓練樣本少於 5 筆,無法完成穩定訓練')
|
||||
|
||||
frame = pd.DataFrame(rows)
|
||||
required_fields = set(FEATURE_COLUMNS) | {'match_result'}
|
||||
missing = required_fields - set(frame.columns)
|
||||
if missing:
|
||||
raise ValueError(f'訓練資料缺欄位:{sorted(missing)}')
|
||||
|
||||
frame = frame.copy()
|
||||
frame[list(FEATURE_COLUMNS)] = frame[list(FEATURE_COLUMNS)].astype(float).fillna(0.0)
|
||||
frame['match_result'] = frame['match_result'].str.lower().str.strip()
|
||||
|
||||
unknown = set(frame['match_result']) - set(OUTCOMES)
|
||||
if unknown:
|
||||
raise ValueError(f'未知賽果標籤:{sorted(unknown)},僅支援 {OUTCOMES}')
|
||||
return frame
|
||||
|
||||
|
||||
def build_default_ml_training_rows() -> list[dict[str, float | str]]:
|
||||
"""建立保底訓練樣本(當環境無法即時取得外部訓練資料時)。"""
|
||||
|
||||
return [
|
||||
{
|
||||
'home_rest_days': 4,
|
||||
'away_rest_days': 3,
|
||||
'home_travel_distance_km': 520,
|
||||
'away_travel_distance_km': 1100,
|
||||
'recent_5_xg_home': 1.8,
|
||||
'recent_5_xg_away': 1.0,
|
||||
'rest_days_advantage': 1,
|
||||
'travel_distance_km': -580,
|
||||
'recent_5_xg_diff': 0.8,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 2,
|
||||
'away_rest_days': 5,
|
||||
'home_travel_distance_km': 220,
|
||||
'away_travel_distance_km': 780,
|
||||
'recent_5_xg_home': 1.1,
|
||||
'recent_5_xg_away': 1.7,
|
||||
'rest_days_advantage': -3,
|
||||
'travel_distance_km': -560,
|
||||
'recent_5_xg_diff': -0.6,
|
||||
'match_result': 'away',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 6,
|
||||
'away_rest_days': 4,
|
||||
'home_travel_distance_km': 120,
|
||||
'away_travel_distance_km': 960,
|
||||
'recent_5_xg_home': 2.3,
|
||||
'recent_5_xg_away': 1.8,
|
||||
'rest_days_advantage': 2,
|
||||
'travel_distance_km': -840,
|
||||
'recent_5_xg_diff': 0.5,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 3,
|
||||
'away_rest_days': 3,
|
||||
'home_travel_distance_km': 900,
|
||||
'away_travel_distance_km': 900,
|
||||
'recent_5_xg_home': 1.2,
|
||||
'recent_5_xg_away': 1.3,
|
||||
'rest_days_advantage': 0,
|
||||
'travel_distance_km': 0,
|
||||
'recent_5_xg_diff': -0.1,
|
||||
'match_result': 'draw',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 8,
|
||||
'away_rest_days': 2,
|
||||
'home_travel_distance_km': 350,
|
||||
'away_travel_distance_km': 700,
|
||||
'recent_5_xg_home': 2.0,
|
||||
'recent_5_xg_away': 1.2,
|
||||
'rest_days_advantage': 6,
|
||||
'travel_distance_km': -350,
|
||||
'recent_5_xg_diff': 0.8,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 1,
|
||||
'away_rest_days': 2,
|
||||
'home_travel_distance_km': 1600,
|
||||
'away_travel_distance_km': 2500,
|
||||
'recent_5_xg_home': 1.4,
|
||||
'recent_5_xg_away': 2.1,
|
||||
'rest_days_advantage': -1,
|
||||
'travel_distance_km': -900,
|
||||
'recent_5_xg_diff': -0.7,
|
||||
'match_result': 'away',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 5,
|
||||
'away_rest_days': 5,
|
||||
'home_travel_distance_km': 700,
|
||||
'away_travel_distance_km': 700,
|
||||
'recent_5_xg_home': 1.9,
|
||||
'recent_5_xg_away': 1.9,
|
||||
'rest_days_advantage': 0,
|
||||
'travel_distance_km': 0,
|
||||
'recent_5_xg_diff': 0.0,
|
||||
'match_result': 'draw',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 9,
|
||||
'away_rest_days': 3,
|
||||
'home_travel_distance_km': 400,
|
||||
'away_travel_distance_km': 300,
|
||||
'recent_5_xg_home': 2.4,
|
||||
'recent_5_xg_away': 1.1,
|
||||
'rest_days_advantage': 6,
|
||||
'travel_distance_km': 100,
|
||||
'recent_5_xg_diff': 1.3,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 2,
|
||||
'away_rest_days': 7,
|
||||
'home_travel_distance_km': 1800,
|
||||
'away_travel_distance_km': 250,
|
||||
'recent_5_xg_home': 1.0,
|
||||
'recent_5_xg_away': 1.5,
|
||||
'rest_days_advantage': -5,
|
||||
'travel_distance_km': 1550,
|
||||
'recent_5_xg_diff': -0.5,
|
||||
'match_result': 'away',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 4,
|
||||
'away_rest_days': 4,
|
||||
'home_travel_distance_km': 500,
|
||||
'away_travel_distance_km': 500,
|
||||
'recent_5_xg_home': 1.6,
|
||||
'recent_5_xg_away': 1.4,
|
||||
'rest_days_advantage': 0,
|
||||
'travel_distance_km': 0,
|
||||
'recent_5_xg_diff': 0.2,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 6,
|
||||
'away_rest_days': 1,
|
||||
'home_travel_distance_km': 300,
|
||||
'away_travel_distance_km': 1200,
|
||||
'recent_5_xg_home': 2.8,
|
||||
'recent_5_xg_away': 0.8,
|
||||
'rest_days_advantage': 5,
|
||||
'travel_distance_km': -900,
|
||||
'recent_5_xg_diff': 2.0,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 2,
|
||||
'away_rest_days': 6,
|
||||
'home_travel_distance_km': 1000,
|
||||
'away_travel_distance_km': 200,
|
||||
'recent_5_xg_home': 1.0,
|
||||
'recent_5_xg_away': 2.6,
|
||||
'rest_days_advantage': -4,
|
||||
'travel_distance_km': 800,
|
||||
'recent_5_xg_diff': -1.6,
|
||||
'match_result': 'away',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 7,
|
||||
'away_rest_days': 7,
|
||||
'home_travel_distance_km': 650,
|
||||
'away_travel_distance_km': 650,
|
||||
'recent_5_xg_home': 1.8,
|
||||
'recent_5_xg_away': 1.8,
|
||||
'rest_days_advantage': 0,
|
||||
'travel_distance_km': 0,
|
||||
'recent_5_xg_diff': 0.0,
|
||||
'match_result': 'draw',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 3,
|
||||
'away_rest_days': 1,
|
||||
'home_travel_distance_km': 260,
|
||||
'away_travel_distance_km': 900,
|
||||
'recent_5_xg_home': 2.1,
|
||||
'recent_5_xg_away': 1.6,
|
||||
'rest_days_advantage': 2,
|
||||
'travel_distance_km': -640,
|
||||
'recent_5_xg_diff': 0.5,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 0,
|
||||
'away_rest_days': 5,
|
||||
'home_travel_distance_km': 1500,
|
||||
'away_travel_distance_km': 150,
|
||||
'recent_5_xg_home': 1.2,
|
||||
'recent_5_xg_away': 2.0,
|
||||
'rest_days_advantage': -5,
|
||||
'travel_distance_km': 1350,
|
||||
'recent_5_xg_diff': -0.8,
|
||||
'match_result': 'away',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 5,
|
||||
'away_rest_days': 2,
|
||||
'home_travel_distance_km': 300,
|
||||
'away_travel_distance_km': 300,
|
||||
'recent_5_xg_home': 2.2,
|
||||
'recent_5_xg_away': 1.1,
|
||||
'rest_days_advantage': 3,
|
||||
'travel_distance_km': 0,
|
||||
'recent_5_xg_diff': 1.1,
|
||||
'match_result': 'home',
|
||||
},
|
||||
{
|
||||
'home_rest_days': 4,
|
||||
'away_rest_days': 8,
|
||||
'home_travel_distance_km': 450,
|
||||
'away_travel_distance_km': 980,
|
||||
'recent_5_xg_home': 1.5,
|
||||
'recent_5_xg_away': 2.4,
|
||||
'rest_days_advantage': -4,
|
||||
'travel_distance_km': -530,
|
||||
'recent_5_xg_diff': -0.9,
|
||||
'match_result': 'away',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def train_match_outcome_ensemble(
|
||||
training_rows: list[Mapping[str, Any]],
|
||||
*,
|
||||
model_id: str | None = None,
|
||||
) -> EnsembleModelArtifact:
|
||||
"""訓練 1X2 賽果 Ensemble(無法使用 sklearn 時自動回退規則模型)。"""
|
||||
|
||||
normalized = [_normalize_training_row(row) for row in training_rows]
|
||||
frame = _validation_frame(normalized)
|
||||
|
||||
x = frame[list(FEATURE_COLUMNS)]
|
||||
y = frame['match_result'].map({'home': 0, 'draw': 1, 'away': 2})
|
||||
|
||||
if len(frame) < 24 or GradientBoostingClassifier is None or train_test_split is None:
|
||||
return EnsembleModelArtifact(
|
||||
model=_FallbackMatchModel(),
|
||||
feature_columns=FEATURE_COLUMNS,
|
||||
model_id=model_id or uuid4().hex,
|
||||
training_size=len(frame),
|
||||
is_fallback=True,
|
||||
training_accuracy=None,
|
||||
)
|
||||
|
||||
x_train, x_val, y_train, y_val = train_test_split(
|
||||
x,
|
||||
y,
|
||||
test_size=min(0.3, max(0.15, 1 - (30 / len(frame)))),
|
||||
random_state=17,
|
||||
stratify=y,
|
||||
)
|
||||
|
||||
model = GradientBoostingClassifier(
|
||||
random_state=17,
|
||||
n_estimators=220,
|
||||
max_depth=3,
|
||||
learning_rate=0.06,
|
||||
)
|
||||
model.fit(x_train, y_train)
|
||||
accuracy = float(model.score(x_val, y_val)) if len(set(y_val)) > 1 else None
|
||||
|
||||
return EnsembleModelArtifact(
|
||||
model=model,
|
||||
feature_columns=FEATURE_COLUMNS,
|
||||
model_id=model_id or uuid4().hex,
|
||||
training_size=len(frame),
|
||||
is_fallback=False,
|
||||
training_accuracy=accuracy,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_training_row(row: Mapping[str, Any]) -> dict[str, float | str]:
|
||||
normalized = normalize_feature_payload(row)
|
||||
if 'match_result' not in row:
|
||||
raise ValueError('訓練資料缺少 match_result')
|
||||
normalized['match_result'] = str(row['match_result']).strip().lower()
|
||||
return normalized
|
||||
|
||||
|
||||
def build_default_ensemble_artifact() -> EnsembleModelArtifact:
|
||||
"""建立系統預設模型(含 fallback)。"""
|
||||
|
||||
return train_match_outcome_ensemble(build_default_ml_training_rows(), model_id='default')
|
||||
|
||||
|
||||
def model_predict_probabilities(
|
||||
artifact: EnsembleModelArtifact,
|
||||
features: Mapping[str, Any],
|
||||
) -> dict[str, float]:
|
||||
"""回傳 home/draw/away 的機率。"""
|
||||
|
||||
normalized = normalize_feature_payload(features)
|
||||
feature_frame = pd.DataFrame([normalized], columns=artifact.feature_columns)
|
||||
probs = artifact.model.predict_proba(feature_frame)[0]
|
||||
return {
|
||||
'home': float(probs[0]),
|
||||
'draw': float(probs[1]),
|
||||
'away': float(probs[2]),
|
||||
}
|
||||
|
||||
|
||||
def calculate_model_edges(
|
||||
predicted: dict[str, float],
|
||||
implied: dict[str, float],
|
||||
) -> dict[str, dict[str, float | bool]]:
|
||||
"""比較模型機率與莊家隱含機率,標示 Strong Buy。"""
|
||||
|
||||
edges: dict[str, dict[str, float | bool]] = {}
|
||||
for key in OUTCOMES:
|
||||
p = float(predicted.get(key, 0))
|
||||
i = float(implied.get(key, 0))
|
||||
edge = p - i
|
||||
edges[key] = {
|
||||
'model_prob': round(p, 6),
|
||||
'implied_prob': round(i, 6),
|
||||
'edge': round(edge, 6),
|
||||
'strong_buy': edge >= 0.04,
|
||||
}
|
||||
return edges
|
||||
|
||||
99
platform/backend/app/analytics/ml_inference.py
Normal file
99
platform/backend/app/analytics/ml_inference.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""XGBoost 推論 API 套件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from xgboost import Booster, DMatrix
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class XGBoostPrediction:
|
||||
home_win: float
|
||||
draw: float
|
||||
away_win: float
|
||||
|
||||
|
||||
def _safe_probability(x: float) -> float:
|
||||
return float(max(0.0, min(1.0, x)))
|
||||
|
||||
|
||||
class XGBoostPredictor:
|
||||
"""XGBoost 預測器:輸入特徵 => 輸出 1x2 機率。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str | None = None,
|
||||
*,
|
||||
feature_columns: list[str] | None = None,
|
||||
) -> None:
|
||||
self.feature_columns = feature_columns or []
|
||||
self.model_path = model_path
|
||||
self.model = self._load_model(model_path) if model_path else None
|
||||
|
||||
def _load_model(self, model_path: str | None) -> Booster | None:
|
||||
if not model_path:
|
||||
return None
|
||||
path = Path(model_path)
|
||||
if not path.exists():
|
||||
return None
|
||||
model = Booster()
|
||||
model.load_model(str(path))
|
||||
return model
|
||||
|
||||
def predict_match_outcome(self, features: dict[str, float]) -> dict[str, float]:
|
||||
"""輸出主勝/平/客勝機率。"""
|
||||
|
||||
if self.model is None:
|
||||
# fallback: 均分
|
||||
return {'home': 1 / 3, 'draw': 1 / 3, 'away': 1 / 3}
|
||||
|
||||
ordered_values = [float(features.get(col, 0.0)) for col in self.feature_columns]
|
||||
dmatrix = DMatrix(np.array([ordered_values]), feature_names=self.feature_columns)
|
||||
probs = self.model.predict(dmatrix)
|
||||
|
||||
if probs.ndim == 1:
|
||||
probs = probs.reshape(1, -1)
|
||||
arr = probs[0]
|
||||
if arr.size < 3:
|
||||
raise ValueError('模型輸出維度不足 3')
|
||||
|
||||
raw = np.array(arr[:3], dtype=float)
|
||||
raw = np.maximum(raw, 0.0)
|
||||
s = raw.sum()
|
||||
if s <= 0:
|
||||
raise ValueError('模型輸出總和異常為 0')
|
||||
norm = raw / s
|
||||
|
||||
return {'home': _safe_probability(norm[0]), 'draw': _safe_probability(norm[1]), 'away': _safe_probability(norm[2])}
|
||||
|
||||
def find_model_edge(
|
||||
self,
|
||||
ml_probs: dict[str, float],
|
||||
bookmaker_implied_probs: dict[str, float],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""回傳模型超越莊家 4% 以上的投注選項。"""
|
||||
|
||||
mapping = [('home', 'home'), ('draw', 'draw'), ('away', 'away')]
|
||||
outputs: list[dict[str, Any]] = []
|
||||
|
||||
for model_key, book_key in mapping:
|
||||
ml_v = float(ml_probs.get(model_key, 0.0))
|
||||
book_v = float(bookmaker_implied_probs.get(book_key, 0.0))
|
||||
edge = ml_v - book_v
|
||||
|
||||
if edge >= 0.04:
|
||||
outputs.append(
|
||||
{
|
||||
'selection': model_key,
|
||||
'ml_prob': round(ml_v, 6),
|
||||
'bookmaker_implied_prob': round(book_v, 6),
|
||||
'edge': round(edge, 6),
|
||||
'label': 'Strong Buy',
|
||||
},
|
||||
)
|
||||
|
||||
return outputs
|
||||
163
platform/backend/app/analytics/player_props.py
Normal file
163
platform/backend/app/analytics/player_props.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""球員道具盤(Player Props)量化引擎。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
PropMetric = Literal['shots', 'shots_on_target', 'passes']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlayerPropsProfile:
|
||||
"""球員與對位環境的道具盤參考參數。"""
|
||||
|
||||
player_id: str
|
||||
metric: PropMetric
|
||||
baseline_mean: float
|
||||
match_minutes: int = 90
|
||||
team_attack_factor: float = 1.0
|
||||
opponent_defence_factor: float = 1.0
|
||||
weather_fatigue_factor: float = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlayerPropsSimulationResult:
|
||||
"""單個道具盤的模擬輸出。"""
|
||||
|
||||
metric: PropMetric
|
||||
line: float
|
||||
over_probability: float
|
||||
under_probability: float
|
||||
expected_count: float
|
||||
p5: float
|
||||
p50: float
|
||||
p95: float
|
||||
simulation_runs: int
|
||||
|
||||
def to_dict(self) -> dict[str, float | int | str]:
|
||||
return {
|
||||
'metric': self.metric,
|
||||
'line': self.line,
|
||||
'over_probability': self.over_probability,
|
||||
'under_probability': self.under_probability,
|
||||
'expected_count': self.expected_count,
|
||||
'p5': self.p5,
|
||||
'p50': self.p50,
|
||||
'p95': self.p95,
|
||||
'simulation_runs': self.simulation_runs,
|
||||
}
|
||||
|
||||
|
||||
def _apply_context_multiplier(profile: PlayerPropsProfile) -> float:
|
||||
"""依據球員對位環境組合出單場事件期望值修正係數。"""
|
||||
|
||||
multipliers = [
|
||||
max(0.1, profile.team_attack_factor),
|
||||
1 / max(0.5, profile.opponent_defence_factor),
|
||||
max(0.6, profile.weather_fatigue_factor),
|
||||
]
|
||||
return float(np.prod(multipliers))
|
||||
|
||||
|
||||
def _metric_seed_variance(profile: PlayerPropsProfile) -> float:
|
||||
"""使用不同維度的離散程度(sigma)以保留球員特徵差異。"""
|
||||
|
||||
if profile.metric == 'passes':
|
||||
return 0.45
|
||||
if profile.metric == 'shots_on_target':
|
||||
return 0.22
|
||||
return 0.30
|
||||
|
||||
|
||||
def simulate_player_prop_probability(
|
||||
profile: PlayerPropsProfile,
|
||||
*,
|
||||
line: float,
|
||||
simulations: int = 10000,
|
||||
rng: np.random.Generator | None = None,
|
||||
) -> PlayerPropsSimulationResult:
|
||||
"""用蒙地卡羅法計算球員道具盤超過盤口線的機率。"""
|
||||
|
||||
if line <= 0:
|
||||
raise ValueError('line 必須為正數')
|
||||
if simulations <= 100:
|
||||
raise ValueError('simulations 最少需要 100 次')
|
||||
|
||||
generator = rng or np.random.default_rng()
|
||||
|
||||
minute_ratio = profile.match_minutes / 90
|
||||
base = profile.baseline_mean * minute_ratio
|
||||
adjusted_mean = max(0.05, base * _apply_context_multiplier(profile))
|
||||
|
||||
# 以 Gamma-Poisson 混合近似捕捉波動,避免單純 Poisson 太過平滑。
|
||||
gamma_shape = max(0.5, 1.0 / (_metric_seed_variance(profile) ** 2))
|
||||
gamma_scale = adjusted_mean / gamma_shape
|
||||
intensity = generator.gamma(gamma_shape, gamma_scale, size=simulations)
|
||||
|
||||
counts = generator.poisson(intensity).astype(float)
|
||||
|
||||
over_count = int(np.sum(counts > line))
|
||||
over_probability = over_count / simulations
|
||||
under_probability = 1 - over_probability
|
||||
|
||||
expected_count = float(np.mean(counts))
|
||||
p5, p50, p95 = [float(np.percentile(counts, q)) for q in (5, 50, 95)]
|
||||
|
||||
return PlayerPropsSimulationResult(
|
||||
metric=profile.metric,
|
||||
line=line,
|
||||
over_probability=round(over_probability, 6),
|
||||
under_probability=round(under_probability, 6),
|
||||
expected_count=round(expected_count, 3),
|
||||
p5=p5,
|
||||
p50=p50,
|
||||
p95=p95,
|
||||
simulation_runs=simulations,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_top_edge(
|
||||
profile: PlayerPropsProfile,
|
||||
bookmaker_over_odds: float,
|
||||
*,
|
||||
line: float,
|
||||
simulations: int = 10000,
|
||||
stake: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""回傳道具盤 EV 與建議邊際,供前端高邊際卡片使用。"""
|
||||
|
||||
result = simulate_player_prop_probability(profile, line=line, simulations=simulations)
|
||||
|
||||
if bookmaker_over_odds <= 1:
|
||||
raise ValueError('bookmaker_over_odds 必須大於 1')
|
||||
|
||||
# EV 計算以 "賭 over" 為例。
|
||||
win_profit = (bookmaker_over_odds - 1) * stake
|
||||
loss = stake
|
||||
ev = result.over_probability * win_profit - (1 - result.over_probability) * loss
|
||||
|
||||
edge = ev / stake
|
||||
top_edge = edge > 0.08
|
||||
|
||||
return {
|
||||
**result.to_dict(),
|
||||
'edge': round(edge, 6),
|
||||
'top_edge': top_edge,
|
||||
'bookmaker_over_odds': bookmaker_over_odds,
|
||||
'implied_prob': round(1 / bookmaker_over_odds, 6),
|
||||
'recommended_stake_hint': round(max(0.0, edge * stake * 0.4), 2),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
'PropMetric',
|
||||
'PlayerPropsProfile',
|
||||
'PlayerPropsSimulationResult',
|
||||
'evaluate_top_edge',
|
||||
'simulate_player_prop_probability',
|
||||
]
|
||||
|
||||
79
platform/backend/app/analytics/player_props_sim.py
Normal file
79
platform/backend/app/analytics/player_props_sim.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""球員道具盤(Props)蒙地卡羅模擬模組。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlayerPropsDistribution:
|
||||
shots: np.ndarray
|
||||
shots_on_target: np.ndarray
|
||||
passes: np.ndarray
|
||||
|
||||
|
||||
def simulate_player_stats(
|
||||
player_metrics: dict,
|
||||
opponent_defense_metrics: dict,
|
||||
iterations: int = 10_000,
|
||||
) -> PlayerPropsDistribution:
|
||||
"""快速模擬球員事件次數分佈。"""
|
||||
|
||||
if iterations <= 0:
|
||||
raise ValueError('iterations 必須大於 0')
|
||||
|
||||
avg_touches = float(player_metrics.get('avg_touches', 45) or 0.0)
|
||||
base_shot_rate = float(player_metrics.get('shots_per_touch', 0.08) or 0.0)
|
||||
base_target_rate = float(player_metrics.get('shot_on_target_rate', 0.35) or 0.0)
|
||||
base_pass_rate = float(player_metrics.get('passes_per_touch', 0.65) or 0.0)
|
||||
|
||||
opp_pressure = float(opponent_defense_metrics.get('pressing_index', 1.0) or 1.0)
|
||||
opp_tackling = float(opponent_defense_metrics.get('marking_index', 1.0) or 1.0)
|
||||
|
||||
adj_touches = max(1.0, avg_touches * max(0.6, 1.0 / max(0.5, opp_pressure)))
|
||||
shot_lambda = adj_touches * base_shot_rate
|
||||
pass_lambda = adj_touches * base_pass_rate
|
||||
|
||||
rng = np.random.default_rng()
|
||||
shots = rng.poisson(lam=shot_lambda, size=iterations)
|
||||
passes = rng.poisson(lam=pass_lambda, size=iterations)
|
||||
|
||||
# 對方壓迫會降低射正率
|
||||
effective_target_rate = max(0.02, base_target_rate / max(opp_tackling, 0.3))
|
||||
shots_on_target = rng.binomial(shots, p=min(effective_target_rate, 0.99), size=iterations)
|
||||
|
||||
return PlayerPropsDistribution(shots=shots.astype(int), shots_on_target=shots_on_target.astype(int), passes=passes.astype(int))
|
||||
|
||||
|
||||
def evaluate_prop_bet(
|
||||
simulated_distribution: PlayerPropsDistribution,
|
||||
line: float,
|
||||
odds: float,
|
||||
) -> dict[str, float | bool]:
|
||||
"""從 10,000 次模擬結果計算超過盤口機率與 EV。"""
|
||||
|
||||
if odds <= 1:
|
||||
raise ValueError('odds 必須大於 1')
|
||||
if line < 0:
|
||||
raise ValueError('line 必須大於等於 0')
|
||||
|
||||
shots = simulated_distribution.shots
|
||||
if shots.size == 0:
|
||||
raise ValueError('distribution 為空')
|
||||
|
||||
probability_over = float((shots > line).mean())
|
||||
from .ev_calculator import calculate_expected_value
|
||||
|
||||
ev = calculate_expected_value(probability_over, odds)
|
||||
|
||||
return {
|
||||
'metric': 'shots',
|
||||
'line': line,
|
||||
'over_probability': round(probability_over, 6),
|
||||
'under_probability': round(1.0 - probability_over, 6),
|
||||
'implied_ev': ev['ev_value'],
|
||||
'ev_percentage': ev['ev_percentage'],
|
||||
'is_value_bet': bool(ev['is_value_bet']),
|
||||
}
|
||||
113
platform/backend/app/analytics/poisson_model.py
Normal file
113
platform/backend/app/analytics/poisson_model.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Poisson 分佈賽果預測模組。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.stats import poisson
|
||||
|
||||
|
||||
class PoissonMatchPredictor:
|
||||
"""基於攻守強度的雙方進球機率預測器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home_attack_strength: float,
|
||||
home_defense_strength: float,
|
||||
away_attack_strength: float,
|
||||
away_defense_strength: float,
|
||||
league_avg_home_goals: float,
|
||||
) -> None:
|
||||
for value, name in [
|
||||
(home_attack_strength, 'home_attack_strength'),
|
||||
(home_defense_strength, 'home_defense_strength'),
|
||||
(away_attack_strength, 'away_attack_strength'),
|
||||
(away_defense_strength, 'away_defense_strength'),
|
||||
(league_avg_home_goals, 'league_avg_home_goals'),
|
||||
]:
|
||||
if value <= 0:
|
||||
raise ValueError(f'{name} 必須大於 0')
|
||||
|
||||
self.home_attack_strength = float(home_attack_strength)
|
||||
self.home_defense_strength = float(home_defense_strength)
|
||||
self.away_attack_strength = float(away_attack_strength)
|
||||
self.away_defense_strength = float(away_defense_strength)
|
||||
self.league_avg_home_goals = float(league_avg_home_goals)
|
||||
|
||||
def calculate_expected_goals(self) -> tuple[float, float]:
|
||||
"""根據攻守強度與聯盟均值估算預期進球數(λ 值)。
|
||||
|
||||
使用比值校正避免極端值放大風險:
|
||||
- 主隊 λ = 聯盟主場均值 × (主攻 / 客守)
|
||||
- 客隊 λ = 聯盟客場均值 × (客攻 / 主守)
|
||||
"""
|
||||
|
||||
league_avg_away_goals = self.league_avg_home_goals * 0.95
|
||||
|
||||
home_lambda = self.league_avg_home_goals * (self.home_attack_strength / self.away_defense_strength)
|
||||
away_lambda = league_avg_away_goals * (self.away_attack_strength / self.home_defense_strength)
|
||||
|
||||
home_lambda = max(0.01, min(home_lambda, 8.0))
|
||||
away_lambda = max(0.01, min(away_lambda, 8.0))
|
||||
|
||||
return home_lambda, away_lambda
|
||||
|
||||
def predict_exact_score_matrix(self, max_goals: int = 5) -> np.ndarray:
|
||||
"""輸出 0~max_goals 間所有比分組合的機率矩陣。
|
||||
|
||||
回傳 shape = (max_goals+1, max_goals+1),
|
||||
index [i,j] 代表主隊 i 球、客隊 j 球的機率。
|
||||
"""
|
||||
|
||||
if max_goals < 0:
|
||||
raise ValueError('max_goals 必須大於等於 0')
|
||||
|
||||
home_lambda, away_lambda = self.calculate_expected_goals()
|
||||
|
||||
goals = np.arange(max_goals + 1)
|
||||
home_prob = poisson.pmf(goals, home_lambda)
|
||||
away_prob = poisson.pmf(goals, away_lambda)
|
||||
|
||||
matrix = np.outer(home_prob, away_prob)
|
||||
matrix = matrix.astype(float)
|
||||
matrix /= matrix.sum() if matrix.sum() > 0 else 1.0
|
||||
return matrix
|
||||
|
||||
def predict_1x2_probabilities(self) -> dict[str, float]:
|
||||
"""由波膽矩陣匯總 1x2(主勝/平/客勝)機率。"""
|
||||
|
||||
matrix = self.predict_exact_score_matrix(max_goals=8)
|
||||
|
||||
draw = float(np.trace(matrix))
|
||||
home_win = float(np.tril(matrix, -1).sum())
|
||||
away_win = float(np.triu(matrix, 1).sum())
|
||||
|
||||
total = home_win + draw + away_win
|
||||
if total <= 0:
|
||||
return {'home_win': 0.0, 'draw': 0.0, 'away_win': 0.0}
|
||||
|
||||
return {
|
||||
'home_win': home_win / total,
|
||||
'draw': draw / total,
|
||||
'away_win': away_win / total,
|
||||
}
|
||||
|
||||
def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 8) -> tuple[float, float]:
|
||||
"""回傳(Under 機率, Over 機率)。"""
|
||||
|
||||
if line < 0:
|
||||
raise ValueError('line 必須大於等於 0')
|
||||
|
||||
matrix = self.predict_exact_score_matrix(max_goals=max_goals)
|
||||
goals = np.arange(max_goals + 1)
|
||||
home, away = np.meshgrid(goals, goals)
|
||||
total = home + away
|
||||
|
||||
under_mask = total <= line
|
||||
under = float(matrix[under_mask].sum())
|
||||
over = float(matrix[~under_mask].sum())
|
||||
normalizer = under + over
|
||||
|
||||
if normalizer <= 0:
|
||||
return 0.0, 0.0
|
||||
|
||||
return under / normalizer, over / normalizer
|
||||
241
platform/backend/app/analytics/portfolio_analyzer.py
Normal file
241
platform/backend/app/analytics/portfolio_analyzer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""個人投注弱點分析(Betting Leaks)引擎。
|
||||
|
||||
將使用者歷史注單做群組化彙總,找出長期導致虧損的下注模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int | None = None) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {'1', 'true', 't', 'yes', 'y'}
|
||||
if isinstance(value, (int, float)):
|
||||
return value not in {0}
|
||||
return default
|
||||
|
||||
|
||||
def _odds_bucket(odds: float | None, step: float = 0.5) -> str:
|
||||
if odds is None or odds <= 0:
|
||||
return 'N/A'
|
||||
if odds <= 1:
|
||||
return '1.00-1.50'
|
||||
bucket_start = ((odds - 1) // step) * step + 1
|
||||
bucket_end = bucket_start + step
|
||||
return f'{bucket_start:.2f}-{bucket_end:.2f}'
|
||||
|
||||
|
||||
def _calculate_pnl(stake: float, is_win: bool, closing_odds: float | None, recommended_odds: float | None) -> float:
|
||||
"""依下注結果與收盤賠率計算實際 P/L。"""
|
||||
|
||||
effective_odds = closing_odds
|
||||
if effective_odds is None or effective_odds <= 1:
|
||||
effective_odds = recommended_odds
|
||||
|
||||
if effective_odds is None or effective_odds <= 1 or stake <= 0:
|
||||
return 0.0
|
||||
|
||||
if is_win:
|
||||
return stake * (effective_odds - 1)
|
||||
return -stake
|
||||
|
||||
|
||||
def _calculate_clv(recommended_odds: float | None, closing_odds: float | None) -> float | None:
|
||||
if recommended_odds is None or closing_odds is None:
|
||||
return None
|
||||
if recommended_odds <= 0 or closing_odds <= 0:
|
||||
return None
|
||||
return (recommended_odds / closing_odds - 1) * 100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeakageCluster:
|
||||
market_type: str
|
||||
bet_type: str
|
||||
odds_bucket: str
|
||||
match_stage: str
|
||||
bet_count: int
|
||||
total_stake: float
|
||||
closed_count: int
|
||||
win_count: int
|
||||
total_pnl: float
|
||||
avg_clv_percent: float
|
||||
roi_percent: float
|
||||
hit_rate_percent: float
|
||||
status: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'market_type': self.market_type,
|
||||
'bet_type': self.bet_type,
|
||||
'odds_bucket': self.odds_bucket,
|
||||
'match_stage': self.match_stage,
|
||||
'bet_count': self.bet_count,
|
||||
'total_stake': self.total_stake,
|
||||
'closed_count': self.closed_count,
|
||||
'win_count': self.win_count,
|
||||
'total_pnl': self.total_pnl,
|
||||
'avg_clv_percent': self.avg_clv_percent,
|
||||
'roi_percent': self.roi_percent,
|
||||
'hit_rate_percent': self.hit_rate_percent,
|
||||
'status': self.status,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardTruth:
|
||||
title: str
|
||||
message: str
|
||||
cluster: dict[str, Any]
|
||||
|
||||
|
||||
def analyze_user_leaks(user_bets: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""分析使用者注單中的高頻虧損模式,回傳風險群組與漏點警告。"""
|
||||
|
||||
raw_bets = user_bets if isinstance(user_bets, list) else []
|
||||
|
||||
grouped: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
||||
|
||||
for raw in raw_bets:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
|
||||
market_type = str(raw.get('market_type', 'unknown')).strip() or 'unknown'
|
||||
is_single = raw.get('parlay_type') in (None, 'single', '', 'single_bet')
|
||||
bet_type = 'single' if is_single else 'parlay'
|
||||
odds = _safe_float(raw.get('odds'))
|
||||
stake = _safe_float(raw.get('stake'))
|
||||
if stake is None or stake <= 0:
|
||||
continue
|
||||
|
||||
match_stage = str(raw.get('match_stage', raw.get('stage', 'unknown'))).strip() or 'unknown'
|
||||
odds_band = _odds_bucket(odds)
|
||||
key = (market_type, bet_type, odds_band, match_stage)
|
||||
|
||||
entry = grouped.setdefault(
|
||||
key,
|
||||
{
|
||||
'bet_count': 0,
|
||||
'total_stake': 0.0,
|
||||
'closed_count': 0,
|
||||
'win_count': 0,
|
||||
'total_pnl': 0.0,
|
||||
'clv_values': [] as list[float],
|
||||
},
|
||||
)
|
||||
|
||||
entry['bet_count'] += 1
|
||||
entry['total_stake'] += stake
|
||||
|
||||
is_settled = _to_bool(raw.get('is_settled'), default=False)
|
||||
if not is_settled:
|
||||
continue
|
||||
|
||||
is_win = _to_bool(raw.get('is_win'))
|
||||
if is_win:
|
||||
entry['win_count'] += 1
|
||||
|
||||
entry['closed_count'] += 1
|
||||
closing_odds = _safe_float(raw.get('closing_odds'))
|
||||
recommended_odds = odds or _safe_float(raw.get('recommended_odds'))
|
||||
pnl = _calculate_pnl(
|
||||
stake=stake,
|
||||
is_win=is_win,
|
||||
closing_odds=closing_odds,
|
||||
recommended_odds=recommended_odds,
|
||||
)
|
||||
entry['total_pnl'] += pnl
|
||||
|
||||
clv = _calculate_clv(recommended_odds, closing_odds)
|
||||
if clv is not None:
|
||||
entry['clv_values'].append(clv)
|
||||
|
||||
total_bets = sum(v['bet_count'] for v in grouped.values())
|
||||
settled_bets = sum(v['closed_count'] for v in grouped.values())
|
||||
total_stake = sum(v['total_stake'] for v in grouped.values())
|
||||
total_pnl = sum(v['total_pnl'] for v in grouped.values())
|
||||
total_win = sum(v['win_count'] for v in grouped.values())
|
||||
|
||||
clusters: list[LeakageCluster] = []
|
||||
hard_truths: list[HardTruth] = []
|
||||
|
||||
for (market_type, bet_type, odds_bucket, match_stage), row in grouped.items():
|
||||
bet_count = int(row['bet_count'])
|
||||
closed_count = int(row['closed_count'])
|
||||
total_stake_group = float(row['total_stake'])
|
||||
total_pnl_group = float(row['total_pnl'])
|
||||
|
||||
roi = (total_pnl_group / total_stake_group * 100) if total_stake_group > 0 else 0.0
|
||||
win_rate = (row['win_count'] / closed_count * 100) if closed_count > 0 else 0.0
|
||||
avg_clv = (sum(row['clv_values']) / len(row['clv_values'])) if row['clv_values'] else 0.0
|
||||
|
||||
status = 'OK'
|
||||
if bet_count > 20 and roi < -10:
|
||||
status = 'CRITICAL_LEAK'
|
||||
hard_truths.append(
|
||||
HardTruth(
|
||||
title='嚴重漏財點',
|
||||
message=(
|
||||
f'{match_stage} / {bet_type} / {market_type} / {odds_bucket} 的下注次數 {bet_count} 場,'
|
||||
f'ROI {roi:.2f}%,請先降低此區塊投注比例。'
|
||||
),
|
||||
cluster={
|
||||
'market_type': market_type,
|
||||
'bet_type': bet_type,
|
||||
'odds_bucket': odds_bucket,
|
||||
'match_stage': match_stage,
|
||||
},
|
||||
).__dict__,
|
||||
)
|
||||
|
||||
clusters.append(
|
||||
LeakageCluster(
|
||||
market_type=market_type,
|
||||
bet_type=bet_type,
|
||||
odds_bucket=odds_bucket,
|
||||
match_stage=match_stage,
|
||||
bet_count=bet_count,
|
||||
total_stake=round(total_stake_group, 2),
|
||||
closed_count=closed_count,
|
||||
win_count=row['win_count'],
|
||||
total_pnl=round(total_pnl_group, 2),
|
||||
avg_clv_percent=round(avg_clv, 4),
|
||||
roi_percent=round(roi, 4),
|
||||
hit_rate_percent=round(win_rate, 2),
|
||||
status=status,
|
||||
),
|
||||
)
|
||||
|
||||
clusters.sort(key=lambda c: c.roi_percent)
|
||||
|
||||
overall_roi = (total_pnl / total_stake * 100) if total_stake > 0 else 0.0
|
||||
overall_hit_rate = (total_win / settled_bets * 100) if settled_bets > 0 else 0.0
|
||||
|
||||
return {
|
||||
'total_bet_count': total_bets,
|
||||
'settled_bet_count': settled_bets,
|
||||
'total_stake': round(total_stake, 2),
|
||||
'total_pnl': round(total_pnl, 2),
|
||||
'overall_roi_percent': round(overall_roi, 4),
|
||||
'overall_hit_rate_percent': round(overall_hit_rate, 2),
|
||||
'clusters': [c.as_dict() for c in clusters],
|
||||
'hard_truths': [h.__dict__ for h in hard_truths],
|
||||
}
|
||||
162
platform/backend/app/analytics/proof_of_yield.py
Normal file
162
platform/backend/app/analytics/proof_of_yield.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""公開獲利帳本(Proof of Yield)模組。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _as_float(value: Any, *, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProofYieldRecord:
|
||||
recommendation_id: str
|
||||
match_id: str
|
||||
market_type: str
|
||||
selection: str
|
||||
stake: float
|
||||
recommended_odds: float
|
||||
closing_odds: float | None
|
||||
is_win: bool
|
||||
settled_at: str
|
||||
clv_ratio: float | None
|
||||
clv_percent: float | None
|
||||
pnl: float
|
||||
created_at: str
|
||||
|
||||
|
||||
def compute_clv(recommended_odds: float, closing_odds: float) -> float:
|
||||
"""CLV = (推薦賠率 / 收盤賠率) - 1。"""
|
||||
|
||||
if recommended_odds <= 0 or closing_odds <= 0:
|
||||
raise ValueError('推薦賠率與收盤賠率都必須大於 0')
|
||||
return (recommended_odds / closing_odds) - 1
|
||||
|
||||
|
||||
def compute_pnl(stake: float, is_win: bool, closing_odds: float | None) -> float:
|
||||
if closing_odds is None or stake <= 0:
|
||||
return 0.0
|
||||
return stake * (closing_odds - 1) if is_win else -stake
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LedgerSummary:
|
||||
total_recommendations: int
|
||||
hit_count: int
|
||||
win_rate_percent: float
|
||||
total_stake: float
|
||||
total_pnl: float
|
||||
roi_percent: float
|
||||
avg_clv_percent: float
|
||||
|
||||
|
||||
class ProofOfYieldStore:
|
||||
"""本地持久化透明帳本(先以 JSON 做可追溯快啟動)。"""
|
||||
|
||||
def __init__(self, file_path: str | None = None) -> None:
|
||||
self.path = Path(file_path or 'data/proof_of_yield_ledger.json')
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load(self) -> list[dict[str, Any]]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
raw = self.path.read_text(encoding='utf-8')
|
||||
if not raw.strip():
|
||||
return []
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return parsed
|
||||
|
||||
def _save(self, rows: list[dict[str, Any]]) -> None:
|
||||
self.path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
def upsert_settlements(self, items: list[dict[str, Any]]) -> list[ProofYieldRecord]:
|
||||
current = self._load()
|
||||
idx = {row['recommendation_id']: i for i, row in enumerate(current)}
|
||||
|
||||
for item in items:
|
||||
recommendation_id = str(item.get('recommendation_id') or uuid4().hex)
|
||||
stake = _as_float(item.get('stake'), default=100.0)
|
||||
recommended_odds = _as_float(item.get('recommended_odds'))
|
||||
closing_odds = item.get('closing_odds')
|
||||
is_win = bool(item.get('is_win', False))
|
||||
closing = _as_float(closing_odds) if closing_odds is not None else None
|
||||
|
||||
clv = None
|
||||
clv_pct = None
|
||||
if closing is not None and recommended_odds > 0:
|
||||
clv = compute_clv(recommended_odds, closing)
|
||||
clv_pct = clv * 100
|
||||
|
||||
pnl = compute_pnl(stake, is_win, closing)
|
||||
record = {
|
||||
'recommendation_id': recommendation_id,
|
||||
'match_id': str(item.get('match_id', 'UNKNOWN')),
|
||||
'market_type': str(item.get('market_type', '1x2')),
|
||||
'selection': str(item.get('selection', 'home')),
|
||||
'stake': round(stake, 4),
|
||||
'recommended_odds': round(recommended_odds, 6),
|
||||
'closing_odds': round(closing, 6) if closing is not None else None,
|
||||
'is_win': is_win,
|
||||
'settled_at': str(item.get('settled_at') or datetime.utcnow().isoformat()),
|
||||
'clv_ratio': round(clv, 6) if clv is not None else None,
|
||||
'clv_percent': round(clv_pct, 4) if clv_pct is not None else None,
|
||||
'pnl': round(pnl, 4),
|
||||
'created_at': str(item.get('created_at') or datetime.utcnow().isoformat()),
|
||||
}
|
||||
|
||||
if recommendation_id in idx:
|
||||
current[idx[recommendation_id]] = record
|
||||
else:
|
||||
current.append(record)
|
||||
|
||||
self._save(current)
|
||||
return [ProofYieldRecord(**row) for row in current]
|
||||
|
||||
def query_ledger(self, *, limit: int = 200) -> list[ProofYieldRecord]:
|
||||
rows = sorted(self._load(), key=lambda row: row.get('created_at', ''), reverse=True)
|
||||
return [ProofYieldRecord(**row) for row in rows[:limit]]
|
||||
|
||||
@staticmethod
|
||||
def summarize(records: list[ProofYieldRecord]) -> LedgerSummary:
|
||||
total = len(records)
|
||||
if total == 0:
|
||||
return LedgerSummary(
|
||||
total_recommendations=0,
|
||||
hit_count=0,
|
||||
win_rate_percent=0.0,
|
||||
total_stake=0.0,
|
||||
total_pnl=0.0,
|
||||
roi_percent=0.0,
|
||||
avg_clv_percent=0.0,
|
||||
)
|
||||
|
||||
hit = sum(1 for row in records if row.is_win)
|
||||
total_stake = sum(row.stake for row in records)
|
||||
total_pnl = sum(row.pnl for row in records)
|
||||
clv_values = [row.clv_percent for row in records if row.clv_percent is not None]
|
||||
avg_clv = sum(clv_values) / len(clv_values) if clv_values else 0.0
|
||||
roi = (total_pnl / total_stake) * 100 if total_stake > 0 else 0.0
|
||||
win_rate = (hit / total) * 100
|
||||
|
||||
return LedgerSummary(
|
||||
total_recommendations=total,
|
||||
hit_count=hit,
|
||||
win_rate_percent=round(win_rate, 4),
|
||||
total_stake=round(total_stake, 4),
|
||||
total_pnl=round(total_pnl, 4),
|
||||
roi_percent=round(roi, 4),
|
||||
avg_clv_percent=round(avg_clv, 4),
|
||||
)
|
||||
|
||||
53
platform/backend/app/analytics/referee_analyzer.py
Normal file
53
platform/backend/app/analytics/referee_analyzer.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""裁判尺度分析器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from .ev_calculator import calculate_expected_value
|
||||
|
||||
|
||||
def calculate_cards_ev(
|
||||
referee_stats: dict,
|
||||
match_tension_index: float,
|
||||
bookmaker_card_line: float,
|
||||
bookmaker_odds: float,
|
||||
) -> dict[str, float | bool | str]:
|
||||
"""判斷裁判/對手張力對紅黃牌盤口的偏差與價值。
|
||||
|
||||
依據裁判最近場次平均黃牌數與比賽張力(衝突度)估算
|
||||
本場真實牌數,並與莊家 O/U 盤口比較。
|
||||
"""
|
||||
|
||||
if bookmaker_odds <= 1:
|
||||
raise ValueError('bookmaker_odds 必須大於 1')
|
||||
if bookmaker_card_line <= 0:
|
||||
raise ValueError('bookmaker_card_line 必須大於 0')
|
||||
if not 0 <= match_tension_index <= 1:
|
||||
raise ValueError('match_tension_index 必須在 0~1')
|
||||
|
||||
avg_cards = float(referee_stats.get('avg_yellow_cards', 0.0) or 0.0)
|
||||
penalties_per_game = float(referee_stats.get('penalties_per_game', 0.0) or 0.0)
|
||||
|
||||
strictness_index = 20.0 + avg_cards * 1.9 + penalties_per_game * 2.5
|
||||
# 綜合壓力補正,將裁判嚴厲度與球隊/賽事張力轉為預測牌數。
|
||||
expected_cards = max(
|
||||
0.5,
|
||||
strictness_index * (0.45 + 0.55 * max(0.0, min(match_tension_index, 1.0))),
|
||||
)
|
||||
|
||||
true_prob = min(1.0, max(0.0, expected_cards / (bookmaker_card_line * 1.4)))
|
||||
implied_prob = 1.0 / bookmaker_odds
|
||||
edge = true_prob - implied_prob
|
||||
|
||||
ev = calculate_expected_value(true_prob, bookmaker_odds, stake=100.0)
|
||||
|
||||
return {
|
||||
'strictness_index': round(strictness_index, 3),
|
||||
'expected_total_cards': round(expected_cards, 3),
|
||||
'true_prob': round(true_prob, 4),
|
||||
'implied_prob': round(implied_prob, 4),
|
||||
'edge_percent': round(edge * 100, 3),
|
||||
'is_value_bet': ev['is_value_bet'],
|
||||
'ev_percentage': ev['ev_percentage'],
|
||||
}
|
||||
131
platform/backend/app/analytics/referee_weather.py
Normal file
131
platform/backend/app/analytics/referee_weather.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""裁判與天候條件量化模組。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def calculate_referee_strictness_index(
|
||||
avg_yellow_cards: float,
|
||||
penalties_per_game: float,
|
||||
) -> float:
|
||||
"""裁判嚴厲度指標(0-100)。"""
|
||||
|
||||
yellow = max(0.0, min(avg_yellow_cards, 8.0)) / 8.0
|
||||
penalties = max(0.0, min(penalties_per_game, 2.5)) / 2.5
|
||||
return round(yellow * 55 + penalties * 45, 4)
|
||||
|
||||
|
||||
def detect_cards_pressure_signal(
|
||||
strictness_index: float,
|
||||
cards_ou_line: float,
|
||||
) -> bool:
|
||||
"""當裁判嚴格且莊家的卡數 O/U 開得偏低時,判斷為可能的逆風盤口。"""
|
||||
|
||||
return strictness_index >= 80 and cards_ou_line <= 4.5
|
||||
|
||||
|
||||
def estimate_heat_index(ambient_temp_c: float, humidity_pct: float) -> float:
|
||||
"""簡化的 Heat Index(攝氏)。"""
|
||||
|
||||
t = max(-60.0, min(60.0, ambient_temp_c))
|
||||
rh = max(0.0, min(100.0, humidity_pct))
|
||||
|
||||
hi = (
|
||||
-8.784695
|
||||
+ 1.61139411 * t
|
||||
+ 2.338549 * rh
|
||||
- 0.14611605 * t * rh
|
||||
- 0.012308094 * t * t
|
||||
- 0.016424828 * rh * rh
|
||||
+ 0.002211732 * t * t * rh
|
||||
+ 0.00072546 * t * rh * rh
|
||||
- 0.000003582 * t * t * rh * rh
|
||||
)
|
||||
return round(max(0.0, hi), 4)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchConditionSignal:
|
||||
strictness_index: float
|
||||
heat_index: float
|
||||
cards_pressure_alert: bool
|
||||
cards_ou_line: float
|
||||
second_half_home_attack: float
|
||||
second_half_away_attack: float
|
||||
second_half_under_recommendation: bool
|
||||
attacker_direction: str
|
||||
|
||||
|
||||
def adjust_attack_for_heat_and_altitude(
|
||||
base_attack: float,
|
||||
*,
|
||||
heat_index: float,
|
||||
is_second_half: bool,
|
||||
venue_altitude_meters: float | None = None,
|
||||
) -> float:
|
||||
"""極端環境下的下半場攻擊效率修正。"""
|
||||
|
||||
if not is_second_half:
|
||||
return round(float(base_attack), 6)
|
||||
|
||||
heat_penalty = max(0.0, heat_index - 28.0) / 120.0 # 每 1.2 度約降 1%
|
||||
altitude_penalty = 0.0
|
||||
if venue_altitude_meters and venue_altitude_meters > 1500:
|
||||
altitude_penalty = min(0.22, (venue_altitude_meters - 1500) / 8000.0)
|
||||
|
||||
factor = max(0.6, 1 - heat_penalty - altitude_penalty)
|
||||
return round(float(base_attack * factor), 6)
|
||||
|
||||
|
||||
def evaluate_match_conditions(
|
||||
*,
|
||||
avg_yellow_cards: float,
|
||||
penalties_per_game: float,
|
||||
cards_ou_line: float,
|
||||
temp_c: float,
|
||||
humidity_pct: float,
|
||||
venue_altitude_meters: int,
|
||||
home_second_half_attack: float,
|
||||
away_second_half_attack: float,
|
||||
) -> MatchConditionSignal:
|
||||
"""整合裁判與天候對下半場盤口與進攻效率的衝擊。"""
|
||||
|
||||
strictness_index = calculate_referee_strictness_index(avg_yellow_cards, penalties_per_game)
|
||||
heat_index = estimate_heat_index(temp_c, humidity_pct)
|
||||
|
||||
adjusted_home = adjust_attack_for_heat_and_altitude(
|
||||
home_second_half_attack,
|
||||
heat_index=heat_index,
|
||||
is_second_half=True,
|
||||
venue_altitude_meters=venue_altitude_meters,
|
||||
)
|
||||
adjusted_away = adjust_attack_for_heat_and_altitude(
|
||||
away_second_half_attack,
|
||||
heat_index=heat_index,
|
||||
is_second_half=True,
|
||||
venue_altitude_meters=venue_altitude_meters,
|
||||
)
|
||||
|
||||
cards_pressure = detect_cards_pressure_signal(strictness_index, cards_ou_line)
|
||||
high_heat = heat_index >= 32.0
|
||||
heat_pressure_delta = home_second_half_attack + away_second_half_attack
|
||||
second_half_under = high_heat and (adjusted_home + adjusted_away) <= heat_pressure_delta * 0.95
|
||||
|
||||
if adjusted_home > adjusted_away:
|
||||
attacker_direction = '上場勢優勢偏向主隊'
|
||||
elif adjusted_home < adjusted_away:
|
||||
attacker_direction = '上場勢優勢偏向客隊'
|
||||
else:
|
||||
attacker_direction = '攻勢對稱'
|
||||
|
||||
return MatchConditionSignal(
|
||||
strictness_index=strictness_index,
|
||||
heat_index=heat_index,
|
||||
cards_pressure_alert=cards_pressure,
|
||||
cards_ou_line=cards_ou_line,
|
||||
second_half_home_attack=adjusted_home,
|
||||
second_half_away_attack=adjusted_away,
|
||||
second_half_under_recommendation=second_half_under,
|
||||
attacker_direction=attacker_direction,
|
||||
)
|
||||
70
platform/backend/app/analytics/rlm.py
Normal file
70
platform/backend/app/analytics/rlm.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""反向盤口移動(RLM)偵測模組。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReverseLineMovementAlert:
|
||||
match_id: str
|
||||
market_type: str
|
||||
selection: str
|
||||
opening_odds: float
|
||||
current_odds: float
|
||||
ticket_pct: float
|
||||
handle_pct: float
|
||||
odds_change_pct: float
|
||||
smart_money_to: str
|
||||
is_triggered: bool
|
||||
triggered_at: datetime
|
||||
rationale: str
|
||||
|
||||
|
||||
def evaluate_reverse_line_movement(
|
||||
match_id: str,
|
||||
market_type: str,
|
||||
selection: str,
|
||||
*,
|
||||
opening_odds: float,
|
||||
current_odds: float,
|
||||
ticket_pct: float,
|
||||
handle_pct: float,
|
||||
ticket_threshold: float = 70.0,
|
||||
odds_change_threshold: float = 0.05,
|
||||
) -> ReverseLineMovementAlert:
|
||||
"""依條件判斷是否出現反向盤口。"""
|
||||
|
||||
if opening_odds <= 0:
|
||||
odds_pct = 0.0
|
||||
else:
|
||||
odds_pct = round((current_odds - opening_odds) / opening_odds, 6)
|
||||
|
||||
is_triggered = (
|
||||
ticket_pct > ticket_threshold
|
||||
and odds_pct > odds_change_threshold
|
||||
and handle_pct < ticket_pct
|
||||
)
|
||||
|
||||
smart_money_to = selection if handle_pct > ticket_pct else '對側'
|
||||
rationale = (
|
||||
f'散戶 {ticket_pct:.1f}% 追捧卻資金 {handle_pct:.1f}%,\n'
|
||||
f'盤口由 {opening_odds:.2f} 上升到 {current_odds:.2f}'
|
||||
)
|
||||
|
||||
return ReverseLineMovementAlert(
|
||||
match_id=match_id,
|
||||
market_type=market_type,
|
||||
selection=selection,
|
||||
opening_odds=opening_odds,
|
||||
current_odds=current_odds,
|
||||
ticket_pct=ticket_pct,
|
||||
handle_pct=handle_pct,
|
||||
odds_change_pct=round(odds_pct * 100, 4),
|
||||
smart_money_to=smart_money_to,
|
||||
is_triggered=is_triggered,
|
||||
triggered_at=datetime.utcnow(),
|
||||
rationale=rationale,
|
||||
)
|
||||
|
||||
71
platform/backend/app/analytics/sgp_engine.py
Normal file
71
platform/backend/app/analytics/sgp_engine.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from typing import List, Dict
|
||||
import math
|
||||
|
||||
class SGPCorrelationEngine:
|
||||
"""
|
||||
同場串關 (Same Game Parlay) 關聯性與價值探測引擎
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_joint_probability(prob_A: float, prob_B: float, correlation_coeff: float) -> float:
|
||||
"""
|
||||
計算兩個事件的聯合機率 (考慮相關係數)。
|
||||
使用簡化的二元正態分佈/Copula近似邏輯。
|
||||
:param prob_A: 事件 A 獨立發生的真實機率
|
||||
:param prob_B: 事件 B 獨立發生的真實機率
|
||||
:param correlation_coeff: 相關係數 (-1.0 到 1.0)
|
||||
"""
|
||||
if not (-1.0 <= correlation_coeff <= 1.0):
|
||||
raise ValueError("相關係數必須介於 -1.0 與 1.0 之間")
|
||||
|
||||
# 獨立發生的聯合機率
|
||||
independent_joint_prob = prob_A * prob_B
|
||||
|
||||
# 理論最大與最小邊界
|
||||
max_joint_prob = min(prob_A, prob_B)
|
||||
min_joint_prob = max(0.0, prob_A + prob_B - 1.0)
|
||||
|
||||
if correlation_coeff == 0:
|
||||
return independent_joint_prob
|
||||
elif correlation_coeff > 0:
|
||||
# 正相關:聯合機率向 max_joint_prob 靠攏
|
||||
return independent_joint_prob + correlation_coeff * (max_joint_prob - independent_joint_prob)
|
||||
else:
|
||||
# 負相關:聯合機率向 min_joint_prob 靠攏
|
||||
return independent_joint_prob + abs(correlation_coeff) * (min_joint_prob - independent_joint_prob)
|
||||
|
||||
@staticmethod
|
||||
def find_sgp_value(events: List[Dict], bookmaker_sgp_odds: float) -> Dict:
|
||||
"""
|
||||
評估 SGP 注單是否具備正期望值。
|
||||
events 範例: [{'prob': 0.6}, {'prob': 0.4}] 且需自帶兩兩相關係數矩陣 (此處簡化為平均相關性)
|
||||
"""
|
||||
if len(events) < 2:
|
||||
raise ValueError("SGP 必須至少包含兩個事件")
|
||||
|
||||
# 假設外部特徵工程已經給出了這組事件的平均正相關係數 (例如 0.4)
|
||||
# 實務上會透過更複雜的 Monte Carlo 計算,此為展示核心邏輯
|
||||
avg_correlation = events[0].get('correlation_with_others', 0.0)
|
||||
|
||||
current_joint_prob = events[0]['prob']
|
||||
for i in range(1, len(events)):
|
||||
current_joint_prob = SGPCorrelationEngine.calculate_joint_probability(
|
||||
current_joint_prob,
|
||||
events[i]['prob'],
|
||||
avg_correlation
|
||||
)
|
||||
|
||||
# 計算莊家隱含機率
|
||||
implied_prob = 1.0 / bookmaker_sgp_odds
|
||||
|
||||
# 計算 EV
|
||||
ev_percentage = (current_joint_prob * bookmaker_sgp_odds) - 1.0
|
||||
is_profitable = ev_percentage > 0.05 # 設定 5% 的 EV 門檻
|
||||
|
||||
return {
|
||||
"true_joint_probability": round(current_joint_prob, 4),
|
||||
"bookmaker_implied_probability": round(implied_prob, 4),
|
||||
"ev_percentage": round(ev_percentage, 4),
|
||||
"is_profitable_sgp": is_profitable,
|
||||
"fair_odds": round(1.0 / current_joint_prob, 2) if current_joint_prob > 0 else 0
|
||||
}
|
||||
135
platform/backend/app/analytics/vig_remover.py
Normal file
135
platform/backend/app/analytics/vig_remover.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""莊家抽水(Vig)去除工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, List, Sequence
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize_scalar
|
||||
|
||||
|
||||
def calculate_overround(odds: Sequence[float]) -> float:
|
||||
"""計算莊家總水位(Overround)。
|
||||
|
||||
Overround = Σ(1 / odds_i)。
|
||||
若結果 > 1 表示含有抽水。
|
||||
"""
|
||||
|
||||
if not odds:
|
||||
raise ValueError('odds 不可為空')
|
||||
_odds = np.asarray(odds, dtype=float)
|
||||
if np.any(_odds <= 1):
|
||||
raise ValueError('賠率必須全部大於 1')
|
||||
|
||||
return float(np.sum(1.0 / _odds))
|
||||
|
||||
|
||||
def remove_margin_basic(odds: Sequence[float]) -> List[float]:
|
||||
"""等比例剝除抽水。
|
||||
|
||||
先轉換為 implied probability,再除以 overround 讓機率總和為 1。
|
||||
"""
|
||||
|
||||
implied = np.array([1.0 / x for x in odds], dtype=float)
|
||||
overround = implied.sum()
|
||||
if overround <= 0:
|
||||
raise ValueError('無效 odds,無法計算去水')
|
||||
|
||||
true_probs = implied / overround
|
||||
return [float(x) for x in true_probs]
|
||||
|
||||
|
||||
def _shin_objective(z: float, observed: np.ndarray) -> float:
|
||||
"""Shin 模型中,透過 z 估計真實機率,使每個結果有一致修正。
|
||||
|
||||
模型假設:
|
||||
q_i(z) = max((p_i - z/(k-1)) / (1 - k/(k-1)*z), 1e-12)
|
||||
其中 q_i 為觀察值 implied probability,p_i 為解構後真實機率。
|
||||
透過約束 Σp_i=1 搜尋最小平方誤差。
|
||||
"""
|
||||
|
||||
k = observed.size
|
||||
if not 0.0 <= z < 1:
|
||||
return 1e9
|
||||
|
||||
denom = 1.0 - k / max(k - 1, 1) * z
|
||||
if denom <= 0:
|
||||
return 1e9
|
||||
|
||||
raw = (observed - z / max(k - 1, 1)) / denom
|
||||
raw = np.clip(raw, 1e-12, None)
|
||||
normalized = raw / raw.sum()
|
||||
return float(np.sum((normalized - observed / observed.sum()) ** 2))
|
||||
|
||||
|
||||
def remove_margin_shin(odds: Sequence[float]) -> List[float]:
|
||||
"""Shin 方法去水。
|
||||
|
||||
流程:
|
||||
1) 觀察賠率轉 implied probability。
|
||||
2) 用單參數 z 做最小化,推回一組更接近無套利的真實機率。
|
||||
3) 回傳機率正規化結果。
|
||||
"""
|
||||
|
||||
odds_array = np.asarray(odds, dtype=float)
|
||||
if odds_array.size == 0:
|
||||
raise ValueError('odds 不可為空')
|
||||
if np.any(odds_array <= 1):
|
||||
raise ValueError('賠率必須全部大於 1')
|
||||
|
||||
implied = 1.0 / odds_array
|
||||
|
||||
if implied.size == 2:
|
||||
# 二元市場可直接利用近似閉式解,穩定性較佳
|
||||
q1 = implied[0] / implied.sum()
|
||||
q2 = implied[1] / implied.sum()
|
||||
z = max(0.0, min(0.49, (q1 + q2 - 1.0) * 0.5))
|
||||
else:
|
||||
# 多項市場,使用數值搜尋
|
||||
result = minimize_scalar(
|
||||
_shin_objective,
|
||||
args=(implied,),
|
||||
bounds=(0.0, 0.49),
|
||||
method='bounded',
|
||||
)
|
||||
z = float(result.x if result.success else 0.0)
|
||||
|
||||
k = implied.size
|
||||
denom = 1.0 - k / max(k - 1, 1) * z
|
||||
if denom <= 0:
|
||||
return remove_margin_basic(odds)
|
||||
|
||||
raw = (implied - z / max(k - 1, 1)) / denom
|
||||
raw = np.clip(raw, 1e-12, None)
|
||||
true_prob = raw / raw.sum()
|
||||
return [float(x) for x in true_prob]
|
||||
|
||||
|
||||
def prob_to_decimal_odds(true_probs: Sequence[float]) -> List[float]:
|
||||
"""真實機率轉換回無水賠率。
|
||||
|
||||
p 轉賠率公式:odds = 1 / p。
|
||||
"""
|
||||
|
||||
probs = np.asarray(true_probs, dtype=float)
|
||||
if np.any(probs <= 0):
|
||||
raise ValueError('機率需大於 0')
|
||||
|
||||
total = probs.sum()
|
||||
if not np.isclose(total, 1.0, atol=1e-6):
|
||||
probs = probs / total
|
||||
return [round(float(1.0 / p), 4) for p in probs]
|
||||
|
||||
|
||||
def compare_bookmaker_true_prob(
|
||||
implied_odds: Sequence[float],
|
||||
transform: Callable[[Sequence[float]], Sequence[float]] = remove_margin_shin,
|
||||
) -> dict[str, list[float]]:
|
||||
"""比對原始賠率與去水後真實賠率,可直接提供前端展示。"""
|
||||
|
||||
true_probs = transform(implied_odds)
|
||||
return {
|
||||
'implied_prob': [float(1.0 / x) for x in implied_odds],
|
||||
'true_implied_prob': true_probs,
|
||||
'true_decimal_odds': prob_to_decimal_odds(true_probs),
|
||||
}
|
||||
41
platform/backend/app/api/affiliate.py
Normal file
41
platform/backend/app/api/affiliate.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
import uuid
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 模擬的 affiliate_clicks 記錄,實務上應寫入 TimescaleDB / PostgreSQL
|
||||
# affiliate_clicks_db = []
|
||||
|
||||
@router.get("/api/v1/go/{bookmaker_id}")
|
||||
async def affiliate_redirect(bookmaker_id: str, request: Request):
|
||||
"""
|
||||
動態聯盟行銷與防廣告攔截引擎
|
||||
- Server-side redirect
|
||||
- 紀錄點擊以計算 CR
|
||||
"""
|
||||
|
||||
# 模擬博彩公司對應表與追蹤碼
|
||||
bookmakers = {
|
||||
"bet365": "https://www.bet365.com/?affiliate=QUANT2026",
|
||||
"pinnacle": "https://www.pinnacle.com/?ref=QUANT2026",
|
||||
"draftkings": "https://www.draftkings.com/?track=QUANT2026"
|
||||
}
|
||||
|
||||
if bookmaker_id not in bookmakers:
|
||||
raise HTTPException(status_code=404, detail="Bookmaker not found")
|
||||
|
||||
target_url = bookmakers[bookmaker_id]
|
||||
|
||||
# 記錄點擊資料 (User-Agent, IP, Timestamp, etc)
|
||||
click_data = {
|
||||
"click_id": str(uuid.uuid4()),
|
||||
"bookmaker_id": bookmaker_id,
|
||||
"user_agent": request.headers.get("user-agent", "unknown"),
|
||||
"client_ip": request.client.host if request.client else "unknown"
|
||||
}
|
||||
|
||||
# affiliate_clicks_db.append(click_data)
|
||||
# print(f"Logged affiliate click: {click_data}")
|
||||
|
||||
return RedirectResponse(url=target_url, status_code=302)
|
||||
49
platform/backend/app/api/daily_card_generator.py
Normal file
49
platform/backend/app/api/daily_card_generator.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import date
|
||||
from typing import List, Dict
|
||||
|
||||
class DailyCardGenerator:
|
||||
"""
|
||||
投資長級別的每日智能注單生成引擎 (Daily Smart Card)
|
||||
"""
|
||||
|
||||
def __init__(self, db_session):
|
||||
self.db = db_session
|
||||
|
||||
def generate_daily_card(self, target_date: date) -> Dict:
|
||||
"""
|
||||
掃描當日賽事,並將高價值投注分類打包
|
||||
"""
|
||||
# 模擬從資料庫與 EV 引擎取得的當日高價值清單
|
||||
# 實務上會 join `matches` 與 `odds_history` 並即時套用 ev_calculator
|
||||
raw_value_bets = self._fetch_todays_value_bets(target_date)
|
||||
|
||||
card = {
|
||||
"date": target_date.isoformat(),
|
||||
"briefing": "AI 賽況總評:淘汰賽階段防守強度升級,系統偵測到大量下半場小球的定價錯誤,建議重倉穩健單關,避開受讓盤。",
|
||||
"total_suggested_units": 0.0,
|
||||
"recommendations": {
|
||||
"SAFE_SINGLE": [], # 穩健單關 (高勝率,正 EV)
|
||||
"HIGH_RISK_SINGLE": [], # 高賠搏冷 (低勝率,超高 EV)
|
||||
"SGP_LOTTERY": [] # 同場爆擊 (SGP)
|
||||
}
|
||||
}
|
||||
|
||||
for bet in raw_value_bets:
|
||||
if bet['true_prob'] > 0.55 and bet['ev_percentage'] > 0.03:
|
||||
bet['suggested_units'] = 1.5
|
||||
card['recommendations']['SAFE_SINGLE'].append(bet)
|
||||
card['total_suggested_units'] += 1.5
|
||||
|
||||
elif bet['true_prob'] < 0.35 and bet['ev_percentage'] > 0.08:
|
||||
bet['suggested_units'] = 0.5
|
||||
card['recommendations']['HIGH_RISK_SINGLE'].append(bet)
|
||||
card['total_suggested_units'] += 0.5
|
||||
|
||||
return card
|
||||
|
||||
def _fetch_todays_value_bets(self, target_date: date) -> List[Dict]:
|
||||
# 模擬資料
|
||||
return [
|
||||
{"match": "USA vs ENG", "selection": "Under 2.5", "odds": 1.95, "true_prob": 0.58, "ev_percentage": 0.131},
|
||||
{"match": "MEX vs ARG", "selection": "MEX Win", "odds": 4.20, "true_prob": 0.28, "ev_percentage": 0.176}
|
||||
]
|
||||
44
platform/backend/app/api/telegram_webhook.py
Normal file
44
platform/backend/app/api/telegram_webhook.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TelegramUpdate(BaseModel):
|
||||
update_id: int
|
||||
message: dict = None
|
||||
|
||||
@router.post("/api/v1/telegram/webhook")
|
||||
async def telegram_webhook(update: TelegramUpdate):
|
||||
"""
|
||||
VIP 私董會互動式 Telegram 機器人 Webhook
|
||||
"""
|
||||
if not update.message or "text" not in update.message:
|
||||
return {"status": "ok"}
|
||||
|
||||
text = update.message["text"].strip()
|
||||
chat_id = update.message["chat"]["id"]
|
||||
|
||||
# 模擬 !sgp [主隊] [客隊]
|
||||
if text.startswith("!sgp"):
|
||||
parts = text.split()
|
||||
if len(parts) == 3:
|
||||
home, away = parts[1], parts[2]
|
||||
# 這裡應該呼叫 SGPCorrelationEngine
|
||||
response_text = f"📊 [SGP 蒙地卡羅運算完成]\n賽事: {home} vs {away}\n推薦串關: {home} 勝 + 總進球數大於 2.5\nEV: +6.5%\n機率: 45%"
|
||||
else:
|
||||
response_text = "❌ 指令錯誤,正確格式: !sgp [主隊] [客隊]"
|
||||
|
||||
# 模擬 !ev
|
||||
elif text.startswith("!ev"):
|
||||
# 這裡應該從 EV 引擎抓取 Top 3
|
||||
response_text = "🔥 [全市場 Top 3 正期望值盤口]\n1. USA vs ENG - Under 2.5 (EV: +13.1%)\n2. MEX vs ARG - MEX Win (EV: +17.6%)\n3. FRA vs BRA - FRA Win (EV: +6.5%)"
|
||||
|
||||
else:
|
||||
response_text = "未知指令。可用指令: !sgp [主隊] [客隊], !ev"
|
||||
|
||||
# 實務上這裡會呼叫 Telegram Bot API 傳送訊息
|
||||
# send_message_to_telegram(chat_id, response_text)
|
||||
print(f"Telegram Bot Reply to {chat_id}: {response_text}")
|
||||
|
||||
return {"status": "ok"}
|
||||
19
platform/backend/app/db/__init__.py
Normal file
19
platform/backend/app/db/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""資料模型套件。"""
|
||||
|
||||
from .base import Base, get_engine, get_session_factory, SessionFactory
|
||||
from .models import Bookmaker, Match, MatchStatus, OddsHistory, SmartMoneyFlow, Team, Venue
|
||||
|
||||
__all__ = [
|
||||
'Base',
|
||||
'Bookmaker',
|
||||
'Match',
|
||||
'MatchStatus',
|
||||
'OddsHistory',
|
||||
'SmartMoneyFlow',
|
||||
'Team',
|
||||
'Venue',
|
||||
'get_engine',
|
||||
'get_session_factory',
|
||||
'SessionFactory',
|
||||
]
|
||||
|
||||
26
platform/backend/app/db/base.py
Normal file
26
platform/backend/app/db/base.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
import os
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Project ORM base model."""
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql+asyncpg://fifa_user:change_me@fifa2026-postgres:5432/fifa2026')
|
||||
|
||||
|
||||
def get_engine(database_url: str = DATABASE_URL):
|
||||
"""Create asynchronous SQLAlchemy engine for production use."""
|
||||
|
||||
return create_async_engine(database_url, echo=False, pool_pre_ping=True)
|
||||
|
||||
|
||||
def get_session_factory(database_url: str = DATABASE_URL):
|
||||
"""Create session factory for async query operations."""
|
||||
|
||||
engine = get_engine(database_url)
|
||||
return async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
SessionFactory = get_session_factory()
|
||||
199
platform/backend/app/db/models.py
Normal file
199
platform/backend/app/db/models.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MatchStatus(str, Enum):
|
||||
PRE_MATCH = 'pre-match'
|
||||
IN_PLAY = 'in-play'
|
||||
FINISHED = 'finished'
|
||||
|
||||
|
||||
class Venue(Base):
|
||||
"""球場主資料:海拔與時區是 2026 世界盃關鍵參數。"""
|
||||
|
||||
__tablename__ = 'venues'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
city: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
country: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
altitude_meters: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
|
||||
matches: Mapped[list[Match]] = relationship('Match', back_populates='venue', lazy='raise')
|
||||
|
||||
|
||||
class Team(Base):
|
||||
"""球隊主表,保留排名與 Elo 給量化模型做能力修正。"""
|
||||
|
||||
__tablename__ = 'teams'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(140), nullable=False, unique=True)
|
||||
fifa_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
current_elo_rating: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
group_name: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
home_matches: Mapped[list[Match]] = relationship(
|
||||
'Match',
|
||||
foreign_keys='Match.home_team_id',
|
||||
back_populates='home_team',
|
||||
)
|
||||
away_matches: Mapped[list[Match]] = relationship(
|
||||
'Match',
|
||||
foreign_keys='Match.away_team_id',
|
||||
back_populates='away_team',
|
||||
)
|
||||
|
||||
|
||||
class Bookmaker(Base):
|
||||
"""莊家主檔。"""
|
||||
|
||||
__tablename__ = 'bookmakers'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||
|
||||
odds_rows: Mapped[list[OddsHistory]] = relationship('OddsHistory', back_populates='bookmaker')
|
||||
|
||||
|
||||
class Match(Base):
|
||||
"""賽事基本結構,儲存 UTC 時間、場地與賽前 xG。"""
|
||||
|
||||
__tablename__ = 'matches'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
home_team_id: Mapped[str] = mapped_column(ForeignKey('teams.id'), nullable=False)
|
||||
away_team_id: Mapped[str] = mapped_column(ForeignKey('teams.id'), nullable=False)
|
||||
venue_id: Mapped[str] = mapped_column(ForeignKey('venues.id'), nullable=False)
|
||||
|
||||
match_time_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
status: Mapped[MatchStatus] = mapped_column(
|
||||
SAEnum(MatchStatus, name='match_status', native_enum=False),
|
||||
default=MatchStatus.PRE_MATCH,
|
||||
)
|
||||
home_xg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
away_xg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
home_team: Mapped[Team] = relationship('Team', foreign_keys=[home_team_id], back_populates='home_matches')
|
||||
away_team: Mapped[Team] = relationship('Team', foreign_keys=[away_team_id], back_populates='away_matches')
|
||||
venue: Mapped[Venue] = relationship('Venue', back_populates='matches')
|
||||
odds_history: Mapped[list[OddsHistory]] = relationship('OddsHistory', back_populates='match')
|
||||
recommendations: Mapped[list['ValueBetRecommendation']] = relationship(
|
||||
'ValueBetRecommendation',
|
||||
back_populates='match',
|
||||
cascade='all, delete-orphan',
|
||||
)
|
||||
|
||||
|
||||
class OddsHistory(Base):
|
||||
"""時間序列賠率表(待轉為 TimescaleDB Hypertable)。"""
|
||||
|
||||
__tablename__ = 'odds_history'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True)
|
||||
bookmaker_id: Mapped[str] = mapped_column(ForeignKey('bookmakers.id'), nullable=False, index=True)
|
||||
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
selection: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
decimal_odds: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
implied_probability: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
match: Mapped[Match] = relationship('Match', back_populates='odds_history')
|
||||
bookmaker: Mapped[Bookmaker] = relationship('Bookmaker', back_populates='odds_rows')
|
||||
|
||||
|
||||
class SmartMoneyFlow(Base):
|
||||
"""聰明錢流向快照表。"""
|
||||
|
||||
__tablename__ = 'smart_money_flow'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True)
|
||||
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
selection: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
ticket_pct: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
handle_pct: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
sharp_indicator: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
|
||||
class ValueBetRecommendation(Base):
|
||||
"""可驗證獲利帳本紀錄(公開透明)。"""
|
||||
|
||||
__tablename__ = 'value_bet_recommendations'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
match_id: Mapped[str] = mapped_column(ForeignKey('matches.id'), nullable=False, index=True)
|
||||
market_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
selection: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
stake: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
recommended_odds: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
closing_odds: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
is_win: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
settled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
clv_ratio: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
pnl: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
note: Mapped[str | None] = mapped_column(String(240), nullable=True)
|
||||
|
||||
match: Mapped[Match] = relationship('Match', back_populates='recommendations')
|
||||
|
||||
|
||||
class AffiliateBookmaker(Base):
|
||||
"""聯盟行銷博彩公司追蹤碼設定。"""
|
||||
|
||||
__tablename__ = 'affiliate_bookmakers'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||
tracking_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
commission_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
|
||||
|
||||
class AffiliateClick(Base):
|
||||
"""聯盟行銷跳轉點擊紀錄(防廣告攔截)。"""
|
||||
|
||||
__tablename__ = 'affiliate_clicks'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
bookmaker_id: Mapped[str] = mapped_column(ForeignKey('affiliate_bookmakers.id'), nullable=False, index=True)
|
||||
user_ip_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
referrer: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
|
||||
converted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
"""量化大神排行榜與跟單系統的用戶資料。"""
|
||||
|
||||
__tablename__ = 'user_profiles'
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||
clv_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
roi_30d: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
sharp_rating: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class CopyBet(Base):
|
||||
"""一鍵跟單交易紀錄。"""
|
||||
|
||||
__tablename__ = 'copy_bets'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
follower_id: Mapped[str] = mapped_column(ForeignKey('user_profiles.id'), nullable=False, index=True)
|
||||
leader_id: Mapped[str] = mapped_column(ForeignKey('user_profiles.id'), nullable=False, index=True)
|
||||
recommendation_id: Mapped[str] = mapped_column(ForeignKey('value_bet_recommendations.id'), nullable=False)
|
||||
follower_stake: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
copied_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
|
||||
1
platform/backend/app/ingestion/__init__.py
Normal file
1
platform/backend/app/ingestion/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
136
platform/backend/app/ingestion/cache.py
Normal file
136
platform/backend/app/ingestion/cache.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Dict, Mapping
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
ARBITRAGE_LUA = r'''
|
||||
local odds_json = ARGV[1]
|
||||
local payload = cjson.decode(odds_json)
|
||||
|
||||
local grouped = {}
|
||||
for _, row in ipairs(payload) do
|
||||
local market = row.market_type
|
||||
local selection = row.selection
|
||||
local odds = tonumber(row.decimal_odds)
|
||||
if market and selection and odds and odds > 0 then
|
||||
if grouped[market] == nil then
|
||||
grouped[market] = {}
|
||||
end
|
||||
if grouped[market][selection] == nil or odds > grouped[market][selection] then
|
||||
grouped[market][selection] = odds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for market, selections in pairs(grouped) do
|
||||
local prob_sum = 0
|
||||
local count = 0
|
||||
for _, odds in pairs(selections) do
|
||||
prob_sum = prob_sum + (1 / odds)
|
||||
count = count + 1
|
||||
end
|
||||
if count > 1 then
|
||||
out[market] = {
|
||||
has_arbitrage = (prob_sum < 1),
|
||||
implied_total_probability = prob_sum,
|
||||
edge = math.max(1 - prob_sum, 0),
|
||||
best_odds = selections,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return cjson.encode(out)
|
||||
'''
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MatchState:
|
||||
"""賽中 Hash 快照欄位。"""
|
||||
|
||||
home_score: int
|
||||
away_score: int
|
||||
possession_home_pct: float
|
||||
possession_away_pct: float
|
||||
red_cards_home: int
|
||||
red_cards_away: int
|
||||
|
||||
|
||||
class MatchCacheManager:
|
||||
"""賽事 Redis 快取層。
|
||||
|
||||
- live:{match_id}:odds 存 JSON,即時賠率
|
||||
- live:{match_id}:state 存 Hash,包含比分、控球率、紅牌數
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self.redis = redis
|
||||
self._lua_sha: str | None = None
|
||||
|
||||
async def _ensure_lua(self) -> str:
|
||||
if self._lua_sha is None:
|
||||
self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA)
|
||||
return self._lua_sha
|
||||
|
||||
async def set_match_odds(
|
||||
self,
|
||||
match_id: str,
|
||||
payload: list[dict[str, Any]],
|
||||
*,
|
||||
ttl_seconds: int = 45,
|
||||
finished: bool = False,
|
||||
) -> None:
|
||||
key = f'live:{match_id}:odds'
|
||||
value = json.dumps(payload, ensure_ascii=False)
|
||||
ttl = 7200 if finished else ttl_seconds
|
||||
await self.redis.set(name=key, value=value, ex=ttl)
|
||||
|
||||
async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]:
|
||||
key = f'live:{match_id}:odds'
|
||||
raw = await self.redis.get(key)
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode()
|
||||
return json.loads(raw)
|
||||
|
||||
async def set_match_state(
|
||||
self,
|
||||
match_id: str,
|
||||
state: MatchState | Mapping[str, Any],
|
||||
*,
|
||||
ttl_seconds: int = 7200,
|
||||
) -> None:
|
||||
key = f'live:{match_id}:state'
|
||||
mapping = {
|
||||
'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score,
|
||||
'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score,
|
||||
'possession_home_pct': state['possession_home_pct'] if isinstance(state, Mapping) else state.possession_home_pct,
|
||||
'possession_away_pct': state['possession_away_pct'] if isinstance(state, Mapping) else state.possession_away_pct,
|
||||
'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home,
|
||||
'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away,
|
||||
}
|
||||
await self.redis.hset(name=key, mapping=mapping)
|
||||
await self.redis.expire(key, ttl_seconds)
|
||||
|
||||
async def get_match_state(self, match_id: str) -> dict[str, str] | None:
|
||||
key = f'live:{match_id}:state'
|
||||
result = await self.redis.hgetall(key)
|
||||
return {str(k): str(v) for k, v in result.items()} if result else None
|
||||
|
||||
async def calculate_arbitrage_for_match(self, match_id: str) -> dict[str, Any]:
|
||||
odds = await self.get_match_odds(match_id)
|
||||
if not odds:
|
||||
return {}
|
||||
|
||||
sha = await self._ensure_lua()
|
||||
result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False))
|
||||
if isinstance(result, bytes):
|
||||
result = result.decode()
|
||||
if isinstance(result, str):
|
||||
return json.loads(result)
|
||||
return result
|
||||
168
platform/backend/app/ingestion/worker.py
Normal file
168
platform/backend/app/ingestion/worker.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .cache import MatchCacheManager
|
||||
|
||||
|
||||
TEAM_ALIAS_MAP = {
|
||||
'USA': 'USMNT',
|
||||
'United States': 'USMNT',
|
||||
'United States of America': 'USMNT',
|
||||
'USMNT': 'USMNT',
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceOdds:
|
||||
match_id: str
|
||||
home_team: str
|
||||
away_team: str
|
||||
market_type: str
|
||||
selection: str
|
||||
decimal_odds: float
|
||||
bookmaker: str
|
||||
status: str = 'in-play'
|
||||
|
||||
|
||||
def normalize_team_name(raw_name: str) -> str:
|
||||
"""對齊來自不同博彩商的球隊名稱,返回標準化內部 ID。"""
|
||||
|
||||
normalized = raw_name.strip()
|
||||
return TEAM_ALIAS_MAP.get(normalized, normalized)
|
||||
|
||||
|
||||
class OddsIngestionWorker:
|
||||
"""非同步抓取賽事賠率與推入 Redis 快取的 Worker。"""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, endpoint: str, api_key: str) -> None:
|
||||
self.session = session
|
||||
self.endpoint = endpoint
|
||||
self.api_key = api_key
|
||||
|
||||
async def _request_with_backoff(self, url: str, *, max_attempts: int = 5) -> Mapping[str, Any]:
|
||||
delay = 0.5
|
||||
attempts = 0
|
||||
while True:
|
||||
attempts += 1
|
||||
try:
|
||||
async with self.session.get(url, timeout=20) as resp:
|
||||
if resp.status == 429:
|
||||
if attempts >= max_attempts:
|
||||
text = await resp.text()
|
||||
raise RuntimeError(f'HTTP 429 Too Many Requests: {text}')
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status >= 500:
|
||||
if attempts >= max_attempts:
|
||||
resp.raise_for_status()
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
if attempts >= max_attempts:
|
||||
raise RuntimeError(f'HTTP request failed: {exc!s}') from exc
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
async def fetch_latest_matches(self) -> list[SourceOdds]:
|
||||
params = {'api_key': self.api_key}
|
||||
url = f'{self.endpoint}/v1/odds'
|
||||
payload = await self._request_with_backoff(url)
|
||||
items = payload.get('data', []) if isinstance(payload, Mapping) else []
|
||||
|
||||
normalized: list[SourceOdds] = []
|
||||
for row in items:
|
||||
try:
|
||||
raw_home = row['home_team']
|
||||
raw_away = row['away_team']
|
||||
normalized.append(
|
||||
SourceOdds(
|
||||
match_id=str(row['match_id']),
|
||||
home_team=normalize_team_name(str(raw_home)),
|
||||
away_team=normalize_team_name(str(raw_away)),
|
||||
market_type=str(row['market_type']),
|
||||
selection=str(row['selection']),
|
||||
decimal_odds=float(row['odds']),
|
||||
bookmaker=str(row.get('bookmaker', 'unknown')),
|
||||
status=str(row.get('status', 'in-play')),
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return normalized
|
||||
|
||||
async def sync_to_cache(
|
||||
self,
|
||||
cache: MatchCacheManager,
|
||||
*,
|
||||
ttl_seconds: int = 45,
|
||||
) -> dict[str, int]:
|
||||
"""抓取賽事即時賠率並更新 Redis 快取。"""
|
||||
|
||||
rows = await self.fetch_latest_matches()
|
||||
payload_by_match: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
|
||||
for row in rows:
|
||||
payload_by_match[row.match_id].append(
|
||||
{
|
||||
'match_id': row.match_id,
|
||||
'home_team': row.home_team,
|
||||
'away_team': row.away_team,
|
||||
'market_type': row.market_type,
|
||||
'selection': row.selection,
|
||||
'decimal_odds': row.decimal_odds,
|
||||
'bookmaker': row.bookmaker,
|
||||
'status': row.status,
|
||||
},
|
||||
)
|
||||
|
||||
for match_id, rows_payload in payload_by_match.items():
|
||||
finished = any(item['status'] == 'finished' for item in rows_payload)
|
||||
await cache.set_match_odds(match_id, rows_payload, ttl_seconds=ttl_seconds, finished=finished)
|
||||
|
||||
return {match_id: len(payload_rows) for match_id, payload_rows in payload_by_match.items()}
|
||||
|
||||
async def run_once(
|
||||
self,
|
||||
cache: MatchCacheManager,
|
||||
*,
|
||||
ttl_seconds: int = 45,
|
||||
) -> dict[str, int]:
|
||||
"""單次輪詢流程(可給排程器或事件輪詢器呼叫)。"""
|
||||
|
||||
return await self.sync_to_cache(cache, ttl_seconds=ttl_seconds)
|
||||
|
||||
|
||||
def to_cache_payload(rows: list[SourceOdds]) -> list[dict[str, Any]]:
|
||||
"""將來源資料轉為 Redis 快取可存取結構。"""
|
||||
|
||||
return [
|
||||
{
|
||||
'match_id': row.match_id,
|
||||
'home_team': row.home_team,
|
||||
'away_team': row.away_team,
|
||||
'market_type': row.market_type,
|
||||
'selection': row.selection,
|
||||
'decimal_odds': row.decimal_odds,
|
||||
'bookmaker': row.bookmaker,
|
||||
'status': row.status,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def serialize_error(error: Exception) -> str:
|
||||
"""錯誤訊息格式化,供上層日誌與警報系統使用。"""
|
||||
|
||||
return json.dumps({'error': str(error), 'type': error.__class__.__name__})
|
||||
1473
platform/backend/app/main.py
Normal file
1473
platform/backend/app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
134
platform/backend/app/services/redis_manager.py
Normal file
134
platform/backend/app/services/redis_manager.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Redis 快取管理層(賠率與賽事快取)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
ARBITRAGE_LUA = r'''
|
||||
local odds_json = ARGV[1]
|
||||
local payload = cjson.decode(odds_json)
|
||||
|
||||
local by_market = {}
|
||||
for _, row in ipairs(payload) do
|
||||
local market = row.market_type
|
||||
local selection = row.selection
|
||||
local odds = tonumber(row.decimal_odds)
|
||||
local bookmaker = tostring(row.bookmaker or "")
|
||||
|
||||
if market and selection and odds and odds > 0 then
|
||||
if by_market[market] == nil then
|
||||
by_market[market] = {}
|
||||
end
|
||||
if by_market[market][selection] == nil or odds > by_market[market][selection].odds then
|
||||
by_market[market][selection] = {odds = odds, bookmaker = bookmaker}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for market, selections in pairs(by_market) do
|
||||
local inv = 0
|
||||
local n = 0
|
||||
for _, item in pairs(selections) do
|
||||
inv = inv + (1 / item.odds)
|
||||
n = n + 1
|
||||
end
|
||||
if n >= 2 then
|
||||
out[market] = {
|
||||
has_arbitrage = (inv < 1),
|
||||
implied_total = inv,
|
||||
best_odds = selections,
|
||||
edge = math.max(1 - inv, 0)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return cjson.encode(out)
|
||||
'''
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MatchState:
|
||||
home_score: int
|
||||
away_score: int
|
||||
minute: int
|
||||
possession_home: float
|
||||
possession_away: float
|
||||
red_cards_home: int
|
||||
red_cards_away: int
|
||||
|
||||
|
||||
class MatchCacheManager:
|
||||
"""實作高頻快取:賠率 JSON + 賽事狀態 Hash。"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self.redis = redis
|
||||
self._lua_sha: str | None = None
|
||||
|
||||
async def _ensure_lua(self) -> str:
|
||||
if self._lua_sha is None:
|
||||
self._lua_sha = await self.redis.script_load(ARBITRAGE_LUA)
|
||||
return self._lua_sha
|
||||
|
||||
async def set_match_odds(
|
||||
self,
|
||||
match_id: str,
|
||||
payload: list[dict[str, Any]],
|
||||
*,
|
||||
finished: bool = False,
|
||||
) -> None:
|
||||
key = f'live_odds:{match_id}'
|
||||
ex = 7200 if finished else 30
|
||||
await self.redis.set(key, json.dumps(payload, ensure_ascii=False), ex=ex)
|
||||
|
||||
async def get_match_odds(self, match_id: str) -> list[dict[str, Any]]:
|
||||
key = f'live_odds:{match_id}'
|
||||
raw = await self.redis.get(key)
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode('utf-8')
|
||||
return json.loads(raw)
|
||||
|
||||
async def set_match_state(
|
||||
self,
|
||||
match_id: str,
|
||||
state: MatchState | Mapping[str, Any],
|
||||
*,
|
||||
finished: bool = False,
|
||||
) -> None:
|
||||
key = f'live_state:{match_id}'
|
||||
mapping = {
|
||||
'home_score': state['home_score'] if isinstance(state, Mapping) else state.home_score,
|
||||
'away_score': state['away_score'] if isinstance(state, Mapping) else state.away_score,
|
||||
'minute': state['minute'] if isinstance(state, Mapping) else state.minute,
|
||||
'possession_home': state['possession_home'] if isinstance(state, Mapping) else state.possession_home,
|
||||
'possession_away': state['possession_away'] if isinstance(state, Mapping) else state.possession_away,
|
||||
'red_cards_home': state['red_cards_home'] if isinstance(state, Mapping) else state.red_cards_home,
|
||||
'red_cards_away': state['red_cards_away'] if isinstance(state, Mapping) else state.red_cards_away,
|
||||
}
|
||||
await self.redis.hset(key, mapping=mapping)
|
||||
await self.redis.expire(key, 7200 if finished else 60)
|
||||
|
||||
async def get_match_state(self, match_id: str) -> dict[str, str] | None:
|
||||
key = f'live_state:{match_id}'
|
||||
result = await self.redis.hgetall(key)
|
||||
return {str(k): str(v) for k, v in result.items()} if result else None
|
||||
|
||||
async def calculate_arbitrage(self, match_id: str) -> dict[str, Any]:
|
||||
odds = await self.get_match_odds(match_id)
|
||||
if not odds:
|
||||
return {}
|
||||
|
||||
sha = await self._ensure_lua()
|
||||
result = await self.redis.evalsha(sha, 0, json.dumps(odds, ensure_ascii=False))
|
||||
if isinstance(result, bytes):
|
||||
result = result.decode()
|
||||
if isinstance(result, str):
|
||||
return json.loads(result)
|
||||
return result
|
||||
153
platform/backend/db_init_timescaledb.sql
Normal file
153
platform/backend/db_init_timescaledb.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS venues (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
altitude_meters INTEGER,
|
||||
timezone TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
fifa_rank INTEGER,
|
||||
current_elo_rating DOUBLE PRECISION,
|
||||
group_name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmakers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id TEXT PRIMARY KEY,
|
||||
home_team_id TEXT NOT NULL REFERENCES teams (id),
|
||||
away_team_id TEXT NOT NULL REFERENCES teams (id),
|
||||
venue_id TEXT NOT NULL REFERENCES venues (id),
|
||||
match_time_utc TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pre-match',
|
||||
home_xg DOUBLE PRECISION,
|
||||
away_xg DOUBLE PRECISION
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS odds_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
match_id TEXT NOT NULL REFERENCES matches (id),
|
||||
bookmaker_id TEXT NOT NULL REFERENCES bookmakers (id),
|
||||
market_type TEXT NOT NULL,
|
||||
selection TEXT NOT NULL,
|
||||
decimal_odds DOUBLE PRECISION NOT NULL,
|
||||
implied_probability DOUBLE PRECISION NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smart_money_flow (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
match_id TEXT NOT NULL REFERENCES matches (id),
|
||||
market_type TEXT NOT NULL,
|
||||
selection TEXT NOT NULL,
|
||||
ticket_pct DOUBLE PRECISION NOT NULL,
|
||||
handle_pct DOUBLE PRECISION NOT NULL,
|
||||
sharp_indicator BOOLEAN NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_time_status ON matches (match_time_utc, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_odds_match_market_recorded_at
|
||||
ON odds_history (match_id, market_type, recorded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_money_flow_match_recorded_at
|
||||
ON smart_money_flow (match_id, market_type, recorded_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS value_bet_recommendations (
|
||||
id TEXT PRIMARY KEY,
|
||||
match_id TEXT NOT NULL REFERENCES matches (id),
|
||||
market_type TEXT NOT NULL,
|
||||
selection TEXT NOT NULL,
|
||||
stake DOUBLE PRECISION NOT NULL,
|
||||
recommended_odds DOUBLE PRECISION NOT NULL,
|
||||
closing_odds DOUBLE PRECISION,
|
||||
is_win BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
settled_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
clv_ratio DOUBLE PRECISION,
|
||||
pnl DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_value_bet_recommendations_match_time
|
||||
ON value_bet_recommendations (match_id, settled_at DESC);
|
||||
|
||||
SELECT create_hypertable(
|
||||
'odds_history',
|
||||
'recorded_at',
|
||||
chunk_time_interval => INTERVAL '1 hour',
|
||||
if_not_exists => TRUE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_odds_history_time_gist
|
||||
ON odds_history USING GIST (recorded_at);
|
||||
|
||||
-- Stage 33: Affiliate Marketing
|
||||
CREATE TABLE IF NOT EXISTS affiliate_bookmakers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
tracking_url TEXT NOT NULL,
|
||||
commission_rate DOUBLE PRECISION NOT NULL DEFAULT 0.0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS affiliate_clicks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bookmaker_id TEXT NOT NULL REFERENCES affiliate_bookmakers (id),
|
||||
user_ip_hash TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
referrer TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
converted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_timestamp
|
||||
ON affiliate_clicks (timestamp DESC);
|
||||
|
||||
SELECT create_hypertable(
|
||||
'affiliate_clicks',
|
||||
'timestamp',
|
||||
chunk_time_interval => INTERVAL '1 day',
|
||||
if_not_exists => TRUE
|
||||
);
|
||||
|
||||
-- Note: TimescaleDB continuous aggregates require a bit more setup in modern versions.
|
||||
-- A simple materialized view for daily conversion rates:
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS affiliate_daily_conversions
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 day', timestamp) AS bucket,
|
||||
bookmaker_id,
|
||||
COUNT(*) AS total_clicks,
|
||||
COUNT(*) FILTER (WHERE converted = TRUE) AS total_conversions
|
||||
FROM affiliate_clicks
|
||||
GROUP BY bucket, bookmaker_id
|
||||
WITH NO DATA;
|
||||
|
||||
-- Stage 35: Social Trading (Copy Bets & Leaderboard)
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
clv_score DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
roi_30d DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
sharp_rating INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS copy_bets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
follower_id TEXT NOT NULL REFERENCES user_profiles(id),
|
||||
leader_id TEXT NOT NULL REFERENCES user_profiles(id),
|
||||
recommendation_id TEXT NOT NULL REFERENCES value_bet_recommendations(id),
|
||||
follower_stake DOUBLE PRECISION NOT NULL,
|
||||
copied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_copy_bets_leader_time
|
||||
ON copy_bets (leader_id, copied_at DESC);
|
||||
12
platform/backend/requirements.txt
Normal file
12
platform/backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
redis==5.2.1
|
||||
pydantic==2.10.5
|
||||
aiohttp==3.11.11
|
||||
asyncpg==0.30.0
|
||||
sqlalchemy[asyncio]==2.0.39
|
||||
pandas==2.2.3
|
||||
numpy==2.1.3
|
||||
scipy==1.14.1
|
||||
scikit-learn==1.5.2
|
||||
xgboost==2.1.0
|
||||
114
platform/backend/workers/odds_ingestion.py
Normal file
114
platform/backend/workers/odds_ingestion.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""非同步賠率抓取 Worker。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Mapping
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..app.services.redis_manager import MatchCacheManager
|
||||
|
||||
|
||||
TEAM_ALIAS = {
|
||||
'usa': 'USMNT',
|
||||
'united states': 'USMNT',
|
||||
'usmnt': 'USMNT',
|
||||
}
|
||||
|
||||
|
||||
def normalize_team_name(raw_name: str) -> str:
|
||||
"""將各莊家球隊名稱對齊到平台內部代號。"""
|
||||
|
||||
normalized = raw_name.strip().lower()
|
||||
return TEAM_ALIAS.get(normalized, raw_name.strip())
|
||||
|
||||
|
||||
class OddsIngestionWorker:
|
||||
"""非同步抓取外部賠率並推入 Redis 快取。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.endpoint = endpoint
|
||||
self.api_key = api_key
|
||||
|
||||
async def _request_with_backoff(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
max_attempts: int = 5,
|
||||
base_delay: float = 0.4,
|
||||
) -> Mapping[str, Any]:
|
||||
delay = base_delay
|
||||
attempt = 0
|
||||
|
||||
while attempt < max_attempts:
|
||||
attempt += 1
|
||||
try:
|
||||
async with self.session.get(url, timeout=15) as resp:
|
||||
if resp.status == 429:
|
||||
if attempt >= max_attempts:
|
||||
text = await resp.text()
|
||||
raise RuntimeError(f'HTTP 429: {text}')
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status >= 500:
|
||||
if attempt >= max_attempts:
|
||||
resp.raise_for_status()
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc:
|
||||
if attempt >= max_attempts:
|
||||
raise RuntimeError(f'fetch failed: {exc!r}') from exc
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
raise RuntimeError('unreachable')
|
||||
|
||||
async def fetch_live_odds(self) -> list[dict[str, Any]]:
|
||||
"""抓取原始賠率清單。"""
|
||||
|
||||
url = f'{self.endpoint.rstrip("/")}/v4/sports/soccer/odds?apiKey={self.api_key}'
|
||||
payload = await self._request_with_backoff(url)
|
||||
items = payload.get('data', []) if isinstance(payload, Mapping) else []
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for row in items if isinstance(items, list) else []:
|
||||
try:
|
||||
normalized.append(
|
||||
{
|
||||
'match_id': str(row['id']),
|
||||
'home_team': normalize_team_name(str(row['home_team'])),
|
||||
'away_team': normalize_team_name(str(row['away_team'])),
|
||||
'market_type': str(row['market_type']),
|
||||
'selection': str(row['selection']),
|
||||
'decimal_odds': float(row['odds']),
|
||||
'bookmaker': str(row.get('bookmaker', '')),
|
||||
},
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return normalized
|
||||
|
||||
async def run_once(self, cache: MatchCacheManager) -> int:
|
||||
"""單輪更新:抓取並寫入 Redis,回傳寫入比賽筆數。"""
|
||||
|
||||
rows = await self.fetch_live_odds()
|
||||
match_map: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
match_map.setdefault(row['match_id'], []).append(row)
|
||||
|
||||
for match_id, payload in match_map.items():
|
||||
await cache.set_match_odds(match_id, payload, finished=False)
|
||||
|
||||
return len(match_map)
|
||||
326
platform/deploy/k3s-manifests.yaml
Normal file
326
platform/deploy/k3s-manifests.yaml
Normal file
@@ -0,0 +1,326 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fifa2026
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: fifa2026-platform-config
|
||||
namespace: fifa2026
|
||||
data:
|
||||
APP_TIME_ZONE: Asia/Taipei
|
||||
NEXT_PUBLIC_WS_URL: wss://2026fifa.wooo.work/ws/matches
|
||||
NEXT_PUBLIC_API_ORIGIN: http://fifa2026-api:8000
|
||||
DATABASE_URL: postgresql://fifa_user:change_me@fifa2026-postgres:5432/fifa2026
|
||||
REDIS_URL: redis://fifa2026-redis:6379/0
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: fifa2026-secrets
|
||||
namespace: fifa2026
|
||||
type: Opaque
|
||||
stringData:
|
||||
NEXTAUTH_SECRET: changeme-nextauth-secret
|
||||
TELEGRAM_BOT_TOKEN: changeme-telegram-token
|
||||
TELEGRAM_CHAT_ID: changeme-chat-id
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-postgres
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: fifa2026
|
||||
- name: POSTGRES_USER
|
||||
value: fifa_user
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: change_me
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: pgdata
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: pgdata
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-postgres
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-redis
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-redis
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-api
|
||||
namespace: fifa2026
|
||||
labels:
|
||||
app: fifa2026-api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: ghcr.io/YOUR_ORG/2026fifa-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "8000"
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: REDIS_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: DATABASE_URL
|
||||
- name: NEXTAUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: NEXTAUTH_SECRET
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 15
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-api
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-api
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-web
|
||||
namespace: fifa2026
|
||||
labels:
|
||||
app: fifa2026-web
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-web
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: ghcr.io/YOUR_ORG/2026fifa-web:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NEXT_PUBLIC_WS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: NEXT_PUBLIC_WS_URL
|
||||
- name: NEXT_PUBLIC_API_ORIGIN
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: NEXT_PUBLIC_API_ORIGIN
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: DATABASE_URL
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: REDIS_URL
|
||||
- name: NEXTAUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: NEXTAUTH_SECRET
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 6
|
||||
periodSeconds: 8
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 12
|
||||
periodSeconds: 12
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-web
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-web
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-alert-worker
|
||||
namespace: fifa2026
|
||||
labels:
|
||||
app: fifa2026-alert-worker
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-alert-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-alert-worker
|
||||
spec:
|
||||
containers:
|
||||
- name: worker
|
||||
image: ghcr.io/YOUR_ORG/2026fifa-alerts:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: REDIS_URL
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: TELEGRAM_BOT_TOKEN
|
||||
- name: TELEGRAM_CHAT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: TELEGRAM_CHAT_ID
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: fifa2026-ingress
|
||||
namespace: fifa2026
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- 2026fifa.wooo.work
|
||||
secretName: fifa2026-tls
|
||||
rules:
|
||||
- host: 2026fifa.wooo.work
|
||||
http:
|
||||
paths:
|
||||
- path: /ws/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: fifa2026-api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: fifa2026-api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: fifa2026-web
|
||||
port:
|
||||
number: 3000
|
||||
32
platform/deploy/k3s/alerts-deployment.yaml
Normal file
32
platform/deploy/k3s/alerts-deployment.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-alert-worker
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-alert-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-alert-worker
|
||||
spec:
|
||||
containers:
|
||||
- name: worker
|
||||
image: ghcr.io/your-org/2026fifa-alerts:latest
|
||||
env:
|
||||
- name: REDIS_URL
|
||||
value: redis://fifa2026-redis:6379/0
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: TELEGRAM_BOT_TOKEN
|
||||
- name: TELEGRAM_CHAT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: TELEGRAM_CHAT_ID
|
||||
|
||||
61
platform/deploy/k3s/backend-deployment.yaml
Normal file
61
platform/deploy/k3s/backend-deployment.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-api
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: ghcr.io/your-org/2026fifa-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "8000"
|
||||
- name: REDIS_URL
|
||||
value: redis://fifa2026-redis:6379/0
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: DATABASE_URL
|
||||
- name: NEXTAUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: NEXTAUTH_SECRET
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 15
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-api
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-api
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
|
||||
11
platform/deploy/k3s/configmap.yaml
Normal file
11
platform/deploy/k3s/configmap.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: fifa2026-platform-config
|
||||
namespace: fifa2026
|
||||
data:
|
||||
APP_TIME_ZONE: Asia/Taipei
|
||||
NEXT_PUBLIC_WS_URL: wss://2026fifa.wooo.work/ws/matches
|
||||
NEXT_PUBLIC_API_ORIGIN: http://fifa2026-api:8000
|
||||
DATABASE_URL: postgresql://fifa_user:change_me@fifa2026-postgres:5432/fifa2026
|
||||
REDIS_URL: redis://fifa2026-redis:6379/0
|
||||
40
platform/deploy/k3s/ingress.yaml
Normal file
40
platform/deploy/k3s/ingress.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: fifa2026-ingress
|
||||
namespace: fifa2026
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- 2026fifa.wooo.work
|
||||
secretName: fifa2026-tls
|
||||
rules:
|
||||
- host: 2026fifa.wooo.work
|
||||
http:
|
||||
paths:
|
||||
- path: /ws/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: fifa2026-api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: fifa2026-api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: fifa2026-web
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
5
platform/deploy/k3s/namespace.yaml
Normal file
5
platform/deploy/k3s/namespace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fifa2026
|
||||
|
||||
47
platform/deploy/k3s/postgres-deployment.yaml
Normal file
47
platform/deploy/k3s/postgres-deployment.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-postgres
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: fifa2026
|
||||
- name: POSTGRES_USER
|
||||
value: fifa_user
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: change_me
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: pgdata
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: pgdata
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-postgres
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
|
||||
34
platform/deploy/k3s/redis-deployment.yaml
Normal file
34
platform/deploy/k3s/redis-deployment.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-redis
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-redis
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
|
||||
11
platform/deploy/k3s/secret.yaml
Normal file
11
platform/deploy/k3s/secret.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: fifa2026-secrets
|
||||
namespace: fifa2026
|
||||
type: Opaque
|
||||
stringData:
|
||||
NEXTAUTH_SECRET: changeme-nextauth-secret
|
||||
TELEGRAM_BOT_TOKEN: changeme-telegram-token
|
||||
TELEGRAM_CHAT_ID: changeme-chat-id
|
||||
|
||||
56
platform/deploy/k3s/web-deployment.yaml
Normal file
56
platform/deploy/k3s/web-deployment.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fifa2026-web
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fifa2026-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fifa2026-web
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: ghcr.io/your-org/2026fifa-web:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NEXTAUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fifa2026-secrets
|
||||
key: NEXTAUTH_SECRET
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: DATABASE_URL
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: REDIS_URL
|
||||
- name: NEXT_PUBLIC_WS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: fifa2026-platform-config
|
||||
key: NEXT_PUBLIC_WS_URL
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fifa2026-web
|
||||
namespace: fifa2026
|
||||
spec:
|
||||
selector:
|
||||
app: fifa2026-web
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
|
||||
7
platform/web/.env.example
Normal file
7
platform/web/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
DATABASE_URL=postgresql://fifa_user:change_me@localhost:5432/fifa2026
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
NEXTAUTH_SECRET=replace-me
|
||||
NEXTAUTH_URL=https://2026fifa.wooo.work
|
||||
NEXT_PUBLIC_WS_URL=wss://2026fifa.wooo.work/ws/matches
|
||||
NEXT_PUBLIC_API_ORIGIN=https://2026fifa.wooo.work/api
|
||||
|
||||
16
platform/web/Dockerfile
Normal file
16
platform/web/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
||||
25
platform/web/app/api/analytics/backtest/route.ts
Normal file
25
platform/web/app/api/analytics/backtest/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/backtest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '回測服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
21
platform/web/app/api/analytics/daily-card/[date]/route.ts
Normal file
21
platform/web/app/api/analytics/daily-card/[date]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ date: string }> }) {
|
||||
try {
|
||||
const { date } = await params;
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/daily-card/${date}`, { method: 'GET' });
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '每日操盤卡服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/hedging/route.ts
Normal file
25
platform/web/app/api/analytics/hedging/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/hedging`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '對沖計算服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/kelly/route.ts
Normal file
25
platform/web/app/api/analytics/kelly/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/kelly`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '凱利計算服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/match-conditions/route.ts
Normal file
25
platform/web/app/api/analytics/match-conditions/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/match-conditions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '比賽條件分析服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
26
platform/web/app/api/analytics/matches/[matchId]/route.ts
Normal file
26
platform/web/app/api/analytics/matches/[matchId]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ matchId: string }> }) {
|
||||
try {
|
||||
const { matchId } = await params;
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '賽事預測服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/matches/route.ts
Normal file
25
platform/web/app/api/analytics/matches/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '賽事列表服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
26
platform/web/app/api/analytics/ml-edge/route.ts
Normal file
26
platform/web/app/api/analytics/ml-edge/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/ml-edge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'ML Edge 服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
25
platform/web/app/api/analytics/ml-edge/train/route.ts
Normal file
25
platform/web/app/api/analytics/ml-edge/train/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/ml-edge/train`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'ML Edge 訓練服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/player-props/route.ts
Normal file
25
platform/web/app/api/analytics/player-props/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/player-props`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '玩家道具盤分析服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/portfolio/leaks/route.ts
Normal file
25
platform/web/app/api/analytics/portfolio/leaks/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/portfolio/leaks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '投注漏財分析服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const limit = req.nextUrl.searchParams.get('limit') || '200';
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/proof-of-yield/ledger?limit=${limit}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '公開帳本服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
5
platform/web/app/api/analytics/proof-of-yield/route.ts
Normal file
5
platform/web/app/api/analytics/proof-of-yield/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ message: '請使用 /api/analytics/proof-of-yield/ledger 或 /api/analytics/proof-of-yield/settle' }, { status: 404 });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/proof-of-yield/settle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '結算服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
25
platform/web/app/api/analytics/rlm/route.ts
Normal file
25
platform/web/app/api/analytics/rlm/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.text();
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/rlm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
return NextResponse.json({ message }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'RLM 服務暫時無法連線';
|
||||
return NextResponse.json({ message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
4
platform/web/app/api/auth/[...nextauth]/route.ts
Normal file
4
platform/web/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
277
platform/web/app/backtesting/page.tsx
Normal file
277
platform/web/app/backtesting/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { EquityCurveChart } from '@/components/EquityCurveChart';
|
||||
import { runBacktest, type BacktestResponse, type BacktestTrade } from '@/lib/analytics-api';
|
||||
|
||||
type TradeRecord = {
|
||||
id: string;
|
||||
date: string;
|
||||
odds: number;
|
||||
isWin: boolean;
|
||||
stake: number;
|
||||
altitude: number;
|
||||
handicap: number;
|
||||
weather: string;
|
||||
recentWinRate: number;
|
||||
market: string;
|
||||
};
|
||||
|
||||
const history: TradeRecord[] = [
|
||||
{ id: 'T-001', date: '2026-01-05', odds: 1.95, isWin: true, stake: 100, altitude: 1700, handicap: -1, weather: '乾燥', recentWinRate: 0.56, market: '讓球' },
|
||||
{ id: 'T-002', date: '2026-01-10', odds: 2.1, isWin: false, stake: 100, altitude: 1200, handicap: -0.5, weather: '潮濕', recentWinRate: 0.49, market: '讓球' },
|
||||
{ id: 'T-003', date: '2026-01-12', odds: 1.82, isWin: true, stake: 100, altitude: 1600, handicap: 0, weather: '高溫', recentWinRate: 0.68, market: '大小分' },
|
||||
{ id: 'T-004', date: '2026-01-19', odds: 2.25, isWin: true, stake: 100, altitude: 2100, handicap: -1.5, weather: '乾燥', recentWinRate: 0.62, market: '讓球' },
|
||||
{ id: 'T-005', date: '2026-01-27', odds: 1.74, isWin: false, stake: 100, altitude: 2200, handicap: 0.5, weather: '低溫', recentWinRate: 0.44, market: '1x2' },
|
||||
{ id: 'T-006', date: '2026-02-01', odds: 2.02, isWin: true, stake: 100, altitude: 1100, handicap: -0.5, weather: '中高濕', recentWinRate: 0.58, market: '讓球' },
|
||||
{ id: 'T-007', date: '2026-02-09', odds: 1.91, isWin: true, stake: 100, altitude: 1800, handicap: -1, weather: '乾燥', recentWinRate: 0.61, market: '讓球' },
|
||||
];
|
||||
|
||||
function toLocalISOString(date: string): string {
|
||||
return new Date(`${date}T15:00:00+08:00`).toISOString();
|
||||
}
|
||||
|
||||
function fallbackRun(trades: TradeRecord[]) {
|
||||
let capital = 10000;
|
||||
const points: { ts: string; capital: number }[] = [{ ts: 'start', capital }];
|
||||
let wins = 0;
|
||||
|
||||
for (const trade of trades) {
|
||||
const pnl = trade.isWin ? trade.stake * (trade.odds - 1) : -trade.stake;
|
||||
capital += pnl;
|
||||
if (trade.isWin) wins += 1;
|
||||
points.push({ ts: trade.date, capital: Number(capital.toFixed(2)) });
|
||||
}
|
||||
|
||||
const hitRate = trades.length > 0 ? (wins / trades.length) * 100 : 0;
|
||||
const stakeTotal = trades.length * 100;
|
||||
const roi = stakeTotal > 0 ? ((capital - 10000) / stakeTotal) * 100 : 0;
|
||||
const maxDrawdown = points.reduce((acc, point, idx) => {
|
||||
const maxBefore = Math.max(...points.slice(0, idx + 1).map((item) => item.capital));
|
||||
const dd = ((maxBefore - point.capital) / maxBefore) * 100;
|
||||
return Math.max(acc, dd);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
matched: trades.length,
|
||||
total: trades.length,
|
||||
hit_count: wins,
|
||||
win_rate: Number(hitRate.toFixed(4)),
|
||||
final_capital: Number(capital.toFixed(4)),
|
||||
net_profit: Number((capital - 10000).toFixed(4)),
|
||||
roi_percent: Number(roi.toFixed(4)),
|
||||
max_drawdown_percent: Number(maxDrawdown.toFixed(4)),
|
||||
equity_curve: points,
|
||||
} as BacktestResponse;
|
||||
}
|
||||
|
||||
export default function BacktestingPage() {
|
||||
const [altMin, setAltMin] = useState(1400);
|
||||
const [altMax, setAltMax] = useState(2200);
|
||||
const [handicapMin, setHandicapMin] = useState(-2);
|
||||
const [handicapMax, setHandicapMax] = useState(0);
|
||||
const [weather, setWeather] = useState('全部');
|
||||
const [minRecentWinRate, setMinRecentWinRate] = useState(0.5);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [result, setResult] = useState<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 filtered = useMemo(() => {
|
||||
return history.filter((trade) => {
|
||||
if (trade.altitude < altMin || trade.altitude > altMax) return false;
|
||||
if (trade.handicap < handicapMin || trade.handicap > handicapMax) return false;
|
||||
if (weather !== '全部' && trade.weather !== weather) return false;
|
||||
if (trade.recentWinRate < minRecentWinRate) return false;
|
||||
return true;
|
||||
});
|
||||
}, [altMin, altMax, handicapMin, handicapMax, weather, minRecentWinRate]);
|
||||
|
||||
const requestPayload = useMemo(
|
||||
() => ({
|
||||
initial_capital: 10000,
|
||||
strategy: {
|
||||
weather: weather === '全部' ? null : weather,
|
||||
altitude_min_meters: altMin,
|
||||
altitude_max_meters: altMax,
|
||||
handicap_min: handicapMin,
|
||||
handicap_max: handicapMax,
|
||||
recent_win_rate_min: minRecentWinRate,
|
||||
recent_win_rate_max: null,
|
||||
market_types: null,
|
||||
start_at: null,
|
||||
end_at: null,
|
||||
},
|
||||
historical_trades: history.map<BacktestTrade>((trade) => ({
|
||||
trade_id: trade.id,
|
||||
settled_at: toLocalISOString(trade.date),
|
||||
odds: trade.odds,
|
||||
is_win: trade.isWin,
|
||||
stake: trade.stake,
|
||||
altitude_meters: trade.altitude,
|
||||
handicap: trade.handicap,
|
||||
weather: trade.weather,
|
||||
recent_form_win_rate: trade.recentWinRate,
|
||||
market_type: trade.market,
|
||||
selection: 'home',
|
||||
})),
|
||||
}),
|
||||
[altMin, altMax, handicapMin, handicapMax, weather, minRecentWinRate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
|
||||
const execute = async () => {
|
||||
try {
|
||||
const data = await runBacktest(requestPayload);
|
||||
if (active) {
|
||||
setResult(data);
|
||||
setErrorMessage('');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
setErrorMessage(error instanceof Error ? error.message : '回測服務無法連線,改用本機試算');
|
||||
setResult(fallbackRun(filtered));
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
execute();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [filtered, requestPayload]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">自訂策略回測引擎</h2>
|
||||
|
||||
<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">
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
海拔下限(m)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={altMin}
|
||||
onChange={(event) => setAltMin(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
海拔上限(m)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={altMax}
|
||||
onChange={(event) => setAltMax(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
讓球下限
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={handicapMin}
|
||||
onChange={(event) => setHandicapMin(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
讓球上限
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={handicapMax}
|
||||
onChange={(event) => setHandicapMax(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
天氣
|
||||
<select
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
value={weather}
|
||||
onChange={(event) => setWeather(event.target.value)}
|
||||
>
|
||||
<option value="全部">全部</option>
|
||||
<option value="乾燥">乾燥</option>
|
||||
<option value="潮濕">潮濕</option>
|
||||
<option value="中高濕">中高濕</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
近期勝率下限
|
||||
<input
|
||||
className="mt-2 w-full"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={minRecentWinRate}
|
||||
onChange={(event) => setMinRecentWinRate(Number(event.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-[#7a5b46]">{(minRecentWinRate * 100).toFixed(0)}%</p>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="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="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="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.roi_percent.toFixed(2)}%</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<p className="dot-matrix text-sm text-[#7d2a15]">
|
||||
命中:{result.hit_count} / {result.total}
|
||||
{' '}
|
||||
| 最終資金:{result.final_capital.toFixed(2)}
|
||||
| 淨利:{result.net_profit.toFixed(2)}
|
||||
</p>
|
||||
{loading ? <p className="mt-1 text-xs text-[#a16b4f]">回測引擎計算中…</p> : null}
|
||||
{errorMessage ? <p className="mt-1 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||||
</section>
|
||||
|
||||
<EquityCurveChart
|
||||
title="資金成長曲線(Equity Curve)"
|
||||
points={result.equity_curve}
|
||||
maxDrawdown={result.max_drawdown_percent}
|
||||
/>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">交易明細(依條件)</h3>
|
||||
<ul className="mt-3 space-y-2 text-sm text-[#7a5b46]">
|
||||
{filtered.map((trade) => (
|
||||
<li key={trade.id} className="rounded-lg bg-white/70 p-3">
|
||||
{trade.id}|{trade.date}|{trade.market}|讓球 {trade.handicap}|海拔 {trade.altitude}m|
|
||||
{trade.isWin ? '勝' : '敗'}|賠率 {trade.odds}
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 ? <li className="rounded-lg bg-white/70 p-3">此條件下目前無筆交易</li> : null}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
platform/web/app/daily-card/page.tsx
Normal file
151
platform/web/app/daily-card/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { formatToTaipeiTime } from '@/lib/timezone';
|
||||
import { getDailyCard, type DailyCardItem } from '@/lib/analytics-api';
|
||||
import { ActionableBetCard } from '@/components/ActionableBetCard';
|
||||
|
||||
const TAB_MAP: Record<'safe' | 'risk' | 'parlay' | 'sgp', string> = {
|
||||
safe: 'SAFE_SINGLE',
|
||||
risk: 'HIGH_RISK_SINGLE',
|
||||
parlay: 'SAFE_PARLAY',
|
||||
sgp: 'SGP_LOTTERY',
|
||||
};
|
||||
|
||||
const sampleDate = formatToTaipeiTime(new Date().toISOString(), 'yyyy-MM-dd');
|
||||
|
||||
export default function DailyCardPage() {
|
||||
const [targetDate] = useState(sampleDate);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'safe' | 'risk' | 'parlay' | 'sgp'>('safe');
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getDailyCard>> | null>(null);
|
||||
|
||||
const tabCards: Record<'safe' | 'risk' | 'parlay' | 'sgp', DailyCardItem[]> = useMemo(() => {
|
||||
if (!data) {
|
||||
return { safe: [], risk: [], parlay: [], sgp: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
safe: data.safe_singles,
|
||||
risk: data.high_risk_singles,
|
||||
parlay: data.safe_parlays,
|
||||
sgp: data.sgp_lotteries,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const briefing = useMemo(() => {
|
||||
if (!data) {
|
||||
return '系統載入中,將於短時間內給出當日全場賽前簡報。';
|
||||
}
|
||||
|
||||
return `AI 總結:今日比賽共 ${data.matched_matches} 場,策略核心為 ${
|
||||
data.total_daily_unit_recommendation
|
||||
} Units。${
|
||||
data.safe_singles.length > 0
|
||||
? `檢測到 ${data.safe_singles.length} 組高穩定單關機會,重點偏向亞盤與低風險連續進位。`
|
||||
: '低風險盤口偏弱,建議保守減碼。'
|
||||
} ${
|
||||
data.high_risk_singles.length > 0
|
||||
? `另有 ${data.high_risk_singles.length} 組高賠搏冷訊號可做小額槓桿。`
|
||||
: ''
|
||||
}`;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await getDailyCard(targetDate);
|
||||
setData(response);
|
||||
} catch (payloadError) {
|
||||
setError(payloadError instanceof Error ? payloadError.message : '每日作戰卡暫時無法抓取');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load().catch(() => undefined);
|
||||
}, [targetDate]);
|
||||
|
||||
function handleAddToSlip(item: DailyCardItem) {
|
||||
setSelectedCount((count) => count + 1);
|
||||
// 未建立後續注單 API,先保留為前端選單暫存
|
||||
window.alert(`已加入注單追蹤:${item.match_label} | ${item.selection}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-2 text-sm ${
|
||||
activeTab === 'safe' ? 'bg-[#7d2a15] text-white' : 'bg-white/70 text-[#5f4330]'
|
||||
}`}
|
||||
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]'
|
||||
}`}
|
||||
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]'
|
||||
}`}
|
||||
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]'
|
||||
}`}
|
||||
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="grid gap-4 md:grid-cols-2">
|
||||
{tabCards[activeTab].map((item) => (
|
||||
<ActionableBetCard
|
||||
key={`${item.match_id}-${item.selection}-${item.market_type}`}
|
||||
item={item}
|
||||
onAddToSlip={handleAddToSlip}
|
||||
className="panel-glow"
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loading && tabCards[activeTab].length === 0 ? (
|
||||
<p className="panel-glow rounded-2xl p-4 text-sm text-[#7a5b46]">目前此策略區塊沒有符合條件的建議。</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
platform/web/app/deep-bet/page.tsx
Normal file
27
platform/web/app/deep-bet/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { QuickBetButton } from '@/components/QuickBetButton';
|
||||
|
||||
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)與
|
||||
即時金額提示。可直接跳轉至賠率已預選頁,減少下注落地時間。
|
||||
</p>
|
||||
</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>
|
||||
<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} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
platform/web/app/globals.css
Normal file
91
platform/web/app/globals.css
Normal file
@@ -0,0 +1,91 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;700;900&family=VT323&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-body: 'Noto Sans TC';
|
||||
--font-matrix: 'VT323';
|
||||
--warm-bg: #f6f0e1;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.55), transparent 35%),
|
||||
radial-gradient(circle at 80% 15%, rgba(209, 67, 45, 0.12), transparent 35%),
|
||||
#f6f0e1;
|
||||
color: #2a2018;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body), Arial, sans-serif;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(140deg, rgba(255, 255, 255, 0.65), rgba(246, 240, 225, 0.85));
|
||||
}
|
||||
|
||||
.panel-glow {
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
background: linear-gradient(170deg, rgba(255, 248, 230, 0.86), rgba(246, 240, 225, 0.88));
|
||||
box-shadow: 0 20px 45px rgba(95, 53, 44, 0.1);
|
||||
}
|
||||
|
||||
.dot-matrix {
|
||||
font-family: var(--font-matrix), monospace;
|
||||
letter-spacing: 0.09em;
|
||||
}
|
||||
|
||||
.status-led {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-led-ok { background: #1a9a57; }
|
||||
.status-led-warn { background: #dcb53b; }
|
||||
.status-led-danger { background: #d1432d; }
|
||||
|
||||
@keyframes top-edge-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 rgba(235, 90, 40, 0.0), 0 0 0 rgba(235, 90, 40, 0.0);
|
||||
border-color: rgba(188, 68, 43, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 18px rgba(235, 90, 40, 0.35), 0 0 36px rgba(235, 90, 40, 0.2);
|
||||
border-color: rgba(235, 90, 40, 1);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(235, 90, 40, 0), 0 0 0 rgba(235, 90, 40, 0);
|
||||
border-color: rgba(188, 68, 43, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.prop-top-edge {
|
||||
animation: top-edge-glow 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-matrix-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.dot-matrix-loading {
|
||||
animation: dot-matrix-pulse 1s steps(1) infinite;
|
||||
}
|
||||
16
platform/web/app/kelly/page.tsx
Normal file
16
platform/web/app/kelly/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
<section className="panel-glow rounded-2xl p-5">
|
||||
<p className="text-sm text-[#7a5b46]">
|
||||
以「凱利準則」為核心,以市場期望值為主動態資金控管;系統同時支援分數凱利(0.25x、0.5x、0.75x)
|
||||
與風險容忍度調節,讓你不只知道該下、還知道「下多少」。
|
||||
</p>
|
||||
</section>
|
||||
<BetSizingSlider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
platform/web/app/layout.tsx
Normal file
67
platform/web/app/layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Link from 'next/link';
|
||||
import './globals.css';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
import { PwaBootstrap } from '@/components/PwaBootstrap';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: '主控總覽' },
|
||||
{ href: '/matches', label: '賽事中心' },
|
||||
{ href: '/odds', label: '跨平台賠率比較' },
|
||||
{ href: '/sharp-money', label: '聰明錢追蹤' },
|
||||
{ href: '/ml-edge', label: 'ML Ensemble' },
|
||||
{ href: '/models', label: '量化模型' },
|
||||
{ href: '/match-conditions', label: '裁判/天候模型' },
|
||||
{ href: '/rlm', label: 'RLM 反向盤口' },
|
||||
{ href: '/proof-of-yield', label: '公開收益帳本' },
|
||||
{ href: '/props', label: '球員道具盤' },
|
||||
{ href: '/kelly', label: '凱利下注' },
|
||||
{ href: '/backtesting', label: '策略回測' },
|
||||
{ href: '/deep-bet', label: '一鍵投注' },
|
||||
{ href: '/daily-card', label: '每日作戰室' },
|
||||
{ href: '/portfolio', label: '個人組合' },
|
||||
];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '2026 FIFA 專業投注研究中心',
|
||||
description: '以台北時區驅動的全站即時投注資料與量化分析平台',
|
||||
manifest: '/manifest.json',
|
||||
themeColor: '#f6f0e1',
|
||||
other: {
|
||||
'application-name': '2026 World Cup Quantum Ops',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-Hant">
|
||||
<body>
|
||||
<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>
|
||||
</header>
|
||||
<main className="mx-auto mt-6 mb-24 max-w-7xl">{children}</main>
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
159
platform/web/app/match-conditions/page.tsx
Normal file
159
platform/web/app/match-conditions/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
analyzeMatchConditions,
|
||||
type MatchConditionRequestPayload,
|
||||
type MatchConditionResponse,
|
||||
} from '@/lib/analytics-api';
|
||||
import { MatchConditionsCard } from '@/components/MatchConditionsCard';
|
||||
|
||||
export default function MatchConditionsPage() {
|
||||
const [matchId, setMatchId] = useState('MEX-2026-GRP-D01');
|
||||
const [avgYellow, setAvgYellow] = useState(5.2);
|
||||
const [penaltiesPerGame, setPenaltiesPerGame] = useState(0.35);
|
||||
const [cardsLine, setCardsLine] = useState(4.5);
|
||||
const [temperature, setTemperature] = useState(34);
|
||||
const [humidity, setHumidity] = useState(71);
|
||||
const [altitude, setAltitude] = useState(2240);
|
||||
const [homeAttack, setHomeAttack] = useState(1.85);
|
||||
const [awayAttack] = useState(1.72);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [result, setResult] = useState<MatchConditionResponse | null>(null);
|
||||
|
||||
async function runConditionAnalysis() {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const payload: MatchConditionRequestPayload = {
|
||||
match_id: matchId,
|
||||
avg_yellow_cards: avgYellow,
|
||||
penalties_per_game: penaltiesPerGame,
|
||||
cards_ou_line: cardsLine,
|
||||
temp_c: temperature,
|
||||
humidity_pct: humidity,
|
||||
venue_altitude_meters: altitude,
|
||||
home_second_half_attack: homeAttack,
|
||||
away_second_half_attack: awayAttack,
|
||||
};
|
||||
const data = await analyzeMatchConditions(payload);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '比賽條件分析暫時中斷');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">裁判與天候條件量化</h2>
|
||||
<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">
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
場次 ID
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
value={matchId}
|
||||
onChange={(event) => setMatchId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
每場平均黃牌
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={avgYellow}
|
||||
onChange={(event) => setAvgYellow(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
每場平均點球
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.05}
|
||||
value={penaltiesPerGame}
|
||||
onChange={(event) => setPenaltiesPerGame(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
Cards O/U 盤口
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={cardsLine}
|
||||
onChange={(event) => setCardsLine(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
溫度(攝氏)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
濕度(%)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={humidity}
|
||||
onChange={(event) => setHumidity(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
海拔(m)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={altitude}
|
||||
onChange={(event) => setAltitude(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
主隊下半場攻擊
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={homeAttack}
|
||||
onChange={(event) => setHomeAttack(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
className="rounded-lg bg-[#7d2a15] px-4 py-2 text-white"
|
||||
type="button"
|
||||
onClick={runConditionAnalysis}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '分析中…' : '執行環境條件分析'}
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||||
</section>
|
||||
|
||||
{result ? (
|
||||
<MatchConditionsCard
|
||||
matchId={result.match_id}
|
||||
strictnessIndex={result.strictness_index}
|
||||
heatIndex={result.heat_index}
|
||||
cardsPressureAlert={result.cards_pressure_alert}
|
||||
secondHalfHomeAttack={result.second_half_home_attack}
|
||||
secondHalfAwayAttack={result.second_half_away_attack}
|
||||
secondHalfUnderRecommendation={result.second_half_under_recommendation}
|
||||
attackerDirection={result.attacker_direction}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
375
platform/web/app/matches/[matchId]/page.tsx
Normal file
375
platform/web/app/matches/[matchId]/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { OddsLineMovementChart } from '@/components/OddsLineMovementChart';
|
||||
import { MatchConditionsCard } from '@/components/MatchConditionsCard';
|
||||
import type {
|
||||
MatchConditionsReadout,
|
||||
MatchDetail,
|
||||
MatchListItem,
|
||||
MatchOddsPoint,
|
||||
MatchPoisson,
|
||||
} from '@/lib/analytics-api';
|
||||
import { formatToTaipeiTime } from '@/lib/timezone';
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
|
||||
async function fetchMatchList(): Promise<MatchListItem[]> {
|
||||
try {
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'force-cache',
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await response.json()) as MatchListItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMatchDetail(matchId: string): Promise<MatchDetail | null> {
|
||||
try {
|
||||
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'force-cache',
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as MatchDetail;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackSummary(detail: MatchDetail): string {
|
||||
const { home_team: home, away_team: away, poisson, conditions } = detail;
|
||||
const homeWin = (poisson.one_x_two.home_win * 100).toFixed(1);
|
||||
const draw = (poisson.one_x_two.draw * 100).toFixed(1);
|
||||
const awayWin = (poisson.one_x_two.away_win * 100).toFixed(1);
|
||||
const overProb = (poisson.over_under_2_5.over * 100).toFixed(1);
|
||||
const underProb = (poisson.over_under_2_5.under * 100).toFixed(1);
|
||||
|
||||
return `【量化總結】
|
||||
${home} vs ${away} 的 1x2 機率分佈顯示主勝 ${homeWin}%,平局 ${draw}%,客勝 ${awayWin}%。
|
||||
Poisson 預測給出主隊 ${poisson.expected_home_goals.toFixed(2)} / 客隊 ${poisson.expected_away_goals.toFixed(2)} 的進球走勢。
|
||||
賽事在 2.5 球上下行為上,Over 機率 ${overProb}%,Under 機率 ${underProb}%。
|
||||
條件面顯示裁判嚴厲度 ${conditions.strictness_index.toFixed(1)},熱指數 ${conditions.heat_index.toFixed(1)},
|
||||
若出現高張力紅黃牌壓力,建議觀察 1x2 讓盤與 Cards/Under 結構,避免逆風追買。`;
|
||||
}
|
||||
|
||||
async function buildQuantSummaryWithLLM(detail: MatchDetail): Promise<string> {
|
||||
const fallback = fallbackSummary(detail);
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = `請以中文繁體為台灣使用者生成 300 字的世界盃賽前量化總結,嚴格使用專業投注語氣,不超過 420 字。
|
||||
請使用以下數據:
|
||||
- 主場 ${detail.home_team}:xG ${detail.home_xg.toFixed(2)}
|
||||
- 客場 ${detail.away_team}:xG ${detail.away_xg.toFixed(2)}
|
||||
- 1x2 機率: 主勝 ${detail.poisson.one_x_two.home_win.toFixed(4)}、平 ${detail.poisson.one_x_two.draw.toFixed(4)}、客勝 ${detail.poisson.one_x_two.away_win.toFixed(4)}
|
||||
- Over/Under(2.5): ${(detail.poisson.over_under_2_5.over * 100).toFixed(1)} / ${(detail.poisson.over_under_2_5.under * 100).toFixed(1)}
|
||||
- 裁判嚴厲度 ${detail.conditions.strictness_index.toFixed(1)},Heat Index ${detail.conditions.heat_index.toFixed(1)},下半場主/客攻擊 ${detail.conditions.second_half_home_attack.toFixed(2)} / ${detail.conditions.second_half_away_attack.toFixed(2)}。`;
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是精通運彩與量化投注的專業分析師。請只輸出純文字,不要 markdown。',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const text = payload?.choices?.[0]?.message?.content;
|
||||
return typeof text === 'string' && text.trim().length > 0 ? text.trim() : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
type Params = { matchId: string };
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<Params>> {
|
||||
const matches = await fetchMatchList();
|
||||
return matches
|
||||
.filter((item) => Boolean(item.match_id))
|
||||
.map((item) => ({ matchId: item.match_id }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { matchId } = await params;
|
||||
const match = await fetchMatchDetail(matchId);
|
||||
|
||||
const title = match
|
||||
? `[資料驅動] 2026世界盃:${match.home_team} vs ${match.away_team} 預測、傷停與聰明錢流向分析`
|
||||
: `2026 世界盃賽事預測`;
|
||||
|
||||
const description = match
|
||||
? `量化模型基於 xG、裁判與天候模型產出「${match.home_team} vs ${match.away_team}」完整賠率走勢與下注偏差訊號,含 2.5 球與波膽機率。`
|
||||
: '2026 世界盃賽事量化預測、聰明錢流向與賠率走勢頁。';
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title,
|
||||
description,
|
||||
locale: 'zh_TW',
|
||||
siteName: '2026 FIFA Quantum Ops',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildScoreMatrixRows(scoreMatrix: MatchPoisson['score_matrix']) {
|
||||
return scoreMatrix.slice(0, 6).map((row, homeGoals) => ({
|
||||
homeGoals,
|
||||
values: row.slice(0, 6),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildOddsChartRows(points: MatchOddsPoint[]) {
|
||||
return points
|
||||
.filter((row) => row.market_type === '1x2')
|
||||
.map((row) => ({
|
||||
time: format(new Date(row.recorded_at), 'HH:mm:ss'),
|
||||
bookmaker: row.bookmaker,
|
||||
odds: Number(row.decimal_odds),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSportsEventJsonLd(detail: MatchDetail) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SportsEvent',
|
||||
name: `${detail.home_team} vs ${detail.away_team} 預測頁`,
|
||||
startDate: detail.match_time_utc,
|
||||
homeTeam: {
|
||||
'@type': 'SportsTeam',
|
||||
name: detail.home_team,
|
||||
},
|
||||
awayTeam: {
|
||||
'@type': 'SportsTeam',
|
||||
name: detail.away_team,
|
||||
},
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: detail.venue_name,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: detail.venue_city,
|
||||
addressCountry: detail.venue_country,
|
||||
},
|
||||
},
|
||||
description: detail.quant_summary,
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: (detail.poisson.one_x_two.home_win * 100).toFixed(1),
|
||||
bestRating: '100',
|
||||
worstRating: '0',
|
||||
ratingCount: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildDatasetJsonLd(detail: MatchDetail) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Dataset',
|
||||
name: `${detail.home_team} vs ${detail.away_team} 量化推論資料集`,
|
||||
description: `2026世界盃賽事模型資料。包含主客 xG、賠率時序、裁判與熱指數、波膽機率。`,
|
||||
creator: {
|
||||
'@type': 'Organization',
|
||||
name: '2026 FIFA Quantum Ops',
|
||||
},
|
||||
license: 'Proprietary',
|
||||
temporalCoverage: detail.match_time_utc,
|
||||
variableMeasured: [
|
||||
{
|
||||
'@type': 'PropertyValue',
|
||||
name: 'xG',
|
||||
value: `${detail.home_xg.toFixed(2)} / ${detail.away_xg.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
'@type': 'PropertyValue',
|
||||
name: 'Poisson 1X2',
|
||||
value: JSON.stringify(detail.poisson.one_x_two),
|
||||
},
|
||||
{
|
||||
'@type': 'PropertyValue',
|
||||
name: 'Heat Index',
|
||||
value: detail.conditions.heat_index,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MatchDetailPage({ params }: { params: Promise<Params> }) {
|
||||
const { matchId } = await params;
|
||||
const detail = await fetchMatchDetail(matchId);
|
||||
|
||||
if (!detail) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const quantSummary = await buildQuantSummaryWithLLM(detail);
|
||||
const oddsRows = buildOddsChartRows(detail.odds_series);
|
||||
const scoreRows = buildScoreMatrixRows(detail.poisson.score_matrix);
|
||||
const kickoffLocal = formatToTaipeiTime(detail.match_time_utc, 'yyyy-MM-dd HH:mm:ss');
|
||||
const isFinished = detail.status === 'finished';
|
||||
|
||||
const conditions: MatchConditionsReadout = detail.conditions;
|
||||
|
||||
const sportsEventJsonLd = JSON.stringify(buildSportsEventJsonLd(detail));
|
||||
const datasetJsonLd = JSON.stringify(buildDatasetJsonLd(detail));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: sportsEventJsonLd }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: datasetJsonLd }}
|
||||
/>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<p className="dot-matrix text-xs text-[#b83822]">/matches/{matchId}</p>
|
||||
<h1 className="mt-2 text-2xl text-[#7d2a15]">{detail.home_team} vs {detail.away_team}</h1>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">
|
||||
開賽時間:{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>
|
||||
|
||||
<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="dot-matrix text-2xl text-[#7d2a15]">
|
||||
{detail.home_xg.toFixed(2)} : {detail.away_xg.toFixed(2)}
|
||||
</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-[#e8cead] bg-white/70 p-3">
|
||||
<p className="text-xs text-[#7d4d39]">1X2 機率</p>
|
||||
<p className="dot-matrix text-sm text-[#7d2a15]">
|
||||
主 { (detail.poisson.one_x_two.home_win * 100).toFixed(1)}% · 平 { (detail.poisson.one_x_two.draw * 100).toFixed(1)}% · 客 { (detail.poisson.one_x_two.away_win * 100).toFixed(1)}%
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm leading-7 text-[#6f4f3c]">{quantSummary}</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<OddsLineMovementChart data={oddsRows} />
|
||||
</div>
|
||||
<MatchConditionsCard
|
||||
matchId={detail.match_id}
|
||||
strictnessIndex={conditions.strictness_index}
|
||||
heatIndex={conditions.heat_index}
|
||||
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}
|
||||
attackerDirection={conditions.attacker_direction}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h2 className="dot-matrix text-xl text-[#7d2a15]">泊松波膽矩陣(0–5 球)</h2>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="min-w-[560px] text-xs text-[#5f4330] md:text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 border border-[#e8cead] bg-[#fff4df] p-2">主\客</th>
|
||||
{[0, 1, 2, 3, 4, 5].map((awayGoals) => (
|
||||
<th key={`h${awayGoals}`} className="border border-[#e8cead] bg-[#fff4df] p-2">
|
||||
客 {awayGoals}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreRows.map((row) => (
|
||||
<tr key={`row-${row.homeGoals}`}>
|
||||
<td className="border border-[#e8cead] bg-[#fff9eb] p-2">主 {row.homeGoals}</td>
|
||||
{row.values.map((value, idx) => {
|
||||
const probability = Math.max(0, Math.min(1, value));
|
||||
return (
|
||||
<td
|
||||
key={`cell-${row.homeGoals}-${idx}`}
|
||||
className="border border-[#eed4b6] p-2"
|
||||
style={{ backgroundColor: `rgba(184, 56, 34, ${probability * 0.8})`, color: probability > 0.15 ? '#fff' : '#5b3d2d' }}
|
||||
>
|
||||
{(probability * 100).toFixed(2)}%
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#7a5b46]">
|
||||
Under/Over 2.5:{(detail.poisson.over_under_2_5.under * 100).toFixed(1)}% / { (detail.poisson.over_under_2_5.over * 100).toFixed(1)}%
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className={`rounded-2xl p-4 ${isFinished ? 'border border-[#dcb53b] bg-[#fff2da]' : 'border border-[#e3c08f] bg-white/80'}`}>
|
||||
<p className="dot-matrix text-lg text-[#7d2a15]">賽事摘要與策略結論</p>
|
||||
<p className="mt-2 text-sm text-[#684834]">
|
||||
本場比賽目前建議集中關注 {isFinished ? '盤口是否已結算' : '即場賠率變動與 Sharp Money 流向'}。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/proof-of-yield"
|
||||
className="dot-matrix rounded-full bg-[#7d2a15] px-4 py-2 text-white transition hover:bg-[#5f250f]"
|
||||
>
|
||||
查看公開績效帳本
|
||||
</Link>
|
||||
<Link
|
||||
href="/daily-card"
|
||||
className="dot-matrix rounded-full bg-white px-4 py-2 text-[#7d2a15] ring-1 ring-[#c58b63] transition hover:bg-[#fff2d9]"
|
||||
>
|
||||
查看今日策略戰情室
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
platform/web/app/matches/page.tsx
Normal file
61
platform/web/app/matches/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { formatToTaipeiTime } from '@/lib/timezone';
|
||||
import { LiveMatchCenter } from '@/components/LiveMatchCenter';
|
||||
|
||||
const matches = [
|
||||
{
|
||||
id: 'm1',
|
||||
teams: '德國 vs 西班牙',
|
||||
kickoff: formatToTaipeiTime(new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString()),
|
||||
status: '即將開賽',
|
||||
score: '0 - 0',
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
teams: '巴西 vs 法國',
|
||||
kickoff: formatToTaipeiTime(new Date(Date.now() + 11 * 60 * 60 * 1000).toISOString()),
|
||||
status: '臨場 64’',
|
||||
score: '1 - 1',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MatchesPage() {
|
||||
const liveTimeline = [
|
||||
{ minute: 18, label: '美國獲得角球攻擊機會,進入危險區' },
|
||||
{ minute: 31, label: '荷蘭主隊黃牌增加,犯規次數上升' },
|
||||
{ minute: 64, label: '德國先發邊鋒進球' },
|
||||
];
|
||||
|
||||
const xgData = [
|
||||
{ minute: 15, xgHome: 0.12, xgAway: 0.08 },
|
||||
{ minute: 30, xgHome: 0.25, xgAway: 0.12 },
|
||||
{ minute: 45, xgHome: 0.40, xgAway: 0.22 },
|
||||
{ minute: 70, xgHome: 0.65, xgAway: 0.30 },
|
||||
{ minute: 90, xgHome: 0.90, xgAway: 0.40 },
|
||||
];
|
||||
|
||||
const zones = [
|
||||
{ zone: '後場三分之一', pct: 24 },
|
||||
{ zone: '中場控制區', pct: 44 },
|
||||
{ zone: '禁區附近', pct: 32 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">賽事中心</h2>
|
||||
<section className="grid gap-4">
|
||||
{matches.map((match) => (
|
||||
<article key={match.id} className="panel-glow rounded-2xl p-5">
|
||||
<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.teams}</p>
|
||||
<p className="text-sm text-[#8a6b58]">開賽:{match.kickoff}</p>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[#8a6b58]">即時狀態:{match.status}</p>
|
||||
<p className="text-sm text-[#7c5340]">臨場比分:{match.score}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<LiveMatchCenter timeline={liveTimeline} xgSeries={xgData} heatZones={zones} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
platform/web/app/ml-edge/page.tsx
Normal file
262
platform/web/app/ml-edge/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
analyzeMlEdge,
|
||||
type MlEdgeRequestPayload,
|
||||
type MlEdgeResponse,
|
||||
type MlTrainRequestPayload,
|
||||
type MlTrainResponse,
|
||||
type MlTrainingRow,
|
||||
trainMlModel,
|
||||
} from '@/lib/analytics-api';
|
||||
|
||||
const defaultTrainingRows: MlTrainingRow[] = [
|
||||
{ home_rest_days: 4, away_rest_days: 3, home_travel_distance_km: 520, away_travel_distance_km: 1100, recent_5_xg_home: 1.8, recent_5_xg_away: 1.0, match_result: 'home' },
|
||||
{ home_rest_days: 2, away_rest_days: 5, home_travel_distance_km: 220, away_travel_distance_km: 780, recent_5_xg_home: 1.1, recent_5_xg_away: 1.7, match_result: 'away' },
|
||||
{ home_rest_days: 6, away_rest_days: 4, home_travel_distance_km: 120, away_travel_distance_km: 960, recent_5_xg_home: 2.3, recent_5_xg_away: 1.8, match_result: 'home' },
|
||||
{ home_rest_days: 3, away_rest_days: 3, home_travel_distance_km: 900, away_travel_distance_km: 900, recent_5_xg_home: 1.2, recent_5_xg_away: 1.3, match_result: 'draw' },
|
||||
];
|
||||
|
||||
export default function MlEdgePage() {
|
||||
const [modelId, setModelId] = useState('default');
|
||||
const [matchId, setMatchId] = useState('MATCH-2026-FINALS-07');
|
||||
const [homeRest, setHomeRest] = useState(6);
|
||||
const [awayRest, setAwayRest] = useState(4);
|
||||
const [homeTravel, setHomeTravel] = useState(420);
|
||||
const [awayTravel, setAwayTravel] = useState(970);
|
||||
const [homeXg, setHomeXg] = useState(2.0);
|
||||
const [awayXg, setAwayXg] = useState(1.1);
|
||||
const [homeOdds, setHomeOdds] = useState(2.05);
|
||||
const [drawOdds, setDrawOdds] = useState(3.35);
|
||||
const [awayOdds, setAwayOdds] = useState(3.75);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [trainLoading, setTrainLoading] = useState(false);
|
||||
const [edgeResult, setEdgeResult] = useState<MlEdgeResponse | null>(null);
|
||||
const [trainResult, setTrainResult] = useState<MlTrainResponse | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [trainMessage, setTrainMessage] = useState('');
|
||||
|
||||
async function runEdge() {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const payload: MlEdgeRequestPayload = {
|
||||
model_id: modelId,
|
||||
match_id: matchId,
|
||||
home_rest_days: homeRest,
|
||||
away_rest_days: awayRest,
|
||||
home_travel_distance_km: homeTravel,
|
||||
away_travel_distance_km: awayTravel,
|
||||
recent_5_xg_home: homeXg,
|
||||
recent_5_xg_away: awayXg,
|
||||
home_implied_odds: homeOdds,
|
||||
draw_implied_odds: drawOdds,
|
||||
away_implied_odds: awayOdds,
|
||||
};
|
||||
const data = await analyzeMlEdge(payload);
|
||||
setEdgeResult(data);
|
||||
setModelId(data.model_id);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'ML 邊界分析暫時失敗');
|
||||
setEdgeResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function trainModel() {
|
||||
setTrainLoading(true);
|
||||
setTrainMessage('');
|
||||
try {
|
||||
const payload: MlTrainRequestPayload = {
|
||||
model_id: `model-${Date.now()}`,
|
||||
rows: defaultTrainingRows,
|
||||
};
|
||||
const result = await trainMlModel(payload);
|
||||
setTrainResult(result);
|
||||
setModelId(result.model_id);
|
||||
setTrainMessage(`訓練完成(${result.training_size}筆)`);
|
||||
} catch (error) {
|
||||
setTrainMessage(error instanceof Error ? error.message : '模型訓練失敗');
|
||||
} finally {
|
||||
setTrainLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">ML Ensemble 量化預測(第 15 階段)</h2>
|
||||
<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">
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
使用模型 ID
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
比賽編號
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
value={matchId}
|
||||
onChange={(event) => setMatchId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
主隊休息日(天)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={homeRest}
|
||||
onChange={(event) => setHomeRest(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
客隊休息日(天)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={awayRest}
|
||||
onChange={(event) => setAwayRest(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
主隊航段距離(km)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={homeTravel}
|
||||
onChange={(event) => setHomeTravel(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
客隊航段距離(km)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
value={awayTravel}
|
||||
onChange={(event) => setAwayTravel(Number(event.target.value))}
|
||||
/>
|
||||
</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"
|
||||
step={0.1}
|
||||
value={homeXg}
|
||||
onChange={(event) => setHomeXg(Number(event.target.value))}
|
||||
/>
|
||||
</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"
|
||||
step={0.1}
|
||||
value={awayXg}
|
||||
onChange={(event) => setAwayXg(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
主勝賠率(賠率)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={homeOdds}
|
||||
onChange={(event) => setHomeOdds(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
和局賠率(賠率)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={drawOdds}
|
||||
onChange={(event) => setDrawOdds(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
客勝賠率(賠率)
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={awayOdds}
|
||||
onChange={(event) => setAwayOdds(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded-lg bg-[#7d2a15] px-4 py-2 text-white"
|
||||
type="button"
|
||||
onClick={runEdge}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '分析中…' : '執行 ML Edge 推論'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-[#d1432d] px-4 py-2 text-white"
|
||||
type="button"
|
||||
onClick={trainModel}
|
||||
disabled={trainLoading}
|
||||
>
|
||||
{trainLoading ? '訓練中…' : '快速訓練演示模型'}
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||||
{trainMessage ? <p className="mt-2 text-xs text-[#6f4f3c]">{trainMessage}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">推論結果</h3>
|
||||
{edgeResult ? (
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[#7a5b46]">尚未提交分析,請先點擊「執行 ML Edge 推論」。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">模型訓練摘要</h3>
|
||||
{trainResult ? (
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">
|
||||
目前模型:{trainResult.model_id} | 樣本數:{trainResult.training_size} | 準確率:
|
||||
{trainResult.accuracy === null ? 'N/A' : trainResult.accuracy.toFixed(4)} | 回退:{trainResult.is_fallback ? '是' : '否'}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">尚未執行訓練。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
platform/web/app/models/page.tsx
Normal file
46
platform/web/app/models/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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 進階下注導流' },
|
||||
];
|
||||
|
||||
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]">整合階段 1~14 的核心模型模組,涵蓋「賽前機率推估」「投注價值偵測」「下注控管」與「執行效率」。</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>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
platform/web/app/odds/page.tsx
Normal file
48
platform/web/app/odds/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { OddsLineMovementChart } from '@/components/OddsLineMovementChart';
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
export default function OddsPage() {
|
||||
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>
|
||||
</section>
|
||||
|
||||
<OddsLineMovementChart data={samples} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
platform/web/app/offline/page.tsx
Normal file
94
platform/web/app/offline/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type CachedOddsEntry = {
|
||||
match_id: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
odds: number;
|
||||
market_type?: string;
|
||||
selection?: string;
|
||||
captured_at: string;
|
||||
};
|
||||
|
||||
const OFFLINE_STORAGE_KEY = 'wc2026:lastOddsSnapshot';
|
||||
|
||||
function formatTs(ts: string) {
|
||||
try {
|
||||
return new Date(ts).toLocaleTimeString('zh-TW', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
export default function OfflinePage() {
|
||||
const [snapshot, setSnapshot] = useState<CachedOddsEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(OFFLINE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(raw);
|
||||
if (Array.isArray(data)) {
|
||||
setSnapshot(data as CachedOddsEntry[]);
|
||||
}
|
||||
} catch {
|
||||
setSnapshot([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<h1 className="mt-2 text-3xl text-[#ffe2bd]">你已進入離線保護模式</h1>
|
||||
<p className="mt-3 text-sm text-[#f6c48f]">
|
||||
網路暫時中斷,已為你保留最後可用賠率快取,並暫時停用即時更新。
|
||||
</p>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-[#6d391f] bg-[#27160f] p-3">
|
||||
<p className="dot-matrix text-sm text-[#f8b66d]">快取賠率(最近)</p>
|
||||
{snapshot.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-[#e6ba95]">目前沒有可顯示的快取資料。</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{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.selection ? ` ${item.selection}` : ''}:
|
||||
<span className="font-semibold text-[#ff9f4c]"> {item.odds.toFixed(2)} </span>
|
||||
<span className="text-[#f4c89c]">({formatTs(item.captured_at)})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="dot-matrix rounded-full bg-[#ab3f23] px-4 py-2 text-white"
|
||||
>
|
||||
返回首頁
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/90 px-4 py-2 text-[#5f2c1a]"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新連線
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user