saadrizvi09 commited on
Commit
b2806e8
·
0 Parent(s):
.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&apos;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
+ }