Initial commit with 2026 World Cup Quant Platform core modules and CI/CD

This commit is contained in:
QuantBot
2026-06-13 23:18:18 +08:00
commit 073abf98c1
155 changed files with 19539 additions and 0 deletions

32
.env.example Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
node_modules
package-lock.json
.env
/ops/shared
/ops/*.log

25
Makefile Normal file
View 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
View 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/18822/80 可到達
- 110 回應為 301188 為 200188 對直接上線友善
- 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
View File

97
docker-compose.prod.yml Normal file
View 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
View 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:

View File

@@ -0,0 +1,241 @@
# 專業外部資料參考台帳(完整版)
本頁定義 2026 世界盃投注研究站的「參考來源矩陣」,目標是從官方公告、賠率市場、新聞訊號、球隊統計、場地天氣五大軸建立可追溯的高勝率研究底盤。
所有時區均以 **Asia/TaipeiUTC+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`)到權重輸入,提升短期決策穩定性。

View 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 APIin-memory prototype
5. 量化儀表頁樣式與 API 串接骨架
後續可無縫接入 PostgreSQL/Redis將 in-memory 資料改為 DB Repository 注入。

View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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

View 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"]

View 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())

View File

@@ -0,0 +1,2 @@
httpx==0.28.1
redis==5.2.1

View 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

View 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"]

View File

@@ -0,0 +1 @@

View 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',
]

View 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_percentROI
- 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',
]

View 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),
},
}

View 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.033%)回傳 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',
]

View 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

View 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,
}

View 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}

View 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),
}

View 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 - 1p 為勝率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']

View 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

View 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

View 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',
]

View 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']),
}

View 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

View 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],
}

View 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),
)

View 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'],
}

View 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,
)

View 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,
)

View 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
}

View 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 probabilityp_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),
}

View 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)

View 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}
]

View 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"}

View 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',
]

View 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()

View 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())

View File

@@ -0,0 +1 @@

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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);

View 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

View 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)

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
apiVersion: v1
kind: Namespace
metadata:
name: fifa2026

View 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

View 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

View 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

View 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

View 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
View 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"]

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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 });
}
}

View 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 });
}

View 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/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 });
}
}

View 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 });
}
}

View File

@@ -0,0 +1,4 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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.25x0.5x0.75x
調
</p>
</section>
<BetSizingSlider />
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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]">05 </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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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