feat: AI-powered prediction market (Hopium)

This commit is contained in:
Face 2025-05-28 16:44:30 +03:00
parent 4fcc55fa72
commit 2a92c37d26
33 changed files with 7009 additions and 4518 deletions

View file

@ -0,0 +1,134 @@
import { auth } from '$lib/auth';
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { predictionQuestion, user, predictionBet } from '$lib/server/db/schema';
import { eq, desc, and, sum, count } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, request }) => {
const statusParam = url.searchParams.get('status') || 'ACTIVE';
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '20');
const validStatuses = ['ACTIVE', 'RESOLVED', 'CANCELLED', 'ALL'];
if (!validStatuses.includes(statusParam)) {
return json({ error: 'Invalid status parameter. Must be one of: ACTIVE, RESOLVED, CANCELLED, ALL' }, { status: 400 });
}
const status = statusParam;
if (Number.isNaN(page) || page < 1 || Number.isNaN(limit) || limit < 1 || limit > 100) {
return json({ error: 'Invalid pagination parameters' }, { status: 400 });
}
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user ? Number(session.user.id) : null;
try {
const conditions = [];
if (status !== 'ALL') {
conditions.push(eq(predictionQuestion.status, status as any));
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined;
const [[{ total }], questions] = await Promise.all([
db.select({ total: count() }).from(predictionQuestion).where(whereCondition),
db.select({
id: predictionQuestion.id,
question: predictionQuestion.question,
status: predictionQuestion.status,
resolutionDate: predictionQuestion.resolutionDate,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
createdAt: predictionQuestion.createdAt,
resolvedAt: predictionQuestion.resolvedAt,
requiresWebSearch: predictionQuestion.requiresWebSearch,
aiResolution: predictionQuestion.aiResolution,
creatorId: predictionQuestion.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorImage: user.image,
})
.from(predictionQuestion)
.leftJoin(user, eq(predictionQuestion.creatorId, user.id))
.where(whereCondition)
.orderBy(desc(predictionQuestion.createdAt))
.limit(limit)
.offset((page - 1) * limit)
]);
let userBetsMap = new Map();
if (userId && questions.length > 0) {
const questionIds = questions.map(q => q.id);
const userBets = await db
.select({
questionId: predictionBet.questionId,
side: predictionBet.side,
totalAmount: sum(predictionBet.amount),
})
.from(predictionBet)
.where(and(
eq(predictionBet.userId, userId),
))
.groupBy(predictionBet.questionId, predictionBet.side);
userBets
.filter(bet => questionIds.includes(bet.questionId))
.forEach(bet => {
if (!userBetsMap.has(bet.questionId)) {
userBetsMap.set(bet.questionId, { yesAmount: 0, noAmount: 0 });
}
const bets = userBetsMap.get(bet.questionId);
if (bet.side) {
bets.yesAmount = Number(bet.totalAmount);
} else {
bets.noAmount = Number(bet.totalAmount);
}
});
}
const formattedQuestions = questions.map(q => {
const totalAmount = Number(q.totalYesAmount) + Number(q.totalNoAmount);
const yesPercentage = totalAmount > 0 ? (Number(q.totalYesAmount) / totalAmount) * 100 : 50;
const noPercentage = totalAmount > 0 ? (Number(q.totalNoAmount) / totalAmount) * 100 : 50;
const userBets = userBetsMap.get(q.id) || null;
return {
id: q.id,
question: q.question,
status: q.status,
resolutionDate: q.resolutionDate,
totalAmount,
yesAmount: Number(q.totalYesAmount),
noAmount: Number(q.totalNoAmount),
yesPercentage,
noPercentage,
createdAt: q.createdAt,
resolvedAt: q.resolvedAt,
requiresWebSearch: q.requiresWebSearch,
aiResolution: q.aiResolution,
creator: {
id: q.creatorId,
name: q.creatorName,
username: q.creatorUsername,
image: q.creatorImage,
},
userBets
};
});
const totalCount = Number(total) || 0;
return json({
questions: formattedQuestions,
total: totalCount,
page,
limit,
totalPages: Math.ceil(totalCount / limit)
});
} catch (e) {
console.error('Error fetching questions:', e);
return json({ error: 'Failed to fetch questions' }, { status: 500 });
}
};

View file

@ -0,0 +1,206 @@
import { auth } from '$lib/auth';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { predictionQuestion, user, predictionBet } from '$lib/server/db/schema';
import { eq, desc, sum, and, asc } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, request }) => {
const questionId = parseInt(params.id!);
if (isNaN(questionId)) {
throw error(400, 'Invalid question ID');
}
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user ? Number(session.user.id) : null;
try {
// Fetch question with creator info
const [questionData] = await db
.select({
id: predictionQuestion.id,
question: predictionQuestion.question,
status: predictionQuestion.status,
resolutionDate: predictionQuestion.resolutionDate,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
createdAt: predictionQuestion.createdAt,
resolvedAt: predictionQuestion.resolvedAt,
requiresWebSearch: predictionQuestion.requiresWebSearch,
aiResolution: predictionQuestion.aiResolution,
creatorId: predictionQuestion.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorImage: user.image,
})
.from(predictionQuestion)
.leftJoin(user, eq(predictionQuestion.creatorId, user.id))
.where(eq(predictionQuestion.id, questionId))
.limit(1);
if (!questionData) {
throw error(404, 'Question not found');
}
const totalAmount = Number(questionData.totalYesAmount) + Number(questionData.totalNoAmount);
const yesPercentage = totalAmount > 0 ? (Number(questionData.totalYesAmount) / totalAmount) * 100 : 50;
const noPercentage = totalAmount > 0 ? (Number(questionData.totalNoAmount) / totalAmount) * 100 : 50;
// Fetch recent bets (last 10)
const recentBets = await db
.select({
id: predictionBet.id,
side: predictionBet.side,
amount: predictionBet.amount,
createdAt: predictionBet.createdAt,
userId: predictionBet.userId,
userName: user.name,
userUsername: user.username,
userImage: user.image,
})
.from(predictionBet)
.leftJoin(user, eq(predictionBet.userId, user.id))
.where(eq(predictionBet.questionId, questionId))
.orderBy(desc(predictionBet.createdAt))
.limit(10);
// Fetch probability history for the chart
const probabilityHistory = await db
.select({
createdAt: predictionBet.createdAt,
side: predictionBet.side,
amount: predictionBet.amount,
})
.from(predictionBet)
.where(eq(predictionBet.questionId, questionId))
.orderBy(asc(predictionBet.createdAt));
// Calculate probability over time
let runningYesTotal = 0;
let runningNoTotal = 0;
const probabilityData: Array<{ time: number; value: number }> = [];
// Add initial point at 50%
if (probabilityHistory.length > 0) {
const firstBetTime = Math.floor(new Date(probabilityHistory[0].createdAt).getTime() / 1000);
probabilityData.push({
time: firstBetTime - 3600, // 1 hour before first bet
value: 50
});
}
for (const bet of probabilityHistory) {
if (bet.side) {
runningYesTotal += Number(bet.amount);
} else {
runningNoTotal += Number(bet.amount);
}
const total = runningYesTotal + runningNoTotal;
const yesPercentage = total > 0 ? (runningYesTotal / total) * 100 : 50;
probabilityData.push({
time: Math.floor(new Date(bet.createdAt).getTime() / 1000),
value: Number(yesPercentage.toFixed(1))
});
}
// Add current point if no recent bets
if (probabilityData.length > 0) {
const lastPoint = probabilityData[probabilityData.length - 1];
const currentTime = Math.floor(Date.now() / 1000);
// Only add current point if last bet was more than 1 hour ago
if (currentTime - lastPoint.time > 3600) {
probabilityData.push({
time: currentTime,
value: Number(yesPercentage.toFixed(1))
});
}
}
let userBets = null;
if (userId) {
// Fetch user's betting data
const userBetData = await db
.select({
side: predictionBet.side,
totalAmount: sum(predictionBet.amount),
})
.from(predictionBet)
.where(and(
eq(predictionBet.questionId, questionId),
eq(predictionBet.userId, userId)
))
.groupBy(predictionBet.side);
const yesAmount = userBetData.find(bet => bet.side === true)?.totalAmount || 0;
const noAmount = userBetData.find(bet => bet.side === false)?.totalAmount || 0;
const userTotalAmount = Number(yesAmount) + Number(noAmount);
if (userTotalAmount > 0) {
// Calculate estimated winnings
const estimatedYesWinnings = Number(yesAmount) > 0
? (totalAmount / Number(questionData.totalYesAmount)) * Number(yesAmount)
: 0;
const estimatedNoWinnings = Number(noAmount) > 0
? (totalAmount / Number(questionData.totalNoAmount)) * Number(noAmount)
: 0;
userBets = {
yesAmount: Number(yesAmount),
noAmount: Number(noAmount),
totalAmount: userTotalAmount,
estimatedYesWinnings,
estimatedNoWinnings,
};
}
}
const formattedQuestion = {
id: questionData.id,
question: questionData.question,
status: questionData.status,
resolutionDate: questionData.resolutionDate,
totalAmount,
yesAmount: Number(questionData.totalYesAmount),
noAmount: Number(questionData.totalNoAmount),
yesPercentage,
noPercentage,
createdAt: questionData.createdAt,
resolvedAt: questionData.resolvedAt,
requiresWebSearch: questionData.requiresWebSearch,
aiResolution: questionData.aiResolution,
creator: {
id: questionData.creatorId,
name: questionData.creatorName,
username: questionData.creatorUsername,
image: questionData.creatorImage,
},
userBets,
recentBets: recentBets.map(bet => ({
id: bet.id,
side: bet.side,
amount: Number(bet.amount),
createdAt: bet.createdAt,
user: {
id: bet.userId,
name: bet.userName,
username: bet.userUsername,
image: bet.userImage,
}
}))
};
return json({
question: formattedQuestion,
probabilityHistory: probabilityData
});
} catch (e) {
console.error('Error fetching question:', e);
if (e instanceof Error && e.message.includes('404')) {
throw error(404, 'Question not found');
}
return json({ error: 'Failed to fetch question' }, { status: 500 });
}
};

View file

@ -0,0 +1,119 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, predictionQuestion, predictionBet } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const questionId = parseInt(params.id!);
const { side, amount } = await request.json();
if (typeof side !== 'boolean' || !amount || amount <= 0) {
return json({ error: 'Invalid bet parameters' }, { status: 400 });
}
const userId = Number(session.user.id);
try {
return await db.transaction(async (tx) => {
// Check question exists and is active
const [questionData] = await tx
.select({
id: predictionQuestion.id,
status: predictionQuestion.status,
resolutionDate: predictionQuestion.resolutionDate,
totalYesAmount: predictionQuestion.totalYesAmount,
totalNoAmount: predictionQuestion.totalNoAmount,
})
.from(predictionQuestion)
.where(eq(predictionQuestion.id, questionId))
.for('update')
.limit(1);
if (!questionData) {
throw new Error('Question not found');
}
if (questionData.status !== 'ACTIVE') {
throw new Error('Question is not active for betting');
}
if (new Date() >= new Date(questionData.resolutionDate)) {
throw new Error('Question has reached resolution date');
}
// Check user balance
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
if (!userData || Number(userData.baseCurrencyBalance) < amount) {
throw new Error('Insufficient balance');
}
// Deduct amount from user balance
await tx
.update(user)
.set({
baseCurrencyBalance: (Number(userData.baseCurrencyBalance) - amount).toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
const [newBet] = await tx
.insert(predictionBet)
.values({
userId,
questionId,
side,
amount: amount.toFixed(8),
})
.returning();
// Update question totals
const currentYesAmount = Number(questionData.totalYesAmount);
const currentNoAmount = Number(questionData.totalNoAmount);
await tx
.update(predictionQuestion)
.set({
totalYesAmount: side
? (currentYesAmount + amount).toFixed(8)
: currentYesAmount.toFixed(8),
totalNoAmount: !side
? (currentNoAmount + amount).toFixed(8)
: currentNoAmount.toFixed(8),
})
.where(eq(predictionQuestion.id, questionId));
// Calculate current potential winnings for response (dynamic)
const newTotalYes = side ? currentYesAmount + amount : currentYesAmount;
const newTotalNo = !side ? currentNoAmount + amount : currentNoAmount;
const totalPool = newTotalYes + newTotalNo;
const currentPotentialWinnings = side
? (totalPool / newTotalYes) * amount
: (totalPool / newTotalNo) * amount;
return json({
success: true,
bet: {
id: newBet.id,
side,
amount,
potentialWinnings: currentPotentialWinnings,
},
newBalance: Number(userData.baseCurrencyBalance) - amount
});
});
} catch (e) {
console.error('Betting error:', e);
return json({ error: (e as Error).message }, { status: 400 });
}
};

View file

@ -0,0 +1,108 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, predictionQuestion } from '$lib/server/db/schema';
import { eq, and, gte, count } from 'drizzle-orm';
import { validateQuestion } from '$lib/server/ai';
import type { RequestHandler } from './$types';
const MIN_BALANCE_REQUIRED = 100000; // $100k
const MAX_QUESTIONS_PER_HOUR = 2;
const MIN_RESOLUTION_HOURS = 1;
const MAX_RESOLUTION_DAYS = 30;
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const { question } = await request.json();
const cleaned = (question ?? '').trim();
if (cleaned.length < 10 || cleaned.length > 200) {
return json({ error: 'Question must be between 10 and 200 characters' }, { status: 400 });
}
const userId = Number(session.user.id);
const now = new Date();
try {
return await db.transaction(async (tx) => {
// Check user balance
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (!userData || Number(userData.baseCurrencyBalance) < MIN_BALANCE_REQUIRED) {
throw new Error(`You need at least $${MIN_BALANCE_REQUIRED.toLocaleString()} to create questions`);
}
// Check hourly creation limit
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [recentQuestions] = await tx
.select({ count: count() })
.from(predictionQuestion)
.where(and(
eq(predictionQuestion.creatorId, userId),
gte(predictionQuestion.createdAt, oneHourAgo)
));
if (Number(recentQuestions.count) >= MAX_QUESTIONS_PER_HOUR) {
throw new Error(`You can only create ${MAX_QUESTIONS_PER_HOUR} questions per hour`);
}
const validation = await validateQuestion(question);
if (!validation.isValid) {
throw new Error(`Question validation failed: ${validation.reason}`);
}
// Use AI suggested date or default fallback
let finalResolutionDate: Date;
if (validation.suggestedResolutionDate && !isNaN(validation.suggestedResolutionDate.getTime())) {
finalResolutionDate = validation.suggestedResolutionDate;
} else {
// Fallback: 24 hours from now
finalResolutionDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
console.warn('Using fallback resolution date (24h), AI suggested:', validation.suggestedResolutionDate);
}
// Validate the final date is within acceptable bounds
const minResolutionDate = new Date(now.getTime() + MIN_RESOLUTION_HOURS * 60 * 60 * 1000);
const maxResolutionDate = new Date(now.getTime() + MAX_RESOLUTION_DAYS * 24 * 60 * 60 * 1000);
if (finalResolutionDate < minResolutionDate) {
finalResolutionDate = minResolutionDate;
} else if (finalResolutionDate > maxResolutionDate) {
finalResolutionDate = maxResolutionDate;
}
// Create question
const [newQuestion] = await tx
.insert(predictionQuestion)
.values({
creatorId: userId,
question: question.trim(),
resolutionDate: finalResolutionDate,
requiresWebSearch: validation.requiresWebSearch,
validationReason: validation.reason,
})
.returning();
return json({
success: true,
question: {
id: newQuestion.id,
question: newQuestion.question,
resolutionDate: newQuestion.resolutionDate,
requiresWebSearch: newQuestion.requiresWebSearch
}
});
});
} catch (e) {
console.error('Question creation error:', e);
return json({ error: (e as Error).message }, { status: 400 });
}
};