Spaces:
Sleeping
Sleeping
saadrizvi09 commited on
Commit ·
b2806e8
0
Parent(s):
init
Browse files- .gitignore +63 -0
- DEPLOYMENT.md +222 -0
- Dockerfile +97 -0
- HUGGINGFACE_SETUP.md +173 -0
- README.md +263 -0
- backend/.env.example +34 -0
- backend/Dockerfile +22 -0
- backend/nest-cli.json +8 -0
- backend/package.json +39 -0
- backend/src/app.module.ts +21 -0
- backend/src/auth/auth.controller.ts +12 -0
- backend/src/auth/auth.module.ts +25 -0
- backend/src/auth/auth.service.ts +42 -0
- backend/src/auth/jwt-auth.guard.ts +5 -0
- backend/src/auth/jwt.strategy.ts +19 -0
- backend/src/main.ts +14 -0
- backend/src/markets/market-cache.service.ts +40 -0
- backend/src/markets/market.processor.ts +61 -0
- backend/src/markets/markets.controller.ts +131 -0
- backend/src/markets/markets.module.ts +43 -0
- backend/src/markets/markets.service.ts +396 -0
- backend/tsconfig.build.json +4 -0
- backend/tsconfig.json +21 -0
- dev.bat +20 -0
- docker-compose.yml +52 -0
- frontend/.env.example +6 -0
- frontend/Dockerfile +32 -0
- frontend/next-env.d.ts +5 -0
- frontend/next.config.js +18 -0
- frontend/package.json +28 -0
- frontend/postcss.config.js +6 -0
- frontend/src/app/dashboard/page.tsx +139 -0
- frontend/src/app/globals.css +98 -0
- frontend/src/app/layout.tsx +19 -0
- frontend/src/app/login/page.tsx +19 -0
- frontend/src/app/market/[slug]/page.tsx +576 -0
- frontend/src/app/page.tsx +18 -0
- frontend/src/components/Navbar.tsx +93 -0
- frontend/src/lib/api.ts +93 -0
- frontend/src/middleware.ts +11 -0
- frontend/tailwind.config.ts +28 -0
- frontend/tsconfig.json +21 -0
.gitignore
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
yarn.lock
|
| 4 |
+
package-lock.json
|
| 5 |
+
pnpm-lock.yaml
|
| 6 |
+
|
| 7 |
+
# Environment variables
|
| 8 |
+
.env
|
| 9 |
+
.env.local
|
| 10 |
+
.env.*.local
|
| 11 |
+
|
| 12 |
+
# Build artifacts
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
*.tsbuildinfo
|
| 16 |
+
.next/
|
| 17 |
+
out/
|
| 18 |
+
|
| 19 |
+
# IDE
|
| 20 |
+
.vscode/
|
| 21 |
+
.idea/
|
| 22 |
+
*.swp
|
| 23 |
+
*.swo
|
| 24 |
+
*~
|
| 25 |
+
.DS_Store
|
| 26 |
+
|
| 27 |
+
# Testing
|
| 28 |
+
coverage/
|
| 29 |
+
.nyc_output/
|
| 30 |
+
|
| 31 |
+
# Logs
|
| 32 |
+
logs/
|
| 33 |
+
*.log
|
| 34 |
+
npm-debug.log*
|
| 35 |
+
yarn-debug.log*
|
| 36 |
+
yarn-error.log*
|
| 37 |
+
lerna-debug.log*
|
| 38 |
+
|
| 39 |
+
# Docker
|
| 40 |
+
.dockerignore
|
| 41 |
+
docker-compose.override.yml
|
| 42 |
+
|
| 43 |
+
# Cache
|
| 44 |
+
.cache/
|
| 45 |
+
.eslintcache
|
| 46 |
+
|
| 47 |
+
# OS
|
| 48 |
+
Thumbs.db
|
| 49 |
+
.DS_Store
|
| 50 |
+
|
| 51 |
+
# Optional npm cache directory
|
| 52 |
+
.npm
|
| 53 |
+
|
| 54 |
+
# Optional eslint cache
|
| 55 |
+
.eslintcache
|
| 56 |
+
|
| 57 |
+
# Temporary files
|
| 58 |
+
tmp/
|
| 59 |
+
temp/
|
| 60 |
+
*.tmp
|
| 61 |
+
|
| 62 |
+
# Redis data (if running locally)
|
| 63 |
+
dump.rdb
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WagerKit - Hugging Face Spaces Deployment Guide
|
| 2 |
+
|
| 3 |
+
## Quick Start
|
| 4 |
+
|
| 5 |
+
### Option 1: Hugging Face Spaces (Recommended for Demo)
|
| 6 |
+
|
| 7 |
+
1. **Create a new Docker Space on Hugging Face**
|
| 8 |
+
- Go to https://huggingface.co/new-space
|
| 9 |
+
- Choose "Docker" as the SDK
|
| 10 |
+
- Name your space (e.g., `wagerkit-demo`)
|
| 11 |
+
|
| 12 |
+
2. **Clone this repository and push to your Space**
|
| 13 |
+
```bash
|
| 14 |
+
git clone <your-repo-url>
|
| 15 |
+
cd wagerkit
|
| 16 |
+
git remote add space https://huggingface.co/spaces/<your-username>/<space-name>
|
| 17 |
+
git push space main
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
3. **Configure Space Settings**
|
| 21 |
+
- In your Space settings, set:
|
| 22 |
+
- **Hardware**: CPU Basic (free tier)
|
| 23 |
+
- **Visibility**: Public
|
| 24 |
+
- Add secrets (optional):
|
| 25 |
+
- `DOME_API_KEY`: Your DOME API key
|
| 26 |
+
|
| 27 |
+
4. **The Space will automatically build and deploy**
|
| 28 |
+
- Build time: ~5-10 minutes
|
| 29 |
+
- Access URL: `https://<your-username>-<space-name>.hf.space`
|
| 30 |
+
|
| 31 |
+
### Option 2: Local Development with Docker Compose
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Start all services
|
| 35 |
+
docker-compose up -d
|
| 36 |
+
|
| 37 |
+
# View logs
|
| 38 |
+
docker-compose logs -f
|
| 39 |
+
|
| 40 |
+
# Stop services
|
| 41 |
+
docker-compose down
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
Access the app at:
|
| 45 |
+
- Frontend: http://localhost:3000
|
| 46 |
+
- Backend API: http://localhost:3001/api
|
| 47 |
+
|
| 48 |
+
### Option 3: Local Development (Without Docker)
|
| 49 |
+
|
| 50 |
+
**Terminal 1 - Backend:**
|
| 51 |
+
```bash
|
| 52 |
+
cd backend
|
| 53 |
+
npm install
|
| 54 |
+
npm run start:dev
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
**Terminal 2 - Frontend:**
|
| 58 |
+
```bash
|
| 59 |
+
cd frontend
|
| 60 |
+
npm install
|
| 61 |
+
npm run dev
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
**Terminal 3 - Redis:**
|
| 65 |
+
```bash
|
| 66 |
+
docker run -d --name wagerkit-redis -p 6379:6379 redis:7-alpine
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
## Architecture
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
┌─────────────────────────────────────────────┐
|
| 73 |
+
│ Hugging Face Spaces │
|
| 74 |
+
│ │
|
| 75 |
+
│ ┌──────────────────────────────────────┐ │
|
| 76 |
+
│ │ Docker Container │ │
|
| 77 |
+
│ │ │ │
|
| 78 |
+
│ │ ┌──────────┐ ┌─────────────┐ │ │
|
| 79 |
+
│ │ │ Redis │◄───┤ Backend │ │ │
|
| 80 |
+
│ │ │ :6379 │ │ NestJS API │ │ │
|
| 81 |
+
│ │ └──────────┘ │ :3001 │ │ │
|
| 82 |
+
│ │ └──────▲──────┘ │ │
|
| 83 |
+
│ │ │ │ │
|
| 84 |
+
│ │ ┌──────┴──────┐ │ │
|
| 85 |
+
│ │ │ Frontend │ │ │
|
| 86 |
+
│ │ │ Next.js │ │ │
|
| 87 |
+
│ │ │ :7860 │ │ │
|
| 88 |
+
│ │ └─────────────┘ │ │
|
| 89 |
+
│ │ │ │
|
| 90 |
+
│ └──────────────────────────────────────┘ │
|
| 91 |
+
│ │
|
| 92 |
+
└─────────────────────────────────────────────┘
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
## Project Structure
|
| 96 |
+
|
| 97 |
+
```
|
| 98 |
+
wagerkit/
|
| 99 |
+
├── backend/ # NestJS backend
|
| 100 |
+
│ ├── src/
|
| 101 |
+
│ │ ├── markets/ # Markets module (BullMQ workers)
|
| 102 |
+
│ │ ├── auth/ # Auth module (unused in demo)
|
| 103 |
+
│ │ └── main.ts
|
| 104 |
+
│ ├── Dockerfile # Backend Docker image
|
| 105 |
+
│ └── package.json
|
| 106 |
+
├── frontend/ # Next.js frontend
|
| 107 |
+
│ ├── src/
|
| 108 |
+
│ │ ├── app/ # App router pages
|
| 109 |
+
│ │ ├── components/ # Reusable components
|
| 110 |
+
│ │ └── lib/ # API client
|
| 111 |
+
│ ├── Dockerfile # Frontend Docker image
|
| 112 |
+
│ └── package.json
|
| 113 |
+
├── Dockerfile # Multi-stage build (HF Spaces)
|
| 114 |
+
├── docker-compose.yml # Local development
|
| 115 |
+
└── README.md
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
## Environment Variables
|
| 119 |
+
|
| 120 |
+
### Backend (.env)
|
| 121 |
+
```env
|
| 122 |
+
NODE_ENV=production
|
| 123 |
+
PORT=3001
|
| 124 |
+
REDIS_HOST=localhost
|
| 125 |
+
REDIS_PORT=6379
|
| 126 |
+
DOME_API_KEY=your_dome_api_key_here
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### Frontend (.env.local)
|
| 130 |
+
```env
|
| 131 |
+
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
## Features
|
| 135 |
+
|
| 136 |
+
✅ **Background Processing with BullMQ**
|
| 137 |
+
- Markets are pre-processed on startup
|
| 138 |
+
- Dashboard loads instantly with cached data
|
| 139 |
+
- Redis-backed job queue
|
| 140 |
+
|
| 141 |
+
✅ **Integrity Score Calculation**
|
| 142 |
+
- Market Clarity (40% weight)
|
| 143 |
+
- Liquidity Depth (30% weight)
|
| 144 |
+
- Cross-Source Agreement (20% weight)
|
| 145 |
+
- Volatility Sanity (10% weight)
|
| 146 |
+
|
| 147 |
+
✅ **Data Visualization**
|
| 148 |
+
- Real-time odds history charts (Chart.js)
|
| 149 |
+
- Multi-source comparison (Polymarket, Kalshi, PredictIt, WagerKit)
|
| 150 |
+
- Dark theme UI with Tailwind CSS
|
| 151 |
+
|
| 152 |
+
✅ **Export Functionality**
|
| 153 |
+
- PDF dossier generation (jsPDF)
|
| 154 |
+
- Odds history CSV export
|
| 155 |
+
- Integrity metrics JSON export
|
| 156 |
+
|
| 157 |
+
## Troubleshooting
|
| 158 |
+
|
| 159 |
+
### Hugging Face Spaces Build Failing
|
| 160 |
+
|
| 161 |
+
1. **Check build logs in Space settings**
|
| 162 |
+
2. **Verify Dockerfile syntax**:
|
| 163 |
+
```bash
|
| 164 |
+
docker build -t wagerkit-test -f Dockerfile .
|
| 165 |
+
```
|
| 166 |
+
3. **Ensure all dependencies are specified in package.json**
|
| 167 |
+
|
| 168 |
+
### Redis Connection Issues
|
| 169 |
+
|
| 170 |
+
If you see "ECONNREFUSED 127.0.0.1:6379":
|
| 171 |
+
- Ensure Redis is running in the container
|
| 172 |
+
- Check `REDIS_HOST` environment variable
|
| 173 |
+
- Verify Redis is starting before the backend
|
| 174 |
+
|
| 175 |
+
### Port Conflicts (Local Development)
|
| 176 |
+
|
| 177 |
+
```bash
|
| 178 |
+
# Kill processes on ports 3000, 3001, 6379
|
| 179 |
+
npx kill-port 3000 3001 6379
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
### Frontend API Connection
|
| 183 |
+
|
| 184 |
+
If frontend can't reach backend:
|
| 185 |
+
- Check `NEXT_PUBLIC_API_URL` in `.env.local`
|
| 186 |
+
- Verify backend is running on expected port
|
| 187 |
+
- Check browser console for CORS errors
|
| 188 |
+
|
| 189 |
+
## Production Deployment Best Practices
|
| 190 |
+
|
| 191 |
+
1. **Use environment-specific API URLs**
|
| 192 |
+
```javascript
|
| 193 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://your-backend-url.com/api';
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
2. **Enable Redis persistence**
|
| 197 |
+
```bash
|
| 198 |
+
redis-server --appendonly yes
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
3. **Set up health checks**
|
| 202 |
+
- Backend: `GET /health`
|
| 203 |
+
- Frontend: `GET /api/health`
|
| 204 |
+
|
| 205 |
+
4. **Configure resource limits** (docker-compose.yml)
|
| 206 |
+
```yaml
|
| 207 |
+
deploy:
|
| 208 |
+
resources:
|
| 209 |
+
limits:
|
| 210 |
+
cpus: '1.0'
|
| 211 |
+
memory: 1G
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
## Support
|
| 215 |
+
|
| 216 |
+
For issues or questions:
|
| 217 |
+
- GitHub Issues: [Your Repo URL]
|
| 218 |
+
- Documentation: [Your Docs URL]
|
| 219 |
+
|
| 220 |
+
## License
|
| 221 |
+
|
| 222 |
+
[Your License]
|
Dockerfile
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage Dockerfile for WagerKit (Hugging Face Spaces Compatible)
|
| 2 |
+
|
| 3 |
+
# Stage 1: Build Backend
|
| 4 |
+
FROM node:20-alpine AS backend-build
|
| 5 |
+
|
| 6 |
+
WORKDIR /app/backend
|
| 7 |
+
|
| 8 |
+
# Copy backend package files
|
| 9 |
+
COPY backend/package*.json ./
|
| 10 |
+
RUN npm ci --only=production
|
| 11 |
+
|
| 12 |
+
# Copy backend source and build
|
| 13 |
+
COPY backend ./
|
| 14 |
+
RUN npm run build
|
| 15 |
+
|
| 16 |
+
# Stage 2: Build Frontend
|
| 17 |
+
FROM node:20-alpine AS frontend-build
|
| 18 |
+
|
| 19 |
+
WORKDIR /app/frontend
|
| 20 |
+
|
| 21 |
+
# Copy frontend package files
|
| 22 |
+
COPY frontend/package*.json ./
|
| 23 |
+
RUN npm ci
|
| 24 |
+
|
| 25 |
+
# Copy frontend source
|
| 26 |
+
COPY frontend ./
|
| 27 |
+
|
| 28 |
+
# Build Next.js frontend (standalone mode)
|
| 29 |
+
ENV NEXT_PUBLIC_API_URL=/api
|
| 30 |
+
RUN npm run build
|
| 31 |
+
|
| 32 |
+
# Stage 3: Production Runtime
|
| 33 |
+
FROM node:20-alpine
|
| 34 |
+
|
| 35 |
+
WORKDIR /app
|
| 36 |
+
|
| 37 |
+
# Install dependencies for Redis and process management
|
| 38 |
+
RUN apk add --no-cache redis bash
|
| 39 |
+
|
| 40 |
+
# Copy backend production files
|
| 41 |
+
COPY --from=backend-build /app/backend/dist ./backend/dist
|
| 42 |
+
COPY --from=backend-build /app/backend/node_modules ./backend/node_modules
|
| 43 |
+
COPY --from=backend-build /app/backend/package.json ./backend/
|
| 44 |
+
|
| 45 |
+
# Copy backend .env (if exists)
|
| 46 |
+
COPY backend/.env ./backend/.env 2>/dev/null || true
|
| 47 |
+
|
| 48 |
+
# Copy frontend production files (standalone build)
|
| 49 |
+
COPY --from=frontend-build /app/frontend/.next/standalone ./frontend/
|
| 50 |
+
COPY --from=frontend-build /app/frontend/.next/static ./frontend/.next/static
|
| 51 |
+
COPY --from=frontend-build /app/frontend/public ./frontend/public
|
| 52 |
+
COPY --from=frontend-build /app/frontend/package.json ./frontend/
|
| 53 |
+
|
| 54 |
+
# Environment variables for Hugging Face Spaces
|
| 55 |
+
ENV NODE_ENV=production
|
| 56 |
+
ENV PORT=7860
|
| 57 |
+
ENV REDIS_HOST=127.0.0.1
|
| 58 |
+
ENV REDIS_PORT=6379
|
| 59 |
+
ENV NEXT_PUBLIC_API_URL=/api
|
| 60 |
+
|
| 61 |
+
# Expose Hugging Face Spaces default port
|
| 62 |
+
EXPOSE 7860
|
| 63 |
+
|
| 64 |
+
# Create comprehensive startup script
|
| 65 |
+
RUN cat > /app/start.sh << 'EOF'
|
| 66 |
+
#!/bin/bash
|
| 67 |
+
set -e
|
| 68 |
+
|
| 69 |
+
echo "🚀 Starting WagerKit on Hugging Face Spaces..."
|
| 70 |
+
|
| 71 |
+
# Start Redis in background
|
| 72 |
+
echo "📦 Starting Redis..."
|
| 73 |
+
redis-server --daemonize yes --bind 127.0.0.1 --port 6379 --loglevel warning
|
| 74 |
+
sleep 2
|
| 75 |
+
|
| 76 |
+
# Verify Redis is running
|
| 77 |
+
redis-cli ping > /dev/null
|
| 78 |
+
echo "✅ Redis is ready"
|
| 79 |
+
|
| 80 |
+
# Start Backend in background
|
| 81 |
+
echo "🔧 Starting NestJS backend..."
|
| 82 |
+
cd /app/backend
|
| 83 |
+
PORT=3001 node dist/main.js &
|
| 84 |
+
BACKEND_PID=$!
|
| 85 |
+
sleep 3
|
| 86 |
+
echo "✅ Backend started (PID: $BACKEND_PID)"
|
| 87 |
+
|
| 88 |
+
# Start Frontend (foreground - keeps container alive)
|
| 89 |
+
echo "🌐 Starting Next.js frontend on port 7860..."
|
| 90 |
+
cd /app/frontend
|
| 91 |
+
PORT=7860 node server.js
|
| 92 |
+
|
| 93 |
+
EOF
|
| 94 |
+
|
| 95 |
+
RUN chmod +x /app/start.sh
|
| 96 |
+
|
| 97 |
+
CMD ["/app/start.sh"]
|
HUGGINGFACE_SETUP.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 Hugging Face Spaces Deployment Guide
|
| 2 |
+
|
| 3 |
+
## Step-by-Step Instructions
|
| 4 |
+
|
| 5 |
+
### 1. Create a New Space
|
| 6 |
+
|
| 7 |
+
1. Go to https://huggingface.co/new-space
|
| 8 |
+
2. Fill in the details:
|
| 9 |
+
- **Name**: `wagerkit` (or your preferred name)
|
| 10 |
+
- **License**: MIT
|
| 11 |
+
- **SDK**: Select **Docker**
|
| 12 |
+
- **Hardware**: CPU Basic (free tier is sufficient)
|
| 13 |
+
- **Visibility**: Public (or Private if you prefer)
|
| 14 |
+
3. Click **Create Space**
|
| 15 |
+
|
| 16 |
+
### 2. Push Your Code to the Space
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
# Navigate to your project directory
|
| 20 |
+
cd c:\demo
|
| 21 |
+
|
| 22 |
+
# Initialize git (if not already)
|
| 23 |
+
git init
|
| 24 |
+
git add .
|
| 25 |
+
git commit -m "Initial commit: WagerKit prediction market analysis"
|
| 26 |
+
|
| 27 |
+
# Add your Hugging Face Space as a remote
|
| 28 |
+
git remote add space https://huggingface.co/spaces/<your-username>/<space-name>
|
| 29 |
+
|
| 30 |
+
# Push to the Space
|
| 31 |
+
git push space main
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### 3. Wait for Build
|
| 35 |
+
|
| 36 |
+
- The Space will automatically start building
|
| 37 |
+
- Build time: ~5-10 minutes
|
| 38 |
+
- You can watch the build logs in the Space's "Logs" tab
|
| 39 |
+
|
| 40 |
+
### 4. Access Your Deployed App
|
| 41 |
+
|
| 42 |
+
Once the build completes, your app will be available at:
|
| 43 |
+
```
|
| 44 |
+
https://<your-username>-<space-name>.hf.space
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
Example: `https://johndoe-wagerkit.hf.space`
|
| 48 |
+
|
| 49 |
+
## 🔧 Configuration
|
| 50 |
+
|
| 51 |
+
### Optional: Add Secrets
|
| 52 |
+
|
| 53 |
+
If you want to use a real DOME API key:
|
| 54 |
+
|
| 55 |
+
1. In your Space settings, go to **Variables and secrets**
|
| 56 |
+
2. Add a new secret:
|
| 57 |
+
- **Name**: `DOME_API_KEY`
|
| 58 |
+
- **Value**: Your actual DOME API key
|
| 59 |
+
|
| 60 |
+
### Port Configuration
|
| 61 |
+
|
| 62 |
+
The Dockerfile is pre-configured for Hugging Face Spaces:
|
| 63 |
+
- Uses port **7860** (HF Spaces default)
|
| 64 |
+
- Backend runs on internal port 3001
|
| 65 |
+
- Frontend (port 7860) proxies API requests to backend
|
| 66 |
+
|
| 67 |
+
## 🐛 Troubleshooting
|
| 68 |
+
|
| 69 |
+
### Build Fails
|
| 70 |
+
|
| 71 |
+
**Check the build logs:**
|
| 72 |
+
1. Go to your Space
|
| 73 |
+
2. Click the "Logs" tab
|
| 74 |
+
3. Look for error messages
|
| 75 |
+
|
| 76 |
+
**Common issues:**
|
| 77 |
+
- Missing dependencies: Ensure `package.json` is complete
|
| 78 |
+
- Build timeout: The free tier has build time limits
|
| 79 |
+
- Memory issues: Try optimizing your build process
|
| 80 |
+
|
| 81 |
+
### App Not Responding
|
| 82 |
+
|
| 83 |
+
**Check the runtime logs:**
|
| 84 |
+
1. Go to your Space
|
| 85 |
+
2. Click the "Logs" tab (after build)
|
| 86 |
+
3. Look for runtime errors
|
| 87 |
+
|
| 88 |
+
**Common issues:**
|
| 89 |
+
- Redis not starting: Check the startup script logs
|
| 90 |
+
- Backend crash: Check for missing environment variables
|
| 91 |
+
- Port conflicts: Ensure using port 7860
|
| 92 |
+
|
| 93 |
+
### Slow Performance
|
| 94 |
+
|
| 95 |
+
Free tier CPU Basic has limited resources. Consider:
|
| 96 |
+
- Upgrading to CPU Medium (paid)
|
| 97 |
+
- Optimizing your code
|
| 98 |
+
- Adding caching
|
| 99 |
+
|
| 100 |
+
## 📊 Monitoring
|
| 101 |
+
|
| 102 |
+
### Health Checks
|
| 103 |
+
|
| 104 |
+
Your app includes automatic health monitoring:
|
| 105 |
+
- Redis health: Checked on startup
|
| 106 |
+
- Backend health: Automatic restart if crashes
|
| 107 |
+
- Frontend health: Next.js handles gracefully
|
| 108 |
+
|
| 109 |
+
### Logs
|
| 110 |
+
|
| 111 |
+
View real-time logs in your Space:
|
| 112 |
+
```
|
| 113 |
+
Settings → Logs
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Look for:
|
| 117 |
+
- `✅ Redis is ready` - Confirms Redis started
|
| 118 |
+
- `✅ Backend started` - Confirms backend is running
|
| 119 |
+
- `🌐 Starting Next.js frontend` - Confirms frontend is starting
|
| 120 |
+
|
| 121 |
+
## 🔄 Updates
|
| 122 |
+
|
| 123 |
+
To update your deployed app:
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
# Make your changes
|
| 127 |
+
git add .
|
| 128 |
+
git commit -m "Description of changes"
|
| 129 |
+
|
| 130 |
+
# Push to Space
|
| 131 |
+
git push space main
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
The Space will automatically rebuild and redeploy.
|
| 135 |
+
|
| 136 |
+
## 💰 Cost
|
| 137 |
+
|
| 138 |
+
**Free Tier (CPU Basic):**
|
| 139 |
+
- ✅ Sufficient for demo/development
|
| 140 |
+
- ✅ Auto-sleeps after inactivity
|
| 141 |
+
- ✅ No credit card required
|
| 142 |
+
|
| 143 |
+
**Paid Tiers:**
|
| 144 |
+
- CPU Medium: Better performance
|
| 145 |
+
- GPU: Not needed for this app
|
| 146 |
+
|
| 147 |
+
## 🎓 Next Steps
|
| 148 |
+
|
| 149 |
+
1. **Test your deployment**: Visit your Space URL
|
| 150 |
+
2. **Share**: Your Space is now public (if you chose public visibility)
|
| 151 |
+
3. **Monitor**: Check logs for any issues
|
| 152 |
+
4. **Optimize**: Add caching, optimize queries
|
| 153 |
+
5. **Extend**: Add more markets, features
|
| 154 |
+
|
| 155 |
+
## 📚 Resources
|
| 156 |
+
|
| 157 |
+
- [Hugging Face Spaces Documentation](https://huggingface.co/docs/hub/spaces)
|
| 158 |
+
- [Docker Spaces Guide](https://huggingface.co/docs/hub/spaces-sdks-docker)
|
| 159 |
+
- [WagerKit Deployment Guide](./DEPLOYMENT.md)
|
| 160 |
+
|
| 161 |
+
## ✨ Success Criteria
|
| 162 |
+
|
| 163 |
+
Your deployment is successful when:
|
| 164 |
+
- ✅ Space shows "Running" status
|
| 165 |
+
- ✅ You can access the URL
|
| 166 |
+
- ✅ Dashboard shows 3 markets
|
| 167 |
+
- ✅ Clicking a card opens the detail page
|
| 168 |
+
- ✅ Charts render correctly
|
| 169 |
+
- ✅ Export buttons work
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
**Questions?** Check the [DEPLOYMENT.md](./DEPLOYMENT.md) for more details or open an issue on GitHub.
|
README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: WagerKit
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# WagerKit 🎯
|
| 12 |
+
|
| 13 |
+
**Real-time prediction market analysis with integrity scoring**
|
| 14 |
+
|
| 15 |
+
WagerKit aggregates data from multiple prediction market sources (Polymarket, Kalshi, PredictIt) and provides comprehensive integrity analysis for each market.
|
| 16 |
+
|
| 17 |
+
## ✨ Features
|
| 18 |
+
|
| 19 |
+
- 📈 **Multi-Source Aggregation**: Compare odds across leading prediction markets
|
| 20 |
+
- 🎯 **Integrity Scoring**: 4-component analysis (Clarity, Liquidity, Agreement, Volatility)
|
| 21 |
+
- 📊 **Visual Analytics**: Real-time odds history charts with Chart.js
|
| 22 |
+
- 📥 **Export Tools**: PDF dossiers, CSV data exports, JSON metadata
|
| 23 |
+
- ⚡ **Background Processing**: BullMQ-powered workers for instant dashboard loads
|
| 24 |
+
- 🎨 **Modern UI**: Dark theme, responsive design with Tailwind CSS
|
| 25 |
+
|
| 26 |
+
## 🏗️ Architecture
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
┌─────────────────────────────────────┐
|
| 30 |
+
│ Docker Container (HF Space) │
|
| 31 |
+
│ │
|
| 32 |
+
│ Redis ──► NestJS Backend (BullMQ) │
|
| 33 |
+
│ ↓ │
|
| 34 |
+
│ Next.js Frontend │
|
| 35 |
+
│ (Port 7860) │
|
| 36 |
+
└─────────────────────────────────────┘
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## 🚀 Quick Start
|
| 40 |
+
|
| 41 |
+
### On Hugging Face Spaces
|
| 42 |
+
|
| 43 |
+
Simply clone this Space or visit the live demo!
|
| 44 |
+
|
| 45 |
+
### Local Development
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# Clone the repository
|
| 49 |
+
git clone <your-repo-url>
|
| 50 |
+
cd wagerkit
|
| 51 |
+
|
| 52 |
+
# Start with Docker Compose
|
| 53 |
+
docker-compose up
|
| 54 |
+
|
| 55 |
+
# Or run individually:
|
| 56 |
+
# Terminal 1: Redis
|
| 57 |
+
docker run -d -p 6379:6379 redis:7-alpine
|
| 58 |
+
|
| 59 |
+
# Terminal 2: Backend
|
| 60 |
+
cd backend && npm install && npm run start:dev
|
| 61 |
+
|
| 62 |
+
# Terminal 3: Frontend
|
| 63 |
+
cd frontend && npm install && npm run dev
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Access at: http://localhost:3000
|
| 67 |
+
|
| 68 |
+
## 📊 Integrity Score Formula
|
| 69 |
+
|
| 70 |
+
**Overall Score** = 100 × (0.40×C + 0.30×L + 0.20×A + 0.10×V)
|
| 71 |
+
|
| 72 |
+
- **C** (Market Clarity): Resolution criteria quality, source coverage
|
| 73 |
+
- **L** (Liquidity Depth): Trading volume, update frequency
|
| 74 |
+
- **A** (Cross-Source Agreement): RMSE between sources
|
| 75 |
+
- **V** (Volatility Sanity): Price stability, spike detection
|
| 76 |
+
|
| 77 |
+
## 🛠️ Tech Stack
|
| 78 |
+
|
| 79 |
+
**Backend:**
|
| 80 |
+
- NestJS 10 (TypeScript)
|
| 81 |
+
- BullMQ (background jobs)
|
| 82 |
+
- Redis (job queue & cache)
|
| 83 |
+
|
| 84 |
+
**Frontend:**
|
| 85 |
+
- Next.js 14 (App Router)
|
| 86 |
+
- React 18
|
| 87 |
+
- Chart.js (visualization)
|
| 88 |
+
- Tailwind CSS (styling)
|
| 89 |
+
- jsPDF (PDF export)
|
| 90 |
+
|
| 91 |
+
## 📦 Deployment
|
| 92 |
+
|
| 93 |
+
See [DEPLOYMENT.md](./DEPLOYMENT.md) for detailed instructions on:
|
| 94 |
+
- Hugging Face Spaces setup
|
| 95 |
+
- Docker configuration
|
| 96 |
+
- Environment variables
|
| 97 |
+
- Local development
|
| 98 |
+
|
| 99 |
+
## 🎮 Usage
|
| 100 |
+
|
| 101 |
+
1. **Dashboard**: View all markets with processing status badges
|
| 102 |
+
2. **Click any card**: Navigate to detailed market analysis
|
| 103 |
+
3. **View Charts**: Compare historical odds across 4 sources
|
| 104 |
+
4. **Export Data**: Download PDF dossiers, CSV odds history, or JSON metadata
|
| 105 |
+
|
| 106 |
+
## 📁 Project Structure
|
| 107 |
+
|
| 108 |
+
```
|
| 109 |
+
wagerkit/
|
| 110 |
+
├── backend/ # NestJS backend
|
| 111 |
+
│ ├── src/
|
| 112 |
+
│ │ ├── markets/ # Markets module (BullMQ workers)
|
| 113 |
+
│ │ ├── auth/ # Auth module (unused in demo)
|
| 114 |
+
│ │ └── main.ts
|
| 115 |
+
│ ├── Dockerfile # Backend Docker image
|
| 116 |
+
│ └── package.json
|
| 117 |
+
├── frontend/ # Next.js frontend
|
| 118 |
+
│ ├── src/
|
| 119 |
+
│ │ ├── app/ # App router pages
|
| 120 |
+
│ │ ├── components/ # Reusable components
|
| 121 |
+
│ │ └── lib/ # API client
|
| 122 |
+
│ ├── Dockerfile # Frontend Docker image
|
| 123 |
+
│ └── package.json
|
| 124 |
+
├── Dockerfile # Multi-stage build (HF Spaces)
|
| 125 |
+
├── docker-compose.yml # Local development
|
| 126 |
+
├── DEPLOYMENT.md # Detailed deployment guide
|
| 127 |
+
└── README.md
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## 🤝 Contributing
|
| 131 |
+
|
| 132 |
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
| 133 |
+
|
| 134 |
+
## 📄 License
|
| 135 |
+
|
| 136 |
+
MIT License
|
| 137 |
+
|
| 138 |
+
## 🙏 Acknowledgments
|
| 139 |
+
|
| 140 |
+
Built with data from leading prediction markets. This is a demonstration project showcasing real-time data aggregation and integrity analysis techniques.
|
| 141 |
+
|
| 142 |
+
---
|
| 143 |
+
|
| 144 |
+
**Documentation**: [DEPLOYMENT.md](./DEPLOYMENT.md)
|
| 145 |
+
Each market includes:
|
| 146 |
+
- **Integrity Score** (0-100) with 4 sub-components:
|
| 147 |
+
- Market Clarity (40% weight)
|
| 148 |
+
- Liquidity Depth (30% weight)
|
| 149 |
+
- Cross-Source Agreement (20% weight)
|
| 150 |
+
- Volatility Sanity (10% weight)
|
| 151 |
+
- **Odds History Chart** - 24-hour price trends from 3 sources
|
| 152 |
+
- **Data Sources** - Polymarket, Kalshi, PredictIt
|
| 153 |
+
- **Export Options**:
|
| 154 |
+
- ✅ Download Dossier (PDF)
|
| 155 |
+
- ✅ Download Dossier (JSON)
|
| 156 |
+
- ✅ Export Odds CSV
|
| 157 |
+
- ✅ Export Integrity CSV
|
| 158 |
+
|
| 159 |
+
## 📊 Integrity Score Formula
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
Score = 100 × (0.40×C + 0.30×L + 0.20×A + 0.10×V)
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
Where:
|
| 166 |
+
- **C** = Market Clarity
|
| 167 |
+
- **L** = Liquidity Depth
|
| 168 |
+
- **A** = Cross-Source Agreement (RMSE-based)
|
| 169 |
+
- **V** = Volatility Sanity (includes spike detection)
|
| 170 |
+
|
| 171 |
+
## 🔧 Tech Stack
|
| 172 |
+
|
| 173 |
+
### Backend
|
| 174 |
+
- NestJS 10
|
| 175 |
+
- TypeScript
|
| 176 |
+
- JWT Authentication (ready but disabled)
|
| 177 |
+
- Express.js
|
| 178 |
+
|
| 179 |
+
### Frontend
|
| 180 |
+
- Next.js 14
|
| 181 |
+
- React 18
|
| 182 |
+
- TypeScript
|
| 183 |
+
- TailwindCSS
|
| 184 |
+
- Chart.js (for odds visualization)
|
| 185 |
+
- jsPDF (for PDF exports)
|
| 186 |
+
|
| 187 |
+
## 📝 API Endpoints
|
| 188 |
+
|
| 189 |
+
### Markets
|
| 190 |
+
- `GET /api/markets` - List all markets
|
| 191 |
+
- `GET /api/markets/:slug` - Get market detail with integrity scores
|
| 192 |
+
- `GET /api/markets/:slug/export/odds-csv` - Export odds history CSV
|
| 193 |
+
- `GET /api/markets/:slug/export/integrity-csv` - Export integrity scores CSV
|
| 194 |
+
- `GET /api/markets/:slug/export/dossier-json` - Export full dossier JSON
|
| 195 |
+
|
| 196 |
+
### Authentication (Optional)
|
| 197 |
+
- `POST /api/auth/login` - Login endpoint (not required for demo)
|
| 198 |
+
|
| 199 |
+
## 🔑 Environment Variables
|
| 200 |
+
|
| 201 |
+
### Backend (`backend/.env`)
|
| 202 |
+
```env
|
| 203 |
+
JWT_SECRET=wagerkit-jwt-secret-key-2024
|
| 204 |
+
DOME_API_KEY=your-dome-api-key-here
|
| 205 |
+
PORT=3001
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
### Frontend (`frontend/.env.local`)
|
| 209 |
+
```env
|
| 210 |
+
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
## 🎨 Design Theme
|
| 214 |
+
- Dark purple/blue gradient background
|
| 215 |
+
- Card-based UI with `#12121e` background
|
| 216 |
+
- Purple (`#6b21a8`) and blue (`#3b82f6`) accent colors
|
| 217 |
+
- Responsive design with Tailwind CSS
|
| 218 |
+
|
| 219 |
+
## 🔮 DOME API Integration (Future)
|
| 220 |
+
|
| 221 |
+
The app is designed to integrate with the DOME API. To enable real data:
|
| 222 |
+
|
| 223 |
+
1. **Install SDK**:
|
| 224 |
+
```bash
|
| 225 |
+
cd backend
|
| 226 |
+
npm install @dome-api/sdk
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
2. **Add API Key** to `backend/.env`:
|
| 230 |
+
```env
|
| 231 |
+
DOME_API_KEY=your-actual-api-key
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
3. **Update Service** in `backend/src/markets/markets.service.ts`:
|
| 235 |
+
- Uncomment the DOME SDK import
|
| 236 |
+
- Replace simulated data methods with real API calls
|
| 237 |
+
|
| 238 |
+
Currently uses simulated data with realistic patterns for demo purposes.
|
| 239 |
+
|
| 240 |
+
## 🚢 Deployment
|
| 241 |
+
|
| 242 |
+
### Build Production
|
| 243 |
+
```bash
|
| 244 |
+
# Backend
|
| 245 |
+
cd backend
|
| 246 |
+
npm run build
|
| 247 |
+
npm run start:prod
|
| 248 |
+
|
| 249 |
+
# Frontend
|
| 250 |
+
cd frontend
|
| 251 |
+
npm run build
|
| 252 |
+
npm start
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
## 📋 Notes
|
| 256 |
+
- Authentication is **disabled** for easy demo access
|
| 257 |
+
- Data is **simulated** but follows real market patterns
|
| 258 |
+
- All export features are **fully functional**
|
| 259 |
+
- Runs entirely locally - no external dependencies required
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
**Built for client demo - February 2026**
|
backend/.env.example
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Environment Variables
|
| 2 |
+
|
| 3 |
+
# Node Environment
|
| 4 |
+
NODE_ENV=production
|
| 5 |
+
|
| 6 |
+
# Server Port
|
| 7 |
+
PORT=3001
|
| 8 |
+
|
| 9 |
+
# Redis Configuration
|
| 10 |
+
REDIS_HOST=localhost
|
| 11 |
+
REDIS_PORT=6379
|
| 12 |
+
|
| 13 |
+
# Optional: Redis Authentication (if using hosted Redis)
|
| 14 |
+
# REDIS_PASSWORD=your_password_here
|
| 15 |
+
|
| 16 |
+
# Optional: Redis TLS (set to 'true' for hosted Redis with TLS)
|
| 17 |
+
# REDIS_TLS=false
|
| 18 |
+
|
| 19 |
+
# Optional: DOME API Key (if integrating real data)
|
| 20 |
+
DOME_API_KEY=your_dome_api_key_here
|
| 21 |
+
|
| 22 |
+
# Database Configuration (if you extend to use PostgreSQL/ClickHouse)
|
| 23 |
+
# DB_HOST=localhost
|
| 24 |
+
# DB_PORT=5432
|
| 25 |
+
# DB_USER=postgres
|
| 26 |
+
# DB_PASSWORD=postgres
|
| 27 |
+
# DB_NAME=wagerkit
|
| 28 |
+
# DB_SSL=false
|
| 29 |
+
|
| 30 |
+
# ClickHouse Configuration (if using ClickHouse for tick data)
|
| 31 |
+
# CLICKHOUSE_HOST=http://localhost:8123
|
| 32 |
+
# CLICKHOUSE_USER=default
|
| 33 |
+
# CLICKHOUSE_PASSWORD=
|
| 34 |
+
# CLICKHOUSE_DB=default
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Dockerfile
|
| 2 |
+
FROM node:20-alpine
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy package files
|
| 7 |
+
COPY package*.json ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm ci --only=production
|
| 11 |
+
|
| 12 |
+
# Copy source
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Build application
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
# Expose port
|
| 19 |
+
EXPOSE 3001
|
| 20 |
+
|
| 21 |
+
# Start application
|
| 22 |
+
CMD ["node", "dist/main.js"]
|
backend/nest-cli.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/nest-cli",
|
| 3 |
+
"collection": "@nestjs/schematics",
|
| 4 |
+
"sourceRoot": "src",
|
| 5 |
+
"compilerOptions": {
|
| 6 |
+
"deleteOutDir": true
|
| 7 |
+
}
|
| 8 |
+
}
|
backend/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "wagerkit-backend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "WagerKit API Backend",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"build": "nest build",
|
| 7 |
+
"start": "nest start",
|
| 8 |
+
"start:dev": "nest start --watch",
|
| 9 |
+
"start:prod": "node dist/main"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@nestjs/bullmq": "^11.0.4",
|
| 13 |
+
"@nestjs/common": "^10.3.0",
|
| 14 |
+
"@nestjs/config": "^3.2.0",
|
| 15 |
+
"@nestjs/core": "^10.3.0",
|
| 16 |
+
"@nestjs/jwt": "^10.2.0",
|
| 17 |
+
"@nestjs/passport": "^10.0.3",
|
| 18 |
+
"@nestjs/platform-express": "^10.3.0",
|
| 19 |
+
"bullmq": "^5.69.3",
|
| 20 |
+
"class-transformer": "^0.5.1",
|
| 21 |
+
"class-validator": "^0.14.1",
|
| 22 |
+
"ioredis": "^5.9.3",
|
| 23 |
+
"passport": "^0.7.0",
|
| 24 |
+
"passport-jwt": "^4.0.1",
|
| 25 |
+
"reflect-metadata": "^0.2.1",
|
| 26 |
+
"rxjs": "^7.8.1"
|
| 27 |
+
},
|
| 28 |
+
"devDependencies": {
|
| 29 |
+
"@nestjs/cli": "^10.3.0",
|
| 30 |
+
"@nestjs/schematics": "^10.1.0",
|
| 31 |
+
"@types/express": "^4.17.21",
|
| 32 |
+
"@types/node": "^20.11.0",
|
| 33 |
+
"@types/passport-jwt": "^4.0.1",
|
| 34 |
+
"ts-loader": "^9.5.1",
|
| 35 |
+
"ts-node": "^10.9.2",
|
| 36 |
+
"tsconfig-paths": "^4.2.0",
|
| 37 |
+
"typescript": "^5.3.3"
|
| 38 |
+
}
|
| 39 |
+
}
|
backend/src/app.module.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from '@nestjs/common';
|
| 2 |
+
import { ConfigModule } from '@nestjs/config';
|
| 3 |
+
import { BullModule } from '@nestjs/bullmq';
|
| 4 |
+
import { AuthModule } from './auth/auth.module';
|
| 5 |
+
import { MarketsModule } from './markets/markets.module';
|
| 6 |
+
|
| 7 |
+
@Module({
|
| 8 |
+
imports: [
|
| 9 |
+
ConfigModule.forRoot({ isGlobal: true }),
|
| 10 |
+
BullModule.forRoot({
|
| 11 |
+
connection: {
|
| 12 |
+
host: process.env.REDIS_HOST || '127.0.0.1',
|
| 13 |
+
port: parseInt(process.env.REDIS_PORT || '6379'),
|
| 14 |
+
maxRetriesPerRequest: null,
|
| 15 |
+
},
|
| 16 |
+
}),
|
| 17 |
+
AuthModule,
|
| 18 |
+
MarketsModule,
|
| 19 |
+
],
|
| 20 |
+
})
|
| 21 |
+
export class AppModule {}
|
backend/src/auth/auth.controller.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Controller, Post, Body } from '@nestjs/common';
|
| 2 |
+
import { AuthService } from './auth.service';
|
| 3 |
+
|
| 4 |
+
@Controller('auth')
|
| 5 |
+
export class AuthController {
|
| 6 |
+
constructor(private readonly authService: AuthService) {}
|
| 7 |
+
|
| 8 |
+
@Post('login')
|
| 9 |
+
async login(@Body() body: { username: string; password: string }) {
|
| 10 |
+
return this.authService.login(body.username, body.password);
|
| 11 |
+
}
|
| 12 |
+
}
|
backend/src/auth/auth.module.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from '@nestjs/common';
|
| 2 |
+
import { JwtModule } from '@nestjs/jwt';
|
| 3 |
+
import { PassportModule } from '@nestjs/passport';
|
| 4 |
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
| 5 |
+
import { AuthService } from './auth.service';
|
| 6 |
+
import { AuthController } from './auth.controller';
|
| 7 |
+
import { JwtStrategy } from './jwt.strategy';
|
| 8 |
+
|
| 9 |
+
@Module({
|
| 10 |
+
imports: [
|
| 11 |
+
PassportModule.register({ defaultStrategy: 'jwt' }),
|
| 12 |
+
JwtModule.registerAsync({
|
| 13 |
+
imports: [ConfigModule],
|
| 14 |
+
inject: [ConfigService],
|
| 15 |
+
useFactory: (config: ConfigService) => ({
|
| 16 |
+
secret: config.get<string>('JWT_SECRET', 'wagerkit-jwt-secret-key-2024'),
|
| 17 |
+
signOptions: { expiresIn: '24h' },
|
| 18 |
+
}),
|
| 19 |
+
}),
|
| 20 |
+
],
|
| 21 |
+
controllers: [AuthController],
|
| 22 |
+
providers: [AuthService, JwtStrategy],
|
| 23 |
+
exports: [AuthService, JwtModule],
|
| 24 |
+
})
|
| 25 |
+
export class AuthModule {}
|
backend/src/auth/auth.service.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
| 2 |
+
import { JwtService } from '@nestjs/jwt';
|
| 3 |
+
|
| 4 |
+
interface User {
|
| 5 |
+
id: number;
|
| 6 |
+
username: string;
|
| 7 |
+
password: string;
|
| 8 |
+
name: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
@Injectable()
|
| 12 |
+
export class AuthService {
|
| 13 |
+
private readonly users: User[] = [
|
| 14 |
+
{ id: 1, username: 'demo', password: 'wagerkit2024', name: 'Demo User' },
|
| 15 |
+
{ id: 2, username: 'admin', password: 'admin123', name: 'Admin' },
|
| 16 |
+
];
|
| 17 |
+
|
| 18 |
+
constructor(private readonly jwtService: JwtService) {}
|
| 19 |
+
|
| 20 |
+
async validateUser(username: string, password: string): Promise<Omit<User, 'password'> | null> {
|
| 21 |
+
const user = this.users.find(
|
| 22 |
+
(u) => u.username === username && u.password === password,
|
| 23 |
+
);
|
| 24 |
+
if (user) {
|
| 25 |
+
const { password: _, ...result } = user;
|
| 26 |
+
return result;
|
| 27 |
+
}
|
| 28 |
+
return null;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async login(username: string, password: string) {
|
| 32 |
+
const user = await this.validateUser(username, password);
|
| 33 |
+
if (!user) {
|
| 34 |
+
throw new UnauthorizedException('Invalid credentials');
|
| 35 |
+
}
|
| 36 |
+
const payload = { sub: user.id, username: user.username, name: user.name };
|
| 37 |
+
return {
|
| 38 |
+
access_token: this.jwtService.sign(payload),
|
| 39 |
+
user: { id: user.id, username: user.username, name: user.name },
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
}
|
backend/src/auth/jwt-auth.guard.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable } from '@nestjs/common';
|
| 2 |
+
import { AuthGuard } from '@nestjs/passport';
|
| 3 |
+
|
| 4 |
+
@Injectable()
|
| 5 |
+
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
backend/src/auth/jwt.strategy.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable } from '@nestjs/common';
|
| 2 |
+
import { PassportStrategy } from '@nestjs/passport';
|
| 3 |
+
import { ExtractJwt, Strategy } from 'passport-jwt';
|
| 4 |
+
import { ConfigService } from '@nestjs/config';
|
| 5 |
+
|
| 6 |
+
@Injectable()
|
| 7 |
+
export class JwtStrategy extends PassportStrategy(Strategy) {
|
| 8 |
+
constructor(private configService: ConfigService) {
|
| 9 |
+
super({
|
| 10 |
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
| 11 |
+
ignoreExpiration: false,
|
| 12 |
+
secretOrKey: configService.get<string>('JWT_SECRET', 'wagerkit-jwt-secret-key-2024'),
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
async validate(payload: any) {
|
| 17 |
+
return { id: payload.sub, username: payload.username, name: payload.name };
|
| 18 |
+
}
|
| 19 |
+
}
|
backend/src/main.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NestFactory } from '@nestjs/core';
|
| 2 |
+
import { AppModule } from './app.module';
|
| 3 |
+
|
| 4 |
+
async function bootstrap() {
|
| 5 |
+
const app = await NestFactory.create(AppModule);
|
| 6 |
+
app.enableCors({
|
| 7 |
+
origin: 'http://localhost:3000',
|
| 8 |
+
credentials: true,
|
| 9 |
+
});
|
| 10 |
+
app.setGlobalPrefix('api');
|
| 11 |
+
await app.listen(3001);
|
| 12 |
+
console.log('WagerKit API running on http://localhost:3001');
|
| 13 |
+
}
|
| 14 |
+
bootstrap();
|
backend/src/markets/market-cache.service.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
| 2 |
+
|
| 3 |
+
export interface CachedMarketData {
|
| 4 |
+
slug: string;
|
| 5 |
+
status: 'pending' | 'processing' | 'ready' | 'error';
|
| 6 |
+
data?: any;
|
| 7 |
+
updatedAt?: string;
|
| 8 |
+
error?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
@Injectable()
|
| 12 |
+
export class MarketCacheService {
|
| 13 |
+
private readonly logger = new Logger(MarketCacheService.name);
|
| 14 |
+
private cache = new Map<string, CachedMarketData>();
|
| 15 |
+
|
| 16 |
+
setStatus(slug: string, status: CachedMarketData['status'], data?: any, error?: string) {
|
| 17 |
+
const entry: CachedMarketData = {
|
| 18 |
+
slug,
|
| 19 |
+
status,
|
| 20 |
+
data: data || this.cache.get(slug)?.data,
|
| 21 |
+
updatedAt: new Date().toISOString(),
|
| 22 |
+
error,
|
| 23 |
+
};
|
| 24 |
+
this.cache.set(slug, entry);
|
| 25 |
+
this.logger.log(`Cache [${slug}] → ${status}`);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
get(slug: string): CachedMarketData | undefined {
|
| 29 |
+
return this.cache.get(slug);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
getAll(): CachedMarketData[] {
|
| 33 |
+
return Array.from(this.cache.values());
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
isReady(slug: string): boolean {
|
| 37 |
+
const entry = this.cache.get(slug);
|
| 38 |
+
return entry?.status === 'ready';
|
| 39 |
+
}
|
| 40 |
+
}
|
backend/src/markets/market.processor.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
| 2 |
+
import { Logger } from '@nestjs/common';
|
| 3 |
+
import { Job } from 'bullmq';
|
| 4 |
+
import { MarketsService } from './markets.service';
|
| 5 |
+
import { MarketCacheService } from './market-cache.service';
|
| 6 |
+
|
| 7 |
+
@Processor('market-processing')
|
| 8 |
+
export class MarketProcessor extends WorkerHost {
|
| 9 |
+
private readonly logger = new Logger(MarketProcessor.name);
|
| 10 |
+
|
| 11 |
+
constructor(
|
| 12 |
+
private readonly marketsService: MarketsService,
|
| 13 |
+
private readonly cacheService: MarketCacheService,
|
| 14 |
+
) {
|
| 15 |
+
super();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async process(job: Job<{ slug: string }>): Promise<any> {
|
| 19 |
+
const { slug } = job.data;
|
| 20 |
+
this.logger.log(`Processing market: ${slug} (Job ${job.id})`);
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
this.cacheService.setStatus(slug, 'processing');
|
| 24 |
+
|
| 25 |
+
// Simulate heavy data fetching & integrity calculation
|
| 26 |
+
// In production this would call real APIs
|
| 27 |
+
await this.simulateWork(300);
|
| 28 |
+
|
| 29 |
+
// Generate market detail (this does the heavy computation)
|
| 30 |
+
const detail = this.marketsService.getMarketDetail(slug);
|
| 31 |
+
|
| 32 |
+
if (!detail) {
|
| 33 |
+
throw new Error(`Market ${slug} not found`);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Calculate integrity score from the generated data
|
| 37 |
+
const computedScore = this.marketsService.calculateIntegrityScore(detail, detail.oddsHistory);
|
| 38 |
+
|
| 39 |
+
const enrichedDetail = {
|
| 40 |
+
...detail,
|
| 41 |
+
integrityScore: computedScore,
|
| 42 |
+
processedAt: new Date().toISOString(),
|
| 43 |
+
jobId: job.id,
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
this.cacheService.setStatus(slug, 'ready', enrichedDetail);
|
| 47 |
+
this.logger.log(`✓ Market ${slug} processed successfully (Score: ${computedScore.overall})`);
|
| 48 |
+
|
| 49 |
+
return enrichedDetail;
|
| 50 |
+
} catch (error) {
|
| 51 |
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
| 52 |
+
this.cacheService.setStatus(slug, 'error', undefined, msg);
|
| 53 |
+
this.logger.error(`✗ Market ${slug} processing failed: ${msg}`);
|
| 54 |
+
throw error;
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
private simulateWork(ms: number): Promise<void> {
|
| 59 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 60 |
+
}
|
| 61 |
+
}
|
backend/src/markets/markets.controller.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Controller,
|
| 3 |
+
Get,
|
| 4 |
+
Post,
|
| 5 |
+
Param,
|
| 6 |
+
Res,
|
| 7 |
+
NotFoundException,
|
| 8 |
+
} from '@nestjs/common';
|
| 9 |
+
import { InjectQueue } from '@nestjs/bullmq';
|
| 10 |
+
import { Queue } from 'bullmq';
|
| 11 |
+
import { Response } from 'express';
|
| 12 |
+
import { MarketsService } from './markets.service';
|
| 13 |
+
import { MarketCacheService } from './market-cache.service';
|
| 14 |
+
|
| 15 |
+
@Controller('markets')
|
| 16 |
+
export class MarketsController {
|
| 17 |
+
constructor(
|
| 18 |
+
private readonly marketsService: MarketsService,
|
| 19 |
+
private readonly cacheService: MarketCacheService,
|
| 20 |
+
@InjectQueue('market-processing') private readonly marketQueue: Queue,
|
| 21 |
+
) {}
|
| 22 |
+
|
| 23 |
+
@Get()
|
| 24 |
+
getMarkets() {
|
| 25 |
+
const markets = this.marketsService.getMarkets();
|
| 26 |
+
// Enrich with processing status
|
| 27 |
+
return markets.map((m) => {
|
| 28 |
+
const cached = this.cacheService.get(m.slug);
|
| 29 |
+
return {
|
| 30 |
+
...m,
|
| 31 |
+
processingStatus: cached?.status || 'unknown',
|
| 32 |
+
};
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
@Get('jobs/status')
|
| 37 |
+
getJobsStatus() {
|
| 38 |
+
const all = this.cacheService.getAll();
|
| 39 |
+
return {
|
| 40 |
+
markets: all.map((c) => ({
|
| 41 |
+
slug: c.slug,
|
| 42 |
+
status: c.status,
|
| 43 |
+
updatedAt: c.updatedAt,
|
| 44 |
+
error: c.error,
|
| 45 |
+
})),
|
| 46 |
+
summary: {
|
| 47 |
+
total: all.length,
|
| 48 |
+
ready: all.filter((c) => c.status === 'ready').length,
|
| 49 |
+
processing: all.filter((c) => c.status === 'processing').length,
|
| 50 |
+
pending: all.filter((c) => c.status === 'pending').length,
|
| 51 |
+
error: all.filter((c) => c.status === 'error').length,
|
| 52 |
+
},
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
@Post(':slug/refresh')
|
| 57 |
+
async refreshMarket(@Param('slug') slug: string) {
|
| 58 |
+
const market = this.marketsService.getMarkets().find((m) => m.slug === slug);
|
| 59 |
+
if (!market) {
|
| 60 |
+
throw new NotFoundException('Market not found');
|
| 61 |
+
}
|
| 62 |
+
this.cacheService.setStatus(slug, 'pending');
|
| 63 |
+
await this.marketQueue.add(
|
| 64 |
+
'process-market',
|
| 65 |
+
{ slug },
|
| 66 |
+
{ removeOnComplete: 100, removeOnFail: 50 },
|
| 67 |
+
);
|
| 68 |
+
return { message: 'Market refresh queued', slug, status: 'pending' };
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@Get(':slug')
|
| 72 |
+
getMarketDetail(@Param('slug') slug: string) {
|
| 73 |
+
// Return cached data if available (processed by BullMQ worker)
|
| 74 |
+
const cached = this.cacheService.get(slug);
|
| 75 |
+
if (cached?.status === 'ready' && cached.data) {
|
| 76 |
+
return {
|
| 77 |
+
...cached.data,
|
| 78 |
+
fromCache: true,
|
| 79 |
+
processingStatus: 'ready',
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Fallback to on-demand generation if not yet cached
|
| 84 |
+
const market = this.marketsService.getMarketDetail(slug);
|
| 85 |
+
if (!market) {
|
| 86 |
+
throw new NotFoundException('Market not found');
|
| 87 |
+
}
|
| 88 |
+
return {
|
| 89 |
+
...market,
|
| 90 |
+
fromCache: false,
|
| 91 |
+
processingStatus: cached?.status || 'not-queued',
|
| 92 |
+
};
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
@Get(':slug/export/odds-csv')
|
| 96 |
+
exportOddsCsv(@Param('slug') slug: string, @Res() res: Response) {
|
| 97 |
+
const csv = this.marketsService.getOddsHistoryCsv(slug);
|
| 98 |
+
if (!csv) {
|
| 99 |
+
throw new NotFoundException('Market not found');
|
| 100 |
+
}
|
| 101 |
+
res.setHeader('Content-Type', 'text/csv');
|
| 102 |
+
res.setHeader(
|
| 103 |
+
'Content-Disposition',
|
| 104 |
+
`attachment; filename=odds_history_${slug}.csv`,
|
| 105 |
+
);
|
| 106 |
+
res.send(csv);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
@Get(':slug/export/integrity-csv')
|
| 110 |
+
exportIntegrityCsv(@Param('slug') slug: string, @Res() res: Response) {
|
| 111 |
+
const csv = this.marketsService.getIntegrityCsv(slug);
|
| 112 |
+
if (!csv) {
|
| 113 |
+
throw new NotFoundException('Market not found');
|
| 114 |
+
}
|
| 115 |
+
res.setHeader('Content-Type', 'text/csv');
|
| 116 |
+
res.setHeader(
|
| 117 |
+
'Content-Disposition',
|
| 118 |
+
`attachment; filename=integrity_${slug}.csv`,
|
| 119 |
+
);
|
| 120 |
+
res.send(csv);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
@Get(':slug/export/dossier-json')
|
| 124 |
+
exportDossierJson(@Param('slug') slug: string) {
|
| 125 |
+
const dossier = this.marketsService.getDossierJson(slug);
|
| 126 |
+
if (!dossier) {
|
| 127 |
+
throw new NotFoundException('Market not found');
|
| 128 |
+
}
|
| 129 |
+
return dossier;
|
| 130 |
+
}
|
| 131 |
+
}
|
backend/src/markets/markets.module.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module, OnModuleInit } from '@nestjs/common';
|
| 2 |
+
import { BullModule, InjectQueue } from '@nestjs/bullmq';
|
| 3 |
+
import { Queue } from 'bullmq';
|
| 4 |
+
import { MarketsService } from './markets.service';
|
| 5 |
+
import { MarketsController } from './markets.controller';
|
| 6 |
+
import { MarketProcessor } from './market.processor';
|
| 7 |
+
import { MarketCacheService } from './market-cache.service';
|
| 8 |
+
|
| 9 |
+
@Module({
|
| 10 |
+
imports: [
|
| 11 |
+
BullModule.registerQueue({
|
| 12 |
+
name: 'market-processing',
|
| 13 |
+
}),
|
| 14 |
+
],
|
| 15 |
+
controllers: [MarketsController],
|
| 16 |
+
providers: [MarketsService, MarketProcessor, MarketCacheService],
|
| 17 |
+
})
|
| 18 |
+
export class MarketsModule implements OnModuleInit {
|
| 19 |
+
constructor(
|
| 20 |
+
@InjectQueue('market-processing') private readonly marketQueue: Queue,
|
| 21 |
+
private readonly marketsService: MarketsService,
|
| 22 |
+
private readonly cacheService: MarketCacheService,
|
| 23 |
+
) {}
|
| 24 |
+
|
| 25 |
+
async onModuleInit() {
|
| 26 |
+
// Pre-process all markets on startup so dashboard loads instantly
|
| 27 |
+
const markets = this.marketsService.getMarkets();
|
| 28 |
+
for (const market of markets) {
|
| 29 |
+
this.cacheService.setStatus(market.slug, 'pending');
|
| 30 |
+
await this.marketQueue.add(
|
| 31 |
+
'process-market',
|
| 32 |
+
{ slug: market.slug },
|
| 33 |
+
{
|
| 34 |
+
removeOnComplete: 100,
|
| 35 |
+
removeOnFail: 50,
|
| 36 |
+
attempts: 3,
|
| 37 |
+
backoff: { type: 'exponential', delay: 1000 },
|
| 38 |
+
},
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
console.log(`[WagerKit] Queued ${markets.length} markets for background processing`);
|
| 42 |
+
}
|
| 43 |
+
}
|
backend/src/markets/markets.service.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable } from '@nestjs/common';
|
| 2 |
+
|
| 3 |
+
// WagerKit internal data aggregation service
|
| 4 |
+
|
| 5 |
+
export interface MarketSource {
|
| 6 |
+
name: string;
|
| 7 |
+
type: 'regulated' | 'onchain';
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export interface IntegrityScore {
|
| 11 |
+
overall: number;
|
| 12 |
+
marketClarity: number;
|
| 13 |
+
liquidityDepth: number;
|
| 14 |
+
crossSourceAgreement: number;
|
| 15 |
+
volatilitySanity: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export interface OddsDataPoint {
|
| 19 |
+
timestamp: string;
|
| 20 |
+
polymarket: number;
|
| 21 |
+
kalshi: number;
|
| 22 |
+
predictit: number;
|
| 23 |
+
wagerkit: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface MarketSummary {
|
| 27 |
+
slug: string;
|
| 28 |
+
title: string;
|
| 29 |
+
tag: string;
|
| 30 |
+
closesAt: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export interface SimulatedMetrics {
|
| 34 |
+
/** Simulated raw tick frequency (production: from ClickHouse odds_tick) */
|
| 35 |
+
ticksPerHour: number;
|
| 36 |
+
/** Simulated 24-h trading volume in $K (production: sum(volume) from odds_tick) */
|
| 37 |
+
dailyVolumeK: number;
|
| 38 |
+
/** Metadata quality / data-completeness factor 0-1 (production: DB metadata checks) */
|
| 39 |
+
dataCompleteness: number;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface MarketDetail extends MarketSummary {
|
| 43 |
+
description: string;
|
| 44 |
+
sources: MarketSource[];
|
| 45 |
+
integrityScore: IntegrityScore;
|
| 46 |
+
oddsHistory: OddsDataPoint[];
|
| 47 |
+
notes: string[];
|
| 48 |
+
resolutionCriteriaUrl: string;
|
| 49 |
+
question: string;
|
| 50 |
+
simulatedMetrics: SimulatedMetrics;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
@Injectable()
|
| 54 |
+
export class MarketsService {
|
| 55 |
+
private readonly marketsData: MarketDetail[] = [
|
| 56 |
+
{
|
| 57 |
+
slug: 'us_election_2024_winner',
|
| 58 |
+
title: 'US 2024 Presidential Election - Winner',
|
| 59 |
+
tag: 'election',
|
| 60 |
+
closesAt: '11/6/2024',
|
| 61 |
+
description: 'Who will win the 2024 United States presidential election? This market resolves to the candidate who wins the Electoral College majority.',
|
| 62 |
+
question: 'Who will win the 2024 United States presidential election?',
|
| 63 |
+
resolutionCriteriaUrl: 'https://wagerkit.xyz/resolution/us-election-2024',
|
| 64 |
+
sources: [
|
| 65 |
+
{ name: 'PredictIt', type: 'regulated' },
|
| 66 |
+
{ name: 'Kalshi', type: 'regulated' },
|
| 67 |
+
{ name: 'Polymarket', type: 'onchain' },
|
| 68 |
+
{ name: 'WagerKit', type: 'onchain' },
|
| 69 |
+
],
|
| 70 |
+
integrityScore: {
|
| 71 |
+
overall: 84.0,
|
| 72 |
+
marketClarity: 88,
|
| 73 |
+
liquidityDepth: 82,
|
| 74 |
+
crossSourceAgreement: 85,
|
| 75 |
+
volatilitySanity: 81,
|
| 76 |
+
},
|
| 77 |
+
notes: ['Post-election convergence', 'Historical resolved market'],
|
| 78 |
+
simulatedMetrics: { ticksPerHour: 52, dailyVolumeK: 1958, dataCompleteness: 0.949 },
|
| 79 |
+
oddsHistory: [],
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
slug: 'btc_halving_2024',
|
| 83 |
+
title: 'BTC Halving 2024 - Price impact probability',
|
| 84 |
+
tag: 'crypto',
|
| 85 |
+
closesAt: '5/20/2024',
|
| 86 |
+
description: 'Will Bitcoin price exceed $80,000 within 90 days of the April 2024 halving event?',
|
| 87 |
+
question: 'Will Bitcoin price exceed $80,000 within 90 days of the April 2024 halving event?',
|
| 88 |
+
resolutionCriteriaUrl: 'https://wagerkit.xyz/resolution/btc-halving-2024',
|
| 89 |
+
sources: [
|
| 90 |
+
{ name: 'PredictIt', type: 'regulated' },
|
| 91 |
+
{ name: 'Kalshi', type: 'regulated' },
|
| 92 |
+
{ name: 'Polymarket', type: 'onchain' },
|
| 93 |
+
{ name: 'WagerKit', type: 'onchain' },
|
| 94 |
+
],
|
| 95 |
+
integrityScore: {
|
| 96 |
+
overall: 90.0,
|
| 97 |
+
marketClarity: 92,
|
| 98 |
+
liquidityDepth: 88,
|
| 99 |
+
crossSourceAgreement: 91,
|
| 100 |
+
volatilitySanity: 89,
|
| 101 |
+
},
|
| 102 |
+
notes: ['Post-halving stability', 'Historical resolved market'],
|
| 103 |
+
simulatedMetrics: { ticksPerHour: 55, dailyVolumeK: 2200, dataCompleteness: 0.919 },
|
| 104 |
+
oddsHistory: [],
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
slug: 'us_cpi_yoy_nov_2025',
|
| 108 |
+
title: 'US CPI YoY - Nov 2025 print',
|
| 109 |
+
tag: 'macro',
|
| 110 |
+
closesAt: '12/18/2025',
|
| 111 |
+
description: 'Will the US CPI Year-over-Year figure for November 2025 come in above 3.0%?',
|
| 112 |
+
question: 'Will the US CPI Year-over-Year figure for November 2025 come in above 3.0%?',
|
| 113 |
+
resolutionCriteriaUrl: 'https://wagerkit.xyz/resolution/us-cpi-nov-2025',
|
| 114 |
+
sources: [
|
| 115 |
+
{ name: 'PredictIt', type: 'regulated' },
|
| 116 |
+
{ name: 'Kalshi', type: 'regulated' },
|
| 117 |
+
{ name: 'Polymarket', type: 'onchain' },
|
| 118 |
+
{ name: 'WagerKit', type: 'onchain' },
|
| 119 |
+
],
|
| 120 |
+
integrityScore: {
|
| 121 |
+
overall: 73.0,
|
| 122 |
+
marketClarity: 76,
|
| 123 |
+
liquidityDepth: 71,
|
| 124 |
+
crossSourceAgreement: 74,
|
| 125 |
+
volatilitySanity: 71,
|
| 126 |
+
},
|
| 127 |
+
notes: ['Post-release convergence', 'Historical resolved market'],
|
| 128 |
+
simulatedMetrics: { ticksPerHour: 38, dailyVolumeK: 1855, dataCompleteness: 0.770 },
|
| 129 |
+
oddsHistory: [],
|
| 130 |
+
},
|
| 131 |
+
];
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Integrity Score Formula (mirrors production IntegrityWorker):
|
| 135 |
+
* Score = 100 × (0.40×C + 0.30×L + 0.20×A + 0.10×V)
|
| 136 |
+
*
|
| 137 |
+
* C = Market Clarity: min(1, base × dataCompleteness)
|
| 138 |
+
* base = 0.5*(hasUrl) + graduated_source_score + graduated_question_score
|
| 139 |
+
* L = Liquidity Depth: 0.40×frequencyScore + 0.40×volumeScore + 0.20×sourceScore
|
| 140 |
+
* A = Cross-Source Agreement: max(0, 1 − min(1, avgPairwiseRMSE / θ)), θ = 0.10
|
| 141 |
+
* V = Volatility Sanity: 0.70×(1 − avgNormStdDev) + 0.30×spikeScore
|
| 142 |
+
*/
|
| 143 |
+
calculateIntegrityScore(market: MarketDetail, oddsHistory: OddsDataPoint[]): IntegrityScore {
|
| 144 |
+
const metrics = market.simulatedMetrics;
|
| 145 |
+
|
| 146 |
+
// ── C: Market Clarity ──────────────────────────────────────────────
|
| 147 |
+
// Production: calculateClarity(market, sourceCount)
|
| 148 |
+
// = min(1, 0.5*(hasUrl) + graduated_source + graduated_question) × dataCompleteness
|
| 149 |
+
let clarityBase = 0;
|
| 150 |
+
if (market.resolutionCriteriaUrl) clarityBase += 0.5;
|
| 151 |
+
// Graduated source score: 1→0 … 4+→0.30
|
| 152 |
+
clarityBase += Math.min(0.3, ((Math.min(market.sources.length, 4) - 1) / 3) * 0.3);
|
| 153 |
+
// Question specificity: graduated by length (20→0, 80+→0.20)
|
| 154 |
+
const qLen = market.question?.length || 0;
|
| 155 |
+
clarityBase += Math.min(0.2, Math.max(0, (qLen - 20) / 300));
|
| 156 |
+
// dataCompleteness from per-market simulated metrics (production: DB metadata quality)
|
| 157 |
+
const clarity = Math.min(1, clarityBase * metrics.dataCompleteness);
|
| 158 |
+
|
| 159 |
+
// ── L: Liquidity Depth ─────────────────────────────────────────────
|
| 160 |
+
// Production: 0.4×freq + 0.4×vol + 0.2×src (real tick data from ClickHouse odds_tick)
|
| 161 |
+
// Uses per-market simulated tick metrics instead of chart-point density.
|
| 162 |
+
const frequencyScore = Math.min(1, metrics.ticksPerHour / 60);
|
| 163 |
+
const volumeScore = Math.min(1, metrics.dailyVolumeK / 2500);
|
| 164 |
+
const sourceScore = Math.min(1, market.sources.length / 5);
|
| 165 |
+
const liquidity = Math.max(0.1, Math.min(1,
|
| 166 |
+
0.4 * frequencyScore + 0.4 * volumeScore + 0.2 * sourceScore,
|
| 167 |
+
));
|
| 168 |
+
|
| 169 |
+
// ── A: Cross-Source Agreement ──────────────────────────────────────
|
| 170 |
+
// Production: max(0, 1 − min(1, RMSE / θ)), θ = 0.10
|
| 171 |
+
// Average pairwise RMSE across all source combinations.
|
| 172 |
+
const sourceKeys = ['polymarket', 'kalshi', 'predictit', 'wagerkit'] as const;
|
| 173 |
+
let agreement = 0.5;
|
| 174 |
+
if (oddsHistory.length > 0 && market.sources.length >= 2) {
|
| 175 |
+
let totalRmse = 0;
|
| 176 |
+
let pairCount = 0;
|
| 177 |
+
for (let i = 0; i < sourceKeys.length; i++) {
|
| 178 |
+
for (let j = i + 1; j < sourceKeys.length; j++) {
|
| 179 |
+
let sumSqDiff = 0;
|
| 180 |
+
for (const point of oddsHistory) {
|
| 181 |
+
sumSqDiff += Math.pow(point[sourceKeys[i]] - point[sourceKeys[j]], 2);
|
| 182 |
+
}
|
| 183 |
+
totalRmse += Math.sqrt(sumSqDiff / oddsHistory.length);
|
| 184 |
+
pairCount++;
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
const avgRmse = totalRmse / pairCount;
|
| 188 |
+
const theta = 0.1;
|
| 189 |
+
agreement = Math.max(0, 1 - Math.min(1, avgRmse / theta));
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// ── V: Volatility Sanity ──────────────────────────────────────────
|
| 193 |
+
// Production: 0.70×(1 − normStdDev) + 0.30×spikeScore
|
| 194 |
+
// Per-source analysis (matching production per-mapping pattern).
|
| 195 |
+
let volatility = 0.5;
|
| 196 |
+
if (oddsHistory.length > 1) {
|
| 197 |
+
let totalNormStdDev = 0;
|
| 198 |
+
let totalSpikes = 0;
|
| 199 |
+
let totalTicks = 0;
|
| 200 |
+
|
| 201 |
+
for (const key of sourceKeys) {
|
| 202 |
+
const prices = oddsHistory.map((p) => p[key]);
|
| 203 |
+
const mean = prices.reduce((a, b) => a + b, 0) / prices.length;
|
| 204 |
+
const stdDev = Math.sqrt(
|
| 205 |
+
prices.reduce((sum, p) => sum + Math.pow(p - mean, 2), 0) / prices.length,
|
| 206 |
+
);
|
| 207 |
+
totalNormStdDev += Math.min(1, stdDev / 0.1);
|
| 208 |
+
|
| 209 |
+
// Spike detection: >10 % relative change between consecutive ticks
|
| 210 |
+
for (let i = 1; i < prices.length; i++) {
|
| 211 |
+
const relChange = Math.abs((prices[i] - prices[i - 1]) / prices[i - 1]);
|
| 212 |
+
if (relChange > 0.1) totalSpikes++;
|
| 213 |
+
totalTicks++;
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const avgNormStdDev = totalNormStdDev / sourceKeys.length;
|
| 218 |
+
const spikeRate = totalTicks > 0 ? totalSpikes / totalTicks : 0;
|
| 219 |
+
const spikeScore = Math.max(0, 1 - spikeRate * 2);
|
| 220 |
+
|
| 221 |
+
volatility = 0.7 * (1 - avgNormStdDev) + 0.3 * spikeScore;
|
| 222 |
+
volatility = Math.max(0.1, Math.min(1, volatility));
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// ── Overall: 100 × (0.40×C + 0.30×L + 0.20×A + 0.10×V) ──────────
|
| 226 |
+
const overall = 100 * (0.4 * clarity + 0.3 * liquidity + 0.2 * agreement + 0.1 * volatility);
|
| 227 |
+
|
| 228 |
+
return {
|
| 229 |
+
overall: Math.round(overall * 10) / 10,
|
| 230 |
+
marketClarity: Math.round(clarity * 100),
|
| 231 |
+
liquidityDepth: Math.round(liquidity * 100),
|
| 232 |
+
crossSourceAgreement: Math.round(agreement * 100),
|
| 233 |
+
volatilitySanity: Math.round(volatility * 100),
|
| 234 |
+
};
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
private generateOddsHistory(slug: string): OddsDataPoint[] {
|
| 238 |
+
const points: OddsDataPoint[] = [];
|
| 239 |
+
const now = new Date();
|
| 240 |
+
|
| 241 |
+
// Per-market odds config — trend/cosAmp control wave amplitude,
|
| 242 |
+
// vol controls random noise, offsets control per-source spread.
|
| 243 |
+
// Tighter params for resolved/post-event markets mirror production convergence.
|
| 244 |
+
const seeds: Record<string, {
|
| 245 |
+
base: number; trend: number; cosAmp: number;
|
| 246 |
+
vol: number; offsets: [number, number, number, number];
|
| 247 |
+
}> = {
|
| 248 |
+
us_election_2024_winner: {
|
| 249 |
+
base: 0.55, trend: 0.035, cosAmp: 0.017, vol: 0.008,
|
| 250 |
+
offsets: [0, 0.014, -0.011, 0.007],
|
| 251 |
+
},
|
| 252 |
+
btc_halving_2024: {
|
| 253 |
+
base: 0.72, trend: 0.02, cosAmp: 0.01, vol: 0.004,
|
| 254 |
+
offsets: [0, 0.009, -0.007, 0.004],
|
| 255 |
+
},
|
| 256 |
+
us_cpi_yoy_nov_2025: {
|
| 257 |
+
base: 0.62, trend: 0.055, cosAmp: 0.028, vol: 0.011,
|
| 258 |
+
offsets: [0, 0.024, -0.019, 0.014],
|
| 259 |
+
},
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
const config = seeds[slug] || {
|
| 263 |
+
base: 0.5, trend: 0.05, cosAmp: 0.02, vol: 0.008,
|
| 264 |
+
offsets: [0, 0.005, -0.003, 0.002] as [number, number, number, number],
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
// Deterministic pseudo-random based on slug hash
|
| 268 |
+
const hashCode = (s: string) => {
|
| 269 |
+
let h = 0;
|
| 270 |
+
for (let i = 0; i < s.length; i++) {
|
| 271 |
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
| 272 |
+
}
|
| 273 |
+
return h;
|
| 274 |
+
};
|
| 275 |
+
let seed = Math.abs(hashCode(slug));
|
| 276 |
+
const pseudoRandom = () => {
|
| 277 |
+
seed = (seed * 16807 + 0) % 2147483647;
|
| 278 |
+
return (seed & 0xfffffff) / 0x10000000;
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
for (let i = 0; i < 96; i++) {
|
| 282 |
+
const time = new Date(now.getTime() - (95 - i) * 15 * 60 * 1000);
|
| 283 |
+
const t = i / 96;
|
| 284 |
+
|
| 285 |
+
// Base trend with wave patterns (cosAmp now per-market)
|
| 286 |
+
const base =
|
| 287 |
+
config.base +
|
| 288 |
+
config.trend * Math.sin(t * Math.PI * 2.5) +
|
| 289 |
+
config.cosAmp * Math.cos(t * Math.PI * 5);
|
| 290 |
+
|
| 291 |
+
// Per-source variation using configured offsets & noise
|
| 292 |
+
const polymarket = Math.max(0.01, Math.min(0.99,
|
| 293 |
+
base + config.offsets[0] + (pseudoRandom() - 0.5) * config.vol * 2,
|
| 294 |
+
));
|
| 295 |
+
const kalshi = Math.max(0.01, Math.min(0.99,
|
| 296 |
+
base + config.offsets[1] + (pseudoRandom() - 0.5) * config.vol * 2,
|
| 297 |
+
));
|
| 298 |
+
const predictit = Math.max(0.01, Math.min(0.99,
|
| 299 |
+
base + config.offsets[2] + (pseudoRandom() - 0.5) * config.vol * 2,
|
| 300 |
+
));
|
| 301 |
+
const wk = Math.max(0.01, Math.min(0.99,
|
| 302 |
+
base + config.offsets[3] + (pseudoRandom() - 0.5) * config.vol * 2,
|
| 303 |
+
));
|
| 304 |
+
|
| 305 |
+
points.push({
|
| 306 |
+
timestamp: time.toISOString(),
|
| 307 |
+
polymarket: Math.round(polymarket * 10000) / 10000,
|
| 308 |
+
kalshi: Math.round(kalshi * 10000) / 10000,
|
| 309 |
+
predictit: Math.round(predictit * 10000) / 10000,
|
| 310 |
+
wagerkit: Math.round(wk * 10000) / 10000,
|
| 311 |
+
});
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
return points;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
getMarkets(): MarketSummary[] {
|
| 318 |
+
return this.marketsData.map(({ slug, title, tag, closesAt }) => ({
|
| 319 |
+
slug,
|
| 320 |
+
title,
|
| 321 |
+
tag,
|
| 322 |
+
closesAt,
|
| 323 |
+
}));
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
getMarketDetail(slug: string): MarketDetail | null {
|
| 327 |
+
const market = this.marketsData.find((m) => m.slug === slug);
|
| 328 |
+
if (!market) return null;
|
| 329 |
+
|
| 330 |
+
const oddsHistory = this.generateOddsHistory(slug);
|
| 331 |
+
|
| 332 |
+
// Use pre-configured scores for demo consistency
|
| 333 |
+
// In production, these would be calculated from real DOME API data:
|
| 334 |
+
// const computedScore = this.calculateIntegrityScore(market, oddsHistory);
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
...market,
|
| 338 |
+
oddsHistory,
|
| 339 |
+
};
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
getOddsHistoryCsv(slug: string): string | null {
|
| 343 |
+
const market = this.marketsData.find((m) => m.slug === slug);
|
| 344 |
+
if (!market) return null;
|
| 345 |
+
|
| 346 |
+
const oddsHistory = this.generateOddsHistory(slug);
|
| 347 |
+
const header = 'Timestamp,Polymarket,Kalshi,PredictIt,WagerKit\n';
|
| 348 |
+
const rows = oddsHistory
|
| 349 |
+
.map(
|
| 350 |
+
(p) =>
|
| 351 |
+
`${p.timestamp},${p.polymarket},${p.kalshi},${p.predictit},${p.wagerkit}`,
|
| 352 |
+
)
|
| 353 |
+
.join('\n');
|
| 354 |
+
|
| 355 |
+
return header + rows;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
getIntegrityCsv(slug: string): string | null {
|
| 359 |
+
const market = this.marketsData.find((m) => m.slug === slug);
|
| 360 |
+
if (!market) return null;
|
| 361 |
+
|
| 362 |
+
const score = market.integrityScore;
|
| 363 |
+
const header = 'Metric,Value,Weight\n';
|
| 364 |
+
const rows = [
|
| 365 |
+
`Overall Score,${score.overall},100%`,
|
| 366 |
+
`Market Clarity,${score.marketClarity}%,40%`,
|
| 367 |
+
`Liquidity Depth,${score.liquidityDepth}%,30%`,
|
| 368 |
+
`Cross-Source Agreement,${score.crossSourceAgreement}%,20%`,
|
| 369 |
+
`Volatility Sanity,${score.volatilitySanity}%,10%`,
|
| 370 |
+
].join('\n');
|
| 371 |
+
|
| 372 |
+
return header + rows;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
getDossierJson(slug: string): object | null {
|
| 376 |
+
const detail = this.getMarketDetail(slug);
|
| 377 |
+
if (!detail) return null;
|
| 378 |
+
|
| 379 |
+
return {
|
| 380 |
+
generatedAt: new Date().toISOString(),
|
| 381 |
+
platform: 'WagerKit',
|
| 382 |
+
market: {
|
| 383 |
+
title: detail.title,
|
| 384 |
+
slug: detail.slug,
|
| 385 |
+
description: detail.description,
|
| 386 |
+
tag: detail.tag,
|
| 387 |
+
closesAt: detail.closesAt,
|
| 388 |
+
question: detail.question,
|
| 389 |
+
},
|
| 390 |
+
integrityScore: detail.integrityScore,
|
| 391 |
+
sources: detail.sources,
|
| 392 |
+
notes: detail.notes,
|
| 393 |
+
oddsHistory: detail.oddsHistory,
|
| 394 |
+
};
|
| 395 |
+
}
|
| 396 |
+
}
|
backend/tsconfig.build.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "./tsconfig.json",
|
| 3 |
+
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
| 4 |
+
}
|
backend/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"module": "commonjs",
|
| 4 |
+
"declaration": true,
|
| 5 |
+
"removeComments": true,
|
| 6 |
+
"emitDecoratorMetadata": true,
|
| 7 |
+
"experimentalDecorators": true,
|
| 8 |
+
"allowSyntheticDefaultImports": true,
|
| 9 |
+
"target": "ES2021",
|
| 10 |
+
"sourceMap": true,
|
| 11 |
+
"outDir": "./dist",
|
| 12 |
+
"baseUrl": "./",
|
| 13 |
+
"incremental": true,
|
| 14 |
+
"skipLibCheck": true,
|
| 15 |
+
"strictNullChecks": false,
|
| 16 |
+
"noImplicitAny": false,
|
| 17 |
+
"strictBindCallApply": false,
|
| 18 |
+
"forceConsistentCasingInFileNames": false,
|
| 19 |
+
"noFallthroughCasesInSwitch": false
|
| 20 |
+
}
|
| 21 |
+
}
|
dev.bat
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo Starting WagerKit...
|
| 3 |
+
echo.
|
| 4 |
+
echo Starting Backend (NestJS on port 3001)...
|
| 5 |
+
start "WagerKit Backend" cmd /k "cd /d c:\demo\backend && npm run start:dev"
|
| 6 |
+
timeout /t 3 /nobreak >nul
|
| 7 |
+
|
| 8 |
+
echo Starting Frontend (Next.js on port 3000)...
|
| 9 |
+
start "WagerKit Frontend" cmd /k "cd /d c:\demo\frontend && npm run dev"
|
| 10 |
+
|
| 11 |
+
echo.
|
| 12 |
+
echo ========================================
|
| 13 |
+
echo WagerKit is starting up!
|
| 14 |
+
echo.
|
| 15 |
+
echo Backend: http://localhost:3001/api
|
| 16 |
+
echo Frontend: http://localhost:3000
|
| 17 |
+
echo ========================================
|
| 18 |
+
echo.
|
| 19 |
+
echo Press any key to exit this window (servers will continue running)
|
| 20 |
+
pause >nul
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
redis:
|
| 5 |
+
image: redis:7-alpine
|
| 6 |
+
container_name: wagerkit-redis
|
| 7 |
+
ports:
|
| 8 |
+
- "6379:6379"
|
| 9 |
+
volumes:
|
| 10 |
+
- redis-data:/data
|
| 11 |
+
command: redis-server --appendonly yes
|
| 12 |
+
healthcheck:
|
| 13 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 14 |
+
interval: 10s
|
| 15 |
+
timeout: 3s
|
| 16 |
+
retries: 3
|
| 17 |
+
|
| 18 |
+
backend:
|
| 19 |
+
build:
|
| 20 |
+
context: ./backend
|
| 21 |
+
dockerfile: Dockerfile
|
| 22 |
+
container_name: wagerkit-backend
|
| 23 |
+
ports:
|
| 24 |
+
- "3001:3001"
|
| 25 |
+
environment:
|
| 26 |
+
- NODE_ENV=production
|
| 27 |
+
- PORT=3001
|
| 28 |
+
- REDIS_HOST=redis
|
| 29 |
+
- REDIS_PORT=6379
|
| 30 |
+
- DOME_API_KEY=${DOME_API_KEY:-your_api_key_here}
|
| 31 |
+
depends_on:
|
| 32 |
+
redis:
|
| 33 |
+
condition: service_healthy
|
| 34 |
+
command: node dist/main.js
|
| 35 |
+
restart: unless-stopped
|
| 36 |
+
|
| 37 |
+
frontend:
|
| 38 |
+
build:
|
| 39 |
+
context: ./frontend
|
| 40 |
+
dockerfile: Dockerfile
|
| 41 |
+
container_name: wagerkit-frontend
|
| 42 |
+
ports:
|
| 43 |
+
- "3000:3000"
|
| 44 |
+
environment:
|
| 45 |
+
- NODE_ENV=production
|
| 46 |
+
- NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
| 47 |
+
depends_on:
|
| 48 |
+
- backend
|
| 49 |
+
restart: unless-stopped
|
| 50 |
+
|
| 51 |
+
volumes:
|
| 52 |
+
redis-data:
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend Environment Variables
|
| 2 |
+
|
| 3 |
+
# API URL - Backend API endpoint
|
| 4 |
+
# For local development: http://localhost:3001/api
|
| 5 |
+
# For Docker/Production: /api (uses Next.js rewrites)
|
| 6 |
+
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend Dockerfile
|
| 2 |
+
FROM node:20-alpine AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy package files
|
| 7 |
+
COPY package*.json ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm ci
|
| 11 |
+
|
| 12 |
+
# Copy source
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Build Next.js app
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
# Production runtime
|
| 19 |
+
FROM node:20-alpine
|
| 20 |
+
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
|
| 23 |
+
# Copy standalone build
|
| 24 |
+
COPY --from=builder /app/.next/standalone ./
|
| 25 |
+
COPY --from=builder /app/.next/static ./.next/static
|
| 26 |
+
COPY --from=builder /app/public ./public
|
| 27 |
+
|
| 28 |
+
# Expose port
|
| 29 |
+
EXPOSE 3000
|
| 30 |
+
|
| 31 |
+
# Start application
|
| 32 |
+
CMD ["node", "server.js"]
|
frontend/next-env.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
|
| 4 |
+
// NOTE: This file should not be edited
|
| 5 |
+
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
frontend/next.config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
output: 'standalone',
|
| 4 |
+
async rewrites() {
|
| 5 |
+
// In production/Docker, proxy /api to backend on port 3001
|
| 6 |
+
if (process.env.NODE_ENV === 'production') {
|
| 7 |
+
return [
|
| 8 |
+
{
|
| 9 |
+
source: '/api/:path*',
|
| 10 |
+
destination: 'http://localhost:3001/api/:path*',
|
| 11 |
+
},
|
| 12 |
+
];
|
| 13 |
+
}
|
| 14 |
+
return [];
|
| 15 |
+
},
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
module.exports = nextConfig;
|
frontend/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "wagerkit-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"next": "14.2.3",
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1",
|
| 14 |
+
"chart.js": "^4.4.1",
|
| 15 |
+
"react-chartjs-2": "^5.2.0",
|
| 16 |
+
"jspdf": "^2.5.1",
|
| 17 |
+
"jspdf-autotable": "^3.8.2"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"typescript": "^5.4.0",
|
| 21 |
+
"@types/react": "^18.3.0",
|
| 22 |
+
"@types/react-dom": "^18.3.0",
|
| 23 |
+
"@types/node": "^20.11.0",
|
| 24 |
+
"tailwindcss": "^3.4.1",
|
| 25 |
+
"postcss": "^8.4.35",
|
| 26 |
+
"autoprefixer": "^10.4.17"
|
| 27 |
+
}
|
| 28 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/src/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import Navbar from '@/components/Navbar';
|
| 6 |
+
import { getMarkets } from '@/lib/api';
|
| 7 |
+
|
| 8 |
+
interface MarketSummary {
|
| 9 |
+
slug: string;
|
| 10 |
+
title: string;
|
| 11 |
+
tag: string;
|
| 12 |
+
closesAt: string;
|
| 13 |
+
processingStatus?: 'pending' | 'processing' | 'ready' | 'error' | 'unknown';
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function DashboardPage() {
|
| 17 |
+
const router = useRouter();
|
| 18 |
+
const [markets, setMarkets] = useState<MarketSummary[]>([]);
|
| 19 |
+
const [loading, setLoading] = useState(true);
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
getMarkets()
|
| 23 |
+
.then(setMarkets)
|
| 24 |
+
.catch(() => {})
|
| 25 |
+
.finally(() => setLoading(false));
|
| 26 |
+
}, []);
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div className="page-gradient min-h-screen">
|
| 30 |
+
<Navbar />
|
| 31 |
+
|
| 32 |
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
| 33 |
+
{/* Header */}
|
| 34 |
+
<div className="mb-10">
|
| 35 |
+
<h1 className="text-3xl font-bold text-white mb-2">Markets</h1>
|
| 36 |
+
<p className="text-wk-muted">
|
| 37 |
+
Monitor prediction market integrity across multiple sources
|
| 38 |
+
</p>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
{loading ? (
|
| 42 |
+
<div className="flex items-center justify-center py-20">
|
| 43 |
+
<svg className="animate-spin h-8 w-8 text-purple-500" viewBox="0 0 24 24">
|
| 44 |
+
<circle
|
| 45 |
+
className="opacity-25"
|
| 46 |
+
cx="12"
|
| 47 |
+
cy="12"
|
| 48 |
+
r="10"
|
| 49 |
+
stroke="currentColor"
|
| 50 |
+
strokeWidth="4"
|
| 51 |
+
fill="none"
|
| 52 |
+
/>
|
| 53 |
+
<path
|
| 54 |
+
className="opacity-75"
|
| 55 |
+
fill="currentColor"
|
| 56 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
| 57 |
+
/>
|
| 58 |
+
</svg>
|
| 59 |
+
</div>
|
| 60 |
+
) : (
|
| 61 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 62 |
+
{markets.map((market) => (
|
| 63 |
+
<div
|
| 64 |
+
key={market.slug}
|
| 65 |
+
onClick={() => router.push(`/market/${market.slug}`)}
|
| 66 |
+
className="card hover:border-purple-500/30 transition-all duration-300 flex flex-col cursor-pointer"
|
| 67 |
+
>
|
| 68 |
+
{/* Card Header */}
|
| 69 |
+
<div className="flex items-start justify-between mb-4">
|
| 70 |
+
<h3 className="text-lg font-semibold text-white leading-snug pr-4">
|
| 71 |
+
{market.title}
|
| 72 |
+
</h3>
|
| 73 |
+
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-white/5 border border-wk-border flex items-center justify-center">
|
| 74 |
+
<svg
|
| 75 |
+
width="16"
|
| 76 |
+
height="16"
|
| 77 |
+
viewBox="0 0 24 24"
|
| 78 |
+
fill="none"
|
| 79 |
+
stroke="#9ca3af"
|
| 80 |
+
strokeWidth="2"
|
| 81 |
+
>
|
| 82 |
+
<polyline points="22,7 13.5,15.5 8.5,10.5 2,17" />
|
| 83 |
+
<polyline points="16,7 22,7 22,13" />
|
| 84 |
+
</svg>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{/* Tag + Status */}
|
| 89 |
+
<div className="mb-4 flex items-center gap-2">
|
| 90 |
+
<span className="tag-pill">{market.tag}</span>
|
| 91 |
+
{market.processingStatus === 'ready' && (
|
| 92 |
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-900/40 text-green-400 border border-green-800/50">
|
| 93 |
+
<span className="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
| 94 |
+
Ready
|
| 95 |
+
</span>
|
| 96 |
+
)}
|
| 97 |
+
{market.processingStatus === 'processing' && (
|
| 98 |
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-900/40 text-blue-400 border border-blue-800/50">
|
| 99 |
+
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
|
| 100 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
| 101 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
| 102 |
+
</svg>
|
| 103 |
+
Processing
|
| 104 |
+
</span>
|
| 105 |
+
)}
|
| 106 |
+
{market.processingStatus === 'pending' && (
|
| 107 |
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800/50">
|
| 108 |
+
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse"></span>
|
| 109 |
+
Queued
|
| 110 |
+
</span>
|
| 111 |
+
)}
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Divider */}
|
| 115 |
+
<div className="border-t border-wk-border my-3" />
|
| 116 |
+
|
| 117 |
+
{/* Close Date */}
|
| 118 |
+
<div className="flex items-center gap-2 text-wk-muted text-sm mb-5">
|
| 119 |
+
<svg
|
| 120 |
+
width="14"
|
| 121 |
+
height="14"
|
| 122 |
+
viewBox="0 0 24 24"
|
| 123 |
+
fill="none"
|
| 124 |
+
stroke="currentColor"
|
| 125 |
+
strokeWidth="2"
|
| 126 |
+
>
|
| 127 |
+
<circle cx="12" cy="12" r="10" />
|
| 128 |
+
<polyline points="12,6 12,12 16,14" />
|
| 129 |
+
</svg>
|
| 130 |
+
Closes: {market.closesAt}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</main>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
frontend/src/app/globals.css
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg-primary: #080810;
|
| 7 |
+
--bg-card: #12121e;
|
| 8 |
+
--border-color: #1e1e30;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
body {
|
| 12 |
+
background-color: var(--bg-primary);
|
| 13 |
+
color: #ffffff;
|
| 14 |
+
min-height: 100vh;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/* Purple/blue gradient glow at top */
|
| 18 |
+
.page-gradient {
|
| 19 |
+
background:
|
| 20 |
+
radial-gradient(ellipse at 20% -10%, rgba(107, 33, 168, 0.35) 0%, transparent 50%),
|
| 21 |
+
radial-gradient(ellipse at 80% -10%, rgba(59, 130, 246, 0.25) 0%, transparent 45%),
|
| 22 |
+
var(--bg-primary);
|
| 23 |
+
min-height: 100vh;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.card {
|
| 27 |
+
background: var(--bg-card);
|
| 28 |
+
border: 1px solid var(--border-color);
|
| 29 |
+
border-radius: 12px;
|
| 30 |
+
padding: 24px;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* Custom scrollbar */
|
| 34 |
+
::-webkit-scrollbar {
|
| 35 |
+
width: 6px;
|
| 36 |
+
}
|
| 37 |
+
::-webkit-scrollbar-track {
|
| 38 |
+
background: var(--bg-primary);
|
| 39 |
+
}
|
| 40 |
+
::-webkit-scrollbar-thumb {
|
| 41 |
+
background: #2a2a40;
|
| 42 |
+
border-radius: 3px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* Button styles */
|
| 46 |
+
.btn-primary {
|
| 47 |
+
background: linear-gradient(135deg, #6b21a8, #3b82f6);
|
| 48 |
+
color: white;
|
| 49 |
+
padding: 10px 24px;
|
| 50 |
+
border-radius: 8px;
|
| 51 |
+
font-weight: 500;
|
| 52 |
+
transition: opacity 0.2s;
|
| 53 |
+
}
|
| 54 |
+
.btn-primary:hover {
|
| 55 |
+
opacity: 0.9;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.btn-outline {
|
| 59 |
+
background: transparent;
|
| 60 |
+
border: 1px solid var(--border-color);
|
| 61 |
+
color: #9ca3af;
|
| 62 |
+
padding: 8px 16px;
|
| 63 |
+
border-radius: 8px;
|
| 64 |
+
font-weight: 500;
|
| 65 |
+
transition: all 0.2s;
|
| 66 |
+
}
|
| 67 |
+
.btn-outline:hover {
|
| 68 |
+
border-color: #3b82f6;
|
| 69 |
+
color: white;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/* Tag pills */
|
| 73 |
+
.tag-pill {
|
| 74 |
+
display: inline-block;
|
| 75 |
+
padding: 4px 14px;
|
| 76 |
+
border-radius: 20px;
|
| 77 |
+
font-size: 13px;
|
| 78 |
+
border: 1px solid #2a2a40;
|
| 79 |
+
background: rgba(255, 255, 255, 0.05);
|
| 80 |
+
color: #d1d5db;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* Note tags */
|
| 84 |
+
.note-tag {
|
| 85 |
+
display: inline-block;
|
| 86 |
+
padding: 6px 14px;
|
| 87 |
+
border-radius: 6px;
|
| 88 |
+
font-size: 13px;
|
| 89 |
+
font-weight: 500;
|
| 90 |
+
}
|
| 91 |
+
.note-tag-green {
|
| 92 |
+
background: #065f46;
|
| 93 |
+
color: #6ee7b7;
|
| 94 |
+
}
|
| 95 |
+
.note-tag-blue {
|
| 96 |
+
background: #1e3a5f;
|
| 97 |
+
color: #93c5fd;
|
| 98 |
+
}
|
frontend/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from 'next';
|
| 2 |
+
import './globals.css';
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: 'WagerKit - Market Intelligence Platform',
|
| 6 |
+
description: 'Advanced prediction market analysis and integrity scoring',
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function RootLayout({
|
| 10 |
+
children,
|
| 11 |
+
}: {
|
| 12 |
+
children: React.ReactNode;
|
| 13 |
+
}) {
|
| 14 |
+
return (
|
| 15 |
+
<html lang="en">
|
| 16 |
+
<body className="antialiased">{children}</body>
|
| 17 |
+
</html>
|
| 18 |
+
);
|
| 19 |
+
}
|
frontend/src/app/login/page.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
|
| 6 |
+
export default function LoginPage() {
|
| 7 |
+
const router = useRouter();
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
// No authentication needed - redirect to dashboard
|
| 11 |
+
router.replace('/dashboard');
|
| 12 |
+
}, [router]);
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="page-gradient flex items-center justify-center min-h-screen">
|
| 16 |
+
<div className="animate-pulse text-wk-muted text-lg">Redirecting...</div>
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
frontend/src/app/market/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { useParams, useRouter } from 'next/navigation';
|
| 5 |
+
import Navbar from '@/components/Navbar';
|
| 6 |
+
import {
|
| 7 |
+
getMarketDetail,
|
| 8 |
+
exportOddsCsv,
|
| 9 |
+
exportIntegrityCsv,
|
| 10 |
+
exportDossierJson,
|
| 11 |
+
} from '@/lib/api';
|
| 12 |
+
import {
|
| 13 |
+
Chart as ChartJS,
|
| 14 |
+
CategoryScale,
|
| 15 |
+
LinearScale,
|
| 16 |
+
PointElement,
|
| 17 |
+
LineElement,
|
| 18 |
+
Title,
|
| 19 |
+
Tooltip,
|
| 20 |
+
Legend,
|
| 21 |
+
Filler,
|
| 22 |
+
} from 'chart.js';
|
| 23 |
+
import { Line } from 'react-chartjs-2';
|
| 24 |
+
|
| 25 |
+
ChartJS.register(
|
| 26 |
+
CategoryScale,
|
| 27 |
+
LinearScale,
|
| 28 |
+
PointElement,
|
| 29 |
+
LineElement,
|
| 30 |
+
Title,
|
| 31 |
+
Tooltip,
|
| 32 |
+
Legend,
|
| 33 |
+
Filler
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
interface MarketDetail {
|
| 37 |
+
slug: string;
|
| 38 |
+
title: string;
|
| 39 |
+
tag: string;
|
| 40 |
+
closesAt: string;
|
| 41 |
+
description: string;
|
| 42 |
+
sources: { name: string; type: string }[];
|
| 43 |
+
integrityScore: {
|
| 44 |
+
overall: number;
|
| 45 |
+
marketClarity: number;
|
| 46 |
+
liquidityDepth: number;
|
| 47 |
+
crossSourceAgreement: number;
|
| 48 |
+
volatilitySanity: number;
|
| 49 |
+
};
|
| 50 |
+
oddsHistory: {
|
| 51 |
+
timestamp: string;
|
| 52 |
+
polymarket: number;
|
| 53 |
+
kalshi: number;
|
| 54 |
+
predictit: number;
|
| 55 |
+
wagerkit: number;
|
| 56 |
+
}[];
|
| 57 |
+
notes: string[];
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function downloadFile(content: string, filename: string, type: string) {
|
| 61 |
+
const blob = new Blob([content], { type });
|
| 62 |
+
const url = URL.createObjectURL(blob);
|
| 63 |
+
const a = document.createElement('a');
|
| 64 |
+
a.href = url;
|
| 65 |
+
a.download = filename;
|
| 66 |
+
document.body.appendChild(a);
|
| 67 |
+
a.click();
|
| 68 |
+
document.body.removeChild(a);
|
| 69 |
+
URL.revokeObjectURL(url);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function getScoreColor(score: number): string {
|
| 73 |
+
if (score >= 80) return 'text-green-400';
|
| 74 |
+
if (score >= 60) return 'text-blue-400';
|
| 75 |
+
if (score >= 40) return 'text-yellow-400';
|
| 76 |
+
if (score >= 20) return 'text-orange-400';
|
| 77 |
+
return 'text-red-400';
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function getScoreLabel(score: number): string {
|
| 81 |
+
if (score >= 80) return 'Highly Reliable';
|
| 82 |
+
if (score >= 60) return 'Stable';
|
| 83 |
+
if (score >= 40) return 'Caution';
|
| 84 |
+
if (score >= 20) return 'High Risk';
|
| 85 |
+
return 'Critical';
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export default function MarketDetailPage() {
|
| 89 |
+
const params = useParams();
|
| 90 |
+
const router = useRouter();
|
| 91 |
+
const slug = params.slug as string;
|
| 92 |
+
|
| 93 |
+
const [market, setMarket] = useState<MarketDetail | null>(null);
|
| 94 |
+
const [loading, setLoading] = useState(true);
|
| 95 |
+
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
if (slug) {
|
| 98 |
+
getMarketDetail(slug)
|
| 99 |
+
.then(setMarket)
|
| 100 |
+
.catch(() => {})
|
| 101 |
+
.finally(() => setLoading(false));
|
| 102 |
+
}
|
| 103 |
+
}, [slug]);
|
| 104 |
+
|
| 105 |
+
const handleExportOddsCsv = async () => {
|
| 106 |
+
const csv = await exportOddsCsv(slug);
|
| 107 |
+
downloadFile(csv, `odds_history_${slug}.csv`, 'text/csv');
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const handleExportIntegrityCsv = async () => {
|
| 111 |
+
const csv = await exportIntegrityCsv(slug);
|
| 112 |
+
downloadFile(csv, `integrity_${slug}.csv`, 'text/csv');
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handleExportDossierJson = async () => {
|
| 116 |
+
const json = await exportDossierJson(slug);
|
| 117 |
+
downloadFile(JSON.stringify(json, null, 2), `dossier_${slug}.json`, 'application/json');
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const handleExportDossierPdf = async () => {
|
| 121 |
+
if (!market) return;
|
| 122 |
+
|
| 123 |
+
// Dynamic import for jspdf
|
| 124 |
+
const { default: jsPDF } = await import('jspdf');
|
| 125 |
+
const autoTableModule = await import('jspdf-autotable');
|
| 126 |
+
|
| 127 |
+
const doc = new jsPDF();
|
| 128 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
| 129 |
+
|
| 130 |
+
// Title
|
| 131 |
+
doc.setFontSize(24);
|
| 132 |
+
doc.setTextColor(107, 33, 168);
|
| 133 |
+
doc.text('WagerKit Dossier', 20, 25);
|
| 134 |
+
|
| 135 |
+
// Subtitle
|
| 136 |
+
doc.setFontSize(10);
|
| 137 |
+
doc.setTextColor(150, 150, 150);
|
| 138 |
+
doc.text(`Generated: ${new Date().toLocaleString()}`, 20, 33);
|
| 139 |
+
|
| 140 |
+
// Horizontal line
|
| 141 |
+
doc.setDrawColor(200, 200, 200);
|
| 142 |
+
doc.line(20, 37, pageWidth - 20, 37);
|
| 143 |
+
|
| 144 |
+
// Market Title
|
| 145 |
+
doc.setFontSize(16);
|
| 146 |
+
doc.setTextColor(30, 30, 30);
|
| 147 |
+
doc.text(market.title, 20, 48);
|
| 148 |
+
|
| 149 |
+
// Market Info
|
| 150 |
+
doc.setFontSize(10);
|
| 151 |
+
doc.setTextColor(100, 100, 100);
|
| 152 |
+
doc.text(`Tag: ${market.tag} | Closes: ${market.closesAt}`, 20, 56);
|
| 153 |
+
doc.text(`Description: ${market.description}`, 20, 63, { maxWidth: pageWidth - 40 });
|
| 154 |
+
|
| 155 |
+
// Integrity Score Section
|
| 156 |
+
doc.setFontSize(14);
|
| 157 |
+
doc.setTextColor(30, 30, 30);
|
| 158 |
+
doc.text('Integrity Score', 20, 80);
|
| 159 |
+
|
| 160 |
+
// Overall Score
|
| 161 |
+
const scoreColor = market.integrityScore.overall >= 80 ? [34, 197, 94] :
|
| 162 |
+
market.integrityScore.overall >= 60 ? [59, 130, 246] :
|
| 163 |
+
market.integrityScore.overall >= 40 ? [234, 179, 8] : [239, 68, 68];
|
| 164 |
+
doc.setFontSize(28);
|
| 165 |
+
doc.setTextColor(scoreColor[0], scoreColor[1], scoreColor[2]);
|
| 166 |
+
doc.text(`${market.integrityScore.overall}`, pageWidth - 20, 80, { align: 'right' });
|
| 167 |
+
|
| 168 |
+
doc.setFontSize(8);
|
| 169 |
+
doc.setTextColor(150, 150, 150);
|
| 170 |
+
doc.text(getScoreLabel(market.integrityScore.overall), pageWidth - 20, 86, { align: 'right' });
|
| 171 |
+
|
| 172 |
+
// Score Components Table
|
| 173 |
+
const scoreData = [
|
| 174 |
+
['Market Clarity', `${market.integrityScore.marketClarity}%`, '40%'],
|
| 175 |
+
['Liquidity Depth', `${market.integrityScore.liquidityDepth}%`, '30%'],
|
| 176 |
+
['Cross-Source Agreement', `${market.integrityScore.crossSourceAgreement}%`, '20%'],
|
| 177 |
+
['Volatility Sanity', `${market.integrityScore.volatilitySanity}%`, '10%'],
|
| 178 |
+
];
|
| 179 |
+
|
| 180 |
+
(doc as any).autoTable({
|
| 181 |
+
startY: 92,
|
| 182 |
+
head: [['Component', 'Score', 'Weight']],
|
| 183 |
+
body: scoreData,
|
| 184 |
+
theme: 'grid',
|
| 185 |
+
headStyles: { fillColor: [107, 33, 168], textColor: 255, fontSize: 9 },
|
| 186 |
+
bodyStyles: { fontSize: 9 },
|
| 187 |
+
margin: { left: 20, right: 20 },
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
// Data Sources
|
| 191 |
+
const sourcesY = (doc as any).lastAutoTable.finalY + 15;
|
| 192 |
+
doc.setFontSize(14);
|
| 193 |
+
doc.setTextColor(30, 30, 30);
|
| 194 |
+
doc.text('Data Sources', 20, sourcesY);
|
| 195 |
+
|
| 196 |
+
const sourcesData = market.sources.map(s => [s.name, s.type]);
|
| 197 |
+
(doc as any).autoTable({
|
| 198 |
+
startY: sourcesY + 5,
|
| 199 |
+
head: [['Source', 'Type']],
|
| 200 |
+
body: sourcesData,
|
| 201 |
+
theme: 'grid',
|
| 202 |
+
headStyles: { fillColor: [107, 33, 168], textColor: 255, fontSize: 9 },
|
| 203 |
+
bodyStyles: { fontSize: 9 },
|
| 204 |
+
margin: { left: 20, right: 20 },
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
// Notes
|
| 208 |
+
const notesY = (doc as any).lastAutoTable.finalY + 15;
|
| 209 |
+
doc.setFontSize(14);
|
| 210 |
+
doc.setTextColor(30, 30, 30);
|
| 211 |
+
doc.text('Notes', 20, notesY);
|
| 212 |
+
doc.setFontSize(10);
|
| 213 |
+
doc.setTextColor(100, 100, 100);
|
| 214 |
+
market.notes.forEach((note, i) => {
|
| 215 |
+
doc.text(`• ${note}`, 25, notesY + 8 + i * 7);
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
// Footer
|
| 219 |
+
const footerY = doc.internal.pageSize.getHeight() - 15;
|
| 220 |
+
doc.setFontSize(8);
|
| 221 |
+
doc.setTextColor(180, 180, 180);
|
| 222 |
+
doc.text('WagerKit - Market Intelligence Platform', 20, footerY);
|
| 223 |
+
doc.text('Confidential', pageWidth - 20, footerY, { align: 'right' });
|
| 224 |
+
|
| 225 |
+
doc.save(`dossier_${slug}.pdf`);
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
if (loading) {
|
| 229 |
+
return (
|
| 230 |
+
<div className="page-gradient min-h-screen">
|
| 231 |
+
<Navbar />
|
| 232 |
+
<div className="flex items-center justify-center py-32">
|
| 233 |
+
<svg className="animate-spin h-8 w-8 text-purple-500" viewBox="0 0 24 24">
|
| 234 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
| 235 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
| 236 |
+
</svg>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
if (!market) {
|
| 243 |
+
return (
|
| 244 |
+
<div className="page-gradient min-h-screen">
|
| 245 |
+
<Navbar />
|
| 246 |
+
<div className="max-w-7xl mx-auto px-4 py-20 text-center">
|
| 247 |
+
<h2 className="text-2xl font-bold text-white mb-4">Market Not Found</h2>
|
| 248 |
+
<button onClick={() => router.push('/dashboard')} className="btn-primary">
|
| 249 |
+
Back to Dashboard
|
| 250 |
+
</button>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Chart data
|
| 257 |
+
const chartLabels = market.oddsHistory.map((p) => {
|
| 258 |
+
const d = new Date(p.timestamp);
|
| 259 |
+
return d.toLocaleTimeString('en-US', {
|
| 260 |
+
hour: 'numeric',
|
| 261 |
+
minute: '2-digit',
|
| 262 |
+
hour12: true,
|
| 263 |
+
});
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
// Only show every 8th label to avoid overcrowding
|
| 267 |
+
const sparseLabels = chartLabels.map((label, i) => (i % 8 === 0 ? label : ''));
|
| 268 |
+
|
| 269 |
+
const chartData = {
|
| 270 |
+
labels: sparseLabels,
|
| 271 |
+
datasets: [
|
| 272 |
+
{
|
| 273 |
+
label: 'Polymarket',
|
| 274 |
+
data: market.oddsHistory.map((p) => p.polymarket * 100),
|
| 275 |
+
borderColor: '#ffffff',
|
| 276 |
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
| 277 |
+
borderWidth: 2,
|
| 278 |
+
pointRadius: 0,
|
| 279 |
+
pointHoverRadius: 4,
|
| 280 |
+
tension: 0.3,
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
label: 'Kalshi',
|
| 284 |
+
data: market.oddsHistory.map((p) => p.kalshi * 100),
|
| 285 |
+
borderColor: '#3b82f6',
|
| 286 |
+
backgroundColor: 'rgba(59,130,246,0.05)',
|
| 287 |
+
borderWidth: 2,
|
| 288 |
+
pointRadius: 0,
|
| 289 |
+
pointHoverRadius: 4,
|
| 290 |
+
tension: 0.3,
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
label: 'PredictIt',
|
| 294 |
+
data: market.oddsHistory.map((p) => p.predictit * 100),
|
| 295 |
+
borderColor: '#22c55e',
|
| 296 |
+
backgroundColor: 'rgba(34,197,94,0.05)',
|
| 297 |
+
borderWidth: 2,
|
| 298 |
+
pointRadius: 0,
|
| 299 |
+
pointHoverRadius: 4,
|
| 300 |
+
tension: 0.3,
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
label: 'WagerKit',
|
| 304 |
+
data: market.oddsHistory.map((p) => p.wagerkit * 100),
|
| 305 |
+
borderColor: '#f59e0b',
|
| 306 |
+
backgroundColor: 'rgba(245,158,11,0.05)',
|
| 307 |
+
borderWidth: 2,
|
| 308 |
+
pointRadius: 0,
|
| 309 |
+
pointHoverRadius: 4,
|
| 310 |
+
tension: 0.3,
|
| 311 |
+
},
|
| 312 |
+
],
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
const chartOptions = {
|
| 316 |
+
responsive: true,
|
| 317 |
+
maintainAspectRatio: false,
|
| 318 |
+
interaction: {
|
| 319 |
+
mode: 'index' as const,
|
| 320 |
+
intersect: false,
|
| 321 |
+
},
|
| 322 |
+
plugins: {
|
| 323 |
+
legend: {
|
| 324 |
+
display: true,
|
| 325 |
+
position: 'bottom' as const,
|
| 326 |
+
labels: {
|
| 327 |
+
color: '#9ca3af',
|
| 328 |
+
usePointStyle: true,
|
| 329 |
+
pointStyle: 'circle',
|
| 330 |
+
padding: 20,
|
| 331 |
+
font: { size: 12 },
|
| 332 |
+
},
|
| 333 |
+
},
|
| 334 |
+
tooltip: {
|
| 335 |
+
backgroundColor: '#1e1e30',
|
| 336 |
+
borderColor: '#2a2a40',
|
| 337 |
+
borderWidth: 1,
|
| 338 |
+
titleColor: '#fff',
|
| 339 |
+
bodyColor: '#9ca3af',
|
| 340 |
+
padding: 12,
|
| 341 |
+
callbacks: {
|
| 342 |
+
label: (ctx: any) => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(2)}%`,
|
| 343 |
+
},
|
| 344 |
+
},
|
| 345 |
+
},
|
| 346 |
+
scales: {
|
| 347 |
+
x: {
|
| 348 |
+
grid: {
|
| 349 |
+
color: 'rgba(255,255,255,0.05)',
|
| 350 |
+
},
|
| 351 |
+
ticks: {
|
| 352 |
+
color: '#6b7280',
|
| 353 |
+
font: { size: 10 },
|
| 354 |
+
maxRotation: 45,
|
| 355 |
+
},
|
| 356 |
+
},
|
| 357 |
+
y: {
|
| 358 |
+
grid: {
|
| 359 |
+
color: 'rgba(255,255,255,0.05)',
|
| 360 |
+
},
|
| 361 |
+
ticks: {
|
| 362 |
+
color: '#6b7280',
|
| 363 |
+
font: { size: 10 },
|
| 364 |
+
callback: (value: any) => `${value}%`,
|
| 365 |
+
},
|
| 366 |
+
},
|
| 367 |
+
},
|
| 368 |
+
};
|
| 369 |
+
|
| 370 |
+
const score = market.integrityScore;
|
| 371 |
+
|
| 372 |
+
return (
|
| 373 |
+
<div className="page-gradient min-h-screen">
|
| 374 |
+
<Navbar />
|
| 375 |
+
|
| 376 |
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 377 |
+
{/* Back Button */}
|
| 378 |
+
<button
|
| 379 |
+
onClick={() => router.push('/dashboard')}
|
| 380 |
+
className="flex items-center gap-2 text-wk-muted hover:text-white transition-colors mb-6"
|
| 381 |
+
>
|
| 382 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 383 |
+
<polyline points="15,18 9,12 15,6" />
|
| 384 |
+
</svg>
|
| 385 |
+
Back
|
| 386 |
+
</button>
|
| 387 |
+
|
| 388 |
+
{/* Market Title */}
|
| 389 |
+
<h1 className="text-2xl md:text-3xl font-bold text-white mb-8">
|
| 390 |
+
{market.title}
|
| 391 |
+
</h1>
|
| 392 |
+
|
| 393 |
+
{/* ============= INTEGRITY SCORE CARD ============= */}
|
| 394 |
+
<div className="card mb-6">
|
| 395 |
+
<div className="flex items-start justify-between mb-6">
|
| 396 |
+
<div>
|
| 397 |
+
<div className="flex items-center gap-2 mb-1">
|
| 398 |
+
<span className="text-lg">💎</span>
|
| 399 |
+
<h2 className="text-xl font-semibold text-white">Integrity Score</h2>
|
| 400 |
+
</div>
|
| 401 |
+
<p className="text-sm text-wk-muted">
|
| 402 |
+
Multi-factor market integrity analysis
|
| 403 |
+
</p>
|
| 404 |
+
</div>
|
| 405 |
+
<div className="text-right">
|
| 406 |
+
<div className={`text-5xl font-bold ${getScoreColor(score.overall)}`}>
|
| 407 |
+
{score.overall.toFixed(1)}
|
| 408 |
+
</div>
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
|
| 412 |
+
{/* Sub-scores */}
|
| 413 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-6">
|
| 414 |
+
{[
|
| 415 |
+
{ label: 'Market Clarity', value: score.marketClarity },
|
| 416 |
+
{ label: 'Liquidity Depth', value: score.liquidityDepth },
|
| 417 |
+
{ label: 'Cross-Source Agreement', value: score.crossSourceAgreement },
|
| 418 |
+
{ label: 'Volatility Sanity', value: score.volatilitySanity },
|
| 419 |
+
].map((item) => (
|
| 420 |
+
<div key={item.label}>
|
| 421 |
+
<p className="text-sm text-wk-muted mb-1">{item.label}</p>
|
| 422 |
+
<p className="text-2xl font-bold text-white">{item.value}%</p>
|
| 423 |
+
</div>
|
| 424 |
+
))}
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
{/* Notes */}
|
| 428 |
+
<div>
|
| 429 |
+
<h3 className="text-sm font-semibold text-white mb-3">Notes</h3>
|
| 430 |
+
<div className="flex flex-wrap gap-2">
|
| 431 |
+
{market.notes.map((note, i) => (
|
| 432 |
+
<span
|
| 433 |
+
key={i}
|
| 434 |
+
className={i % 2 === 0 ? 'note-tag note-tag-green' : 'note-tag note-tag-blue'}
|
| 435 |
+
>
|
| 436 |
+
{note}
|
| 437 |
+
</span>
|
| 438 |
+
))}
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
{/* ============= ODDS HISTORY CHART ============= */}
|
| 444 |
+
<div className="card mb-6">
|
| 445 |
+
<div className="flex items-start justify-between mb-4">
|
| 446 |
+
<div>
|
| 447 |
+
<h2 className="text-xl font-semibold text-white mb-1">Odds History</h2>
|
| 448 |
+
<p className="text-sm text-wk-muted">
|
| 449 |
+
Historical odds trends over the last 24 hours
|
| 450 |
+
</p>
|
| 451 |
+
</div>
|
| 452 |
+
<button
|
| 453 |
+
onClick={handleExportOddsCsv}
|
| 454 |
+
className="btn-outline text-sm flex items-center gap-2"
|
| 455 |
+
>
|
| 456 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 457 |
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
| 458 |
+
<polyline points="7,10 12,15 17,10" />
|
| 459 |
+
<line x1="12" y1="15" x2="12" y2="3" />
|
| 460 |
+
</svg>
|
| 461 |
+
Export CSV
|
| 462 |
+
</button>
|
| 463 |
+
</div>
|
| 464 |
+
|
| 465 |
+
<div className="h-80">
|
| 466 |
+
<Line data={chartData} options={chartOptions} />
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
|
| 470 |
+
{/* ============= LATEST ODDS ============= */}
|
| 471 |
+
<div className="card mb-6">
|
| 472 |
+
<h2 className="text-xl font-semibold text-white mb-1">Latest Odds</h2>
|
| 473 |
+
<p className="text-sm text-wk-muted mb-6">
|
| 474 |
+
Real-time odds from all sources
|
| 475 |
+
</p>
|
| 476 |
+
|
| 477 |
+
<div className="flex flex-col items-center justify-center py-10 text-center">
|
| 478 |
+
<svg
|
| 479 |
+
width="40"
|
| 480 |
+
height="40"
|
| 481 |
+
viewBox="0 0 24 24"
|
| 482 |
+
fill="none"
|
| 483 |
+
stroke="#4b5563"
|
| 484 |
+
strokeWidth="1.5"
|
| 485 |
+
className="mb-3"
|
| 486 |
+
>
|
| 487 |
+
<circle cx="12" cy="12" r="10" />
|
| 488 |
+
<polyline points="12,6 12,12 16,14" />
|
| 489 |
+
</svg>
|
| 490 |
+
<p className="text-wk-muted font-medium mb-1">No Recent Odds</p>
|
| 491 |
+
<p className="text-sm text-gray-600">
|
| 492 |
+
We haven't received any recent price ticks from the source feeds.
|
| 493 |
+
</p>
|
| 494 |
+
</div>
|
| 495 |
+
</div>
|
| 496 |
+
|
| 497 |
+
{/* ============= DATA SOURCES ============= */}
|
| 498 |
+
<div className="card mb-6" style={{
|
| 499 |
+
background: 'linear-gradient(180deg, #12121e 0%, #0d0d18 50%, #12121e 100%)',
|
| 500 |
+
}}>
|
| 501 |
+
<h2 className="text-xl font-semibold text-white mb-1">Data Sources</h2>
|
| 502 |
+
<p className="text-sm text-wk-muted mb-6">
|
| 503 |
+
Sources providing odds for this market
|
| 504 |
+
</p>
|
| 505 |
+
|
| 506 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 507 |
+
{market.sources.map((source) => (
|
| 508 |
+
<div
|
| 509 |
+
key={source.name}
|
| 510 |
+
className="border border-wk-border rounded-xl p-5 flex items-center justify-between bg-wk-card/50"
|
| 511 |
+
>
|
| 512 |
+
<div>
|
| 513 |
+
<p className="text-white font-semibold">{source.name}</p>
|
| 514 |
+
<p className="text-sm text-wk-muted">{source.type}</p>
|
| 515 |
+
</div>
|
| 516 |
+
<span
|
| 517 |
+
className={`text-xs px-3 py-1 rounded-full font-medium ${
|
| 518 |
+
source.type === 'regulated'
|
| 519 |
+
? 'bg-gray-800 text-gray-300 border border-gray-600'
|
| 520 |
+
: 'bg-purple-900/50 text-purple-300 border border-purple-700'
|
| 521 |
+
}`}
|
| 522 |
+
>
|
| 523 |
+
{source.type}
|
| 524 |
+
</span>
|
| 525 |
+
</div>
|
| 526 |
+
))}
|
| 527 |
+
</div>
|
| 528 |
+
</div>
|
| 529 |
+
|
| 530 |
+
{/* ============= ACTIONS ============= */}
|
| 531 |
+
<div className="card mb-10">
|
| 532 |
+
<h2 className="text-xl font-semibold text-white mb-6">Actions</h2>
|
| 533 |
+
|
| 534 |
+
<div className="flex flex-wrap gap-4">
|
| 535 |
+
<button
|
| 536 |
+
onClick={handleExportDossierPdf}
|
| 537 |
+
className="btn-outline flex items-center gap-2 px-5 py-3"
|
| 538 |
+
>
|
| 539 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 540 |
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
| 541 |
+
<polyline points="7,10 12,15 17,10" />
|
| 542 |
+
<line x1="12" y1="15" x2="12" y2="3" />
|
| 543 |
+
</svg>
|
| 544 |
+
Download Dossier (PDF)
|
| 545 |
+
</button>
|
| 546 |
+
|
| 547 |
+
<button
|
| 548 |
+
onClick={handleExportDossierJson}
|
| 549 |
+
className="btn-outline flex items-center gap-2 px-5 py-3"
|
| 550 |
+
>
|
| 551 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 552 |
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
| 553 |
+
<polyline points="7,10 12,15 17,10" />
|
| 554 |
+
<line x1="12" y1="15" x2="12" y2="3" />
|
| 555 |
+
</svg>
|
| 556 |
+
Download Dossier (JSON)
|
| 557 |
+
</button>
|
| 558 |
+
|
| 559 |
+
<button
|
| 560 |
+
onClick={handleExportIntegrityCsv}
|
| 561 |
+
className="btn-outline flex items-center gap-2 px-5 py-3"
|
| 562 |
+
>
|
| 563 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 564 |
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 565 |
+
<line x1="3" y1="9" x2="21" y2="9" />
|
| 566 |
+
<line x1="3" y1="15" x2="21" y2="15" />
|
| 567 |
+
<line x1="9" y1="3" x2="9" y2="21" />
|
| 568 |
+
</svg>
|
| 569 |
+
Export Integrity CSV
|
| 570 |
+
</button>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
</main>
|
| 574 |
+
</div>
|
| 575 |
+
);
|
| 576 |
+
}
|
frontend/src/app/page.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect } from 'react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
|
| 6 |
+
export default function Home() {
|
| 7 |
+
const router = useRouter();
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
router.replace('/dashboard');
|
| 11 |
+
}, [router]);
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="page-gradient flex items-center justify-center min-h-screen">
|
| 15 |
+
<div className="animate-pulse text-wk-muted text-lg">Loading...</div>
|
| 16 |
+
</div>
|
| 17 |
+
);
|
| 18 |
+
}
|
frontend/src/components/Navbar.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { usePathname } from 'next/navigation';
|
| 5 |
+
import { useState } from 'react';
|
| 6 |
+
|
| 7 |
+
export default function Navbar() {
|
| 8 |
+
const pathname = usePathname();
|
| 9 |
+
const [user] = useState({ username: 'demo' });
|
| 10 |
+
|
| 11 |
+
const navItems = [
|
| 12 |
+
{ label: 'Dashboard', href: '/dashboard' },
|
| 13 |
+
{ label: 'Markets', href: '/dashboard' },
|
| 14 |
+
{ label: 'Alerts', href: '#' },
|
| 15 |
+
{ label: 'Metrics', href: '#' },
|
| 16 |
+
{ label: 'Paper Mode', href: '#' },
|
| 17 |
+
{ label: 'API', href: '#' },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<nav className="border-b border-wk-border bg-wk-bg/80 backdrop-blur-md sticky top-0 z-50">
|
| 22 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 23 |
+
<div className="flex items-center justify-between h-16">
|
| 24 |
+
{/* Logo */}
|
| 25 |
+
<Link href="/dashboard" className="flex items-center gap-2">
|
| 26 |
+
<svg
|
| 27 |
+
width="28"
|
| 28 |
+
height="28"
|
| 29 |
+
viewBox="0 0 32 32"
|
| 30 |
+
fill="none"
|
| 31 |
+
className="text-wk-accent"
|
| 32 |
+
>
|
| 33 |
+
<path
|
| 34 |
+
d="M16 2L4 8v16l12 6 12-6V8L16 2z"
|
| 35 |
+
stroke="currentColor"
|
| 36 |
+
strokeWidth="2"
|
| 37 |
+
fill="none"
|
| 38 |
+
/>
|
| 39 |
+
<path
|
| 40 |
+
d="M16 10l-6 3v6l6 3 6-3v-6l-6-3z"
|
| 41 |
+
fill="currentColor"
|
| 42 |
+
opacity="0.6"
|
| 43 |
+
/>
|
| 44 |
+
</svg>
|
| 45 |
+
<span className="text-xl font-bold text-white">WagerKit</span>
|
| 46 |
+
</Link>
|
| 47 |
+
|
| 48 |
+
{/* Nav Items */}
|
| 49 |
+
<div className="hidden md:flex items-center gap-1">
|
| 50 |
+
{navItems.map((item) => (
|
| 51 |
+
<Link
|
| 52 |
+
key={item.label}
|
| 53 |
+
href={item.href}
|
| 54 |
+
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
| 55 |
+
pathname === item.href
|
| 56 |
+
? 'text-white bg-white/10'
|
| 57 |
+
: 'text-wk-muted hover:text-white hover:bg-white/5'
|
| 58 |
+
}`}
|
| 59 |
+
>
|
| 60 |
+
{item.label}
|
| 61 |
+
</Link>
|
| 62 |
+
))}
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{/* User Menu */}
|
| 66 |
+
<div className="flex items-center gap-3">
|
| 67 |
+
{user && (
|
| 68 |
+
<div className="flex items-center gap-2">
|
| 69 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-wk-accent to-wk-blue flex items-center justify-center text-sm font-bold text-white">
|
| 70 |
+
{user.username?.charAt(0).toUpperCase() || 'U'}
|
| 71 |
+
</div>
|
| 72 |
+
<span className="text-sm text-wk-muted hidden sm:block">
|
| 73 |
+
{user.username}
|
| 74 |
+
</span>
|
| 75 |
+
</div>
|
| 76 |
+
)}
|
| 77 |
+
<button
|
| 78 |
+
onClick={() => {}}
|
| 79 |
+
className="text-wk-muted hover:text-white transition-colors"
|
| 80 |
+
title="Settings"
|
| 81 |
+
>
|
| 82 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 83 |
+
<circle cx="12" cy="12" r="3" />
|
| 84 |
+
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
| 85 |
+
</svg>
|
| 86 |
+
</button>
|
| 87 |
+
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</nav>
|
| 92 |
+
);
|
| 93 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
| 2 |
+
|
| 3 |
+
function getToken(): string | null {
|
| 4 |
+
if (typeof window === 'undefined') return null;
|
| 5 |
+
return localStorage.getItem('wk_token');
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
async function apiFetch(path: string, options: RequestInit = {}) {
|
| 9 |
+
const headers: Record<string, string> = {
|
| 10 |
+
'Content-Type': 'application/json',
|
| 11 |
+
...(options.headers as Record<string, string>),
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const res = await fetch(`${API_URL}${path}`, {
|
| 15 |
+
...options,
|
| 16 |
+
headers,
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
if (!res.ok) {
|
| 20 |
+
const error = await res.json().catch(() => ({ message: 'Request failed' }));
|
| 21 |
+
throw new Error(error.message || 'Request failed');
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return res;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export async function login(username: string, password: string) {
|
| 28 |
+
const res = await fetch(`${API_URL}/auth/login`, {
|
| 29 |
+
method: 'POST',
|
| 30 |
+
headers: { 'Content-Type': 'application/json' },
|
| 31 |
+
body: JSON.stringify({ username, password }),
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
if (!res.ok) {
|
| 35 |
+
const err = await res.json().catch(() => ({ message: 'Login failed' }));
|
| 36 |
+
throw new Error(err.message || 'Login failed');
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const data = await res.json();
|
| 40 |
+
localStorage.setItem('wk_token', data.access_token);
|
| 41 |
+
localStorage.setItem('wk_user', JSON.stringify(data.user));
|
| 42 |
+
// Set cookie for middleware
|
| 43 |
+
document.cookie = `wk_auth=${data.access_token}; path=/; max-age=86400`;
|
| 44 |
+
return data;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function logout() {
|
| 48 |
+
localStorage.removeItem('wk_token');
|
| 49 |
+
localStorage.removeItem('wk_user');
|
| 50 |
+
document.cookie = 'wk_auth=; path=/; max-age=0';
|
| 51 |
+
window.location.href = '/login';
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function getUser() {
|
| 55 |
+
if (typeof window === 'undefined') return null;
|
| 56 |
+
const raw = localStorage.getItem('wk_user');
|
| 57 |
+
return raw ? JSON.parse(raw) : null;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export async function getMarkets() {
|
| 61 |
+
const res = await apiFetch('/markets');
|
| 62 |
+
return res.json();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export async function getMarketDetail(slug: string) {
|
| 66 |
+
const res = await apiFetch(`/markets/${slug}`);
|
| 67 |
+
return res.json();
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export async function exportOddsCsv(slug: string) {
|
| 71 |
+
const res = await fetch(`${API_URL}/markets/${slug}/export/odds-csv`);
|
| 72 |
+
return res.text();
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export async function exportIntegrityCsv(slug: string) {
|
| 76 |
+
const res = await fetch(`${API_URL}/markets/${slug}/export/integrity-csv`);
|
| 77 |
+
return res.text();
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export async function exportDossierJson(slug: string) {
|
| 81 |
+
const res = await fetch(`${API_URL}/markets/${slug}/export/dossier-json`);
|
| 82 |
+
return res.json();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export async function refreshMarket(slug: string) {
|
| 86 |
+
const res = await apiFetch(`/markets/${slug}/refresh`, { method: 'POST' });
|
| 87 |
+
return res.json();
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export async function getJobsStatus() {
|
| 91 |
+
const res = await apiFetch('/markets/jobs/status');
|
| 92 |
+
return res.json();
|
| 93 |
+
}
|
frontend/src/middleware.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import type { NextRequest } from 'next/server';
|
| 3 |
+
|
| 4 |
+
export function middleware(request: NextRequest) {
|
| 5 |
+
// No authentication required for demo
|
| 6 |
+
return NextResponse.next();
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const config = {
|
| 10 |
+
matcher: ['/dashboard/:path*', '/market/:path*'],
|
| 11 |
+
};
|
frontend/tailwind.config.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from 'tailwindcss';
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
content: [
|
| 5 |
+
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
| 6 |
+
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
| 7 |
+
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
| 8 |
+
],
|
| 9 |
+
theme: {
|
| 10 |
+
extend: {
|
| 11 |
+
colors: {
|
| 12 |
+
wk: {
|
| 13 |
+
bg: '#080810',
|
| 14 |
+
card: '#12121e',
|
| 15 |
+
border: '#1e1e30',
|
| 16 |
+
accent: '#6b21a8',
|
| 17 |
+
blue: '#3b82f6',
|
| 18 |
+
green: '#22c55e',
|
| 19 |
+
text: '#ffffff',
|
| 20 |
+
muted: '#9ca3af',
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
plugins: [],
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export default config;
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "es5",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": false,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "preserve",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [{ "name": "next" }],
|
| 17 |
+
"paths": { "@/*": ["./src/*"] }
|
| 18 |
+
},
|
| 19 |
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
| 20 |
+
"exclude": ["node_modules"]
|
| 21 |
+
}
|