diff --git a/backend/add_bets.py b/backend/add_bets.py new file mode 100644 index 0000000..388b5ed --- /dev/null +++ b/backend/add_bets.py @@ -0,0 +1,174 @@ +""" +Add 100 spread bets to the Wake Forest vs MS State game +""" +import asyncio +import random +from datetime import datetime +from decimal import Decimal +from sqlalchemy import select + +from app.database import async_session +from app.models import User, Wallet, SportEvent, SpreadBet +from app.models.spread_bet import TeamSide, SpreadBetStatus +from app.utils.security import get_password_hash + + +# Fake names for generating users +FIRST_NAMES = [ + "James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Oliver", "Sophia", + "Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan", + "Amelia", "Logan", "Harper", "Aiden", "Evelyn", "Jackson", "Luna", + "Sebastian", "Camila", "Henry", "Gianna", "Alexander", "Abigail" +] + +LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Rodriguez", "Martinez", "Wilson", "Anderson", "Taylor", + "Thomas", "Moore", "Jackson", "Martin", "Lee", "Thompson", "White" +] + + +async def create_users_if_needed(db, count: int) -> list[User]: + """Create additional test users if needed""" + # Get existing users + result = await db.execute(select(User).where(User.is_admin == False)) + existing_users = list(result.scalars().all()) + + if len(existing_users) >= count: + return existing_users[:count] + + # Create more users + users_needed = count - len(existing_users) + print(f"Creating {users_needed} additional test users...") + + new_users = [] + for i in range(users_needed): + first = random.choice(FIRST_NAMES) + last = random.choice(LAST_NAMES) + username = f"{first.lower()}{last.lower()}{random.randint(1, 999)}" + email = f"{username}@example.com" + + # Check if user exists + result = await db.execute(select(User).where(User.email == email)) + if result.scalar_one_or_none(): + continue + + user = User( + email=email, + username=username, + password_hash=get_password_hash("password123"), + display_name=f"{first} {last}" + ) + db.add(user) + await db.flush() + + # Create wallet with random balance + wallet = Wallet( + user_id=user.id, + balance=Decimal(str(random.randint(500, 5000))), + escrow=Decimal("0.00") + ) + db.add(wallet) + new_users.append(user) + + await db.commit() + + # Re-fetch all users + result = await db.execute(select(User).where(User.is_admin == False)) + return list(result.scalars().all()) + + +async def add_bets(): + """Add 100 spread bets to Wake Forest vs MS State game""" + async with async_session() as db: + # Find the Wake Forest vs MS State event + result = await db.execute( + select(SportEvent).where( + SportEvent.home_team == "Wake Forest", + SportEvent.away_team == "MS State" + ) + ) + event = result.scalar_one_or_none() + + if not event: + print("Error: Wake Forest vs MS State event not found!") + print("Please run init_spread_betting.py first") + return + + print(f"Found event: {event.home_team} vs {event.away_team} (ID: {event.id})") + print(f"Official spread: {event.official_spread}") + print(f"Spread range: {event.min_spread} to {event.max_spread}") + + # Create/get test users (need at least 20 for variety) + users = await create_users_if_needed(db, 20) + print(f"Using {len(users)} users to create bets") + + # Generate 100 bets + print("\nCreating 100 spread bets...") + + # Spread range from -10 to +10 with 0.5 increments + spreads = [x / 2 for x in range(-20, 21)] # -10.0 to +10.0 + + # Stake amounts - realistic distribution + stakes = [25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 750, 1000] + + bets_created = 0 + for i in range(100): + creator = random.choice(users) + + # Generate spread - cluster around the official spread with some outliers + if random.random() < 0.7: + # 70% of bets cluster around official spread (+/- 3 points) + spread = event.official_spread + random.choice([-3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3]) + else: + # 30% are spread across the full range + spread = random.choice(spreads) + + # Clamp to valid range + spread = max(event.min_spread, min(event.max_spread, spread)) + + # Random team side + team = random.choice([TeamSide.HOME, TeamSide.AWAY]) + + # Random stake - weighted toward smaller amounts + if random.random() < 0.6: + stake = random.choice(stakes[:6]) # 60% small bets + elif random.random() < 0.85: + stake = random.choice(stakes[6:10]) # 25% medium bets + else: + stake = random.choice(stakes[10:]) # 15% large bets + + bet = SpreadBet( + event_id=event.id, + spread=spread, + team=team, + creator_id=creator.id, + stake_amount=Decimal(str(stake)), + house_commission_percent=Decimal("10.00"), + status=SpreadBetStatus.OPEN, + created_at=datetime.utcnow() + ) + db.add(bet) + bets_created += 1 + + if (i + 1) % 20 == 0: + print(f" Created {i + 1} bets...") + + await db.commit() + + print(f"\n{'='*60}") + print(f"Successfully created {bets_created} spread bets!") + print(f"{'='*60}") + print(f"\nBet distribution:") + print(f" Event: {event.home_team} vs {event.away_team}") + print(f" Spreads: clustered around {event.official_spread}") + print(f" Stakes: $25 - $1000") + print(f" Teams: mixed HOME and AWAY") + + +async def main(): + await add_bets() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/binance-trade.png b/binance-trade.png new file mode 100644 index 0000000..906873a Binary files /dev/null and b/binance-trade.png differ diff --git a/frontend/src/components/bets/TradingPanel.tsx b/frontend/src/components/bets/TradingPanel.tsx new file mode 100644 index 0000000..9f80eb3 --- /dev/null +++ b/frontend/src/components/bets/TradingPanel.tsx @@ -0,0 +1,689 @@ +import { useState, useEffect, useMemo } from 'react' +import { Link } from 'react-router-dom' +import { useAuthStore } from '@/store' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { spreadBetsApi } from '@/api/spread-bets' +import type { SportEventWithBets, SpreadGridBet } from '@/types/sport-event' +import { TeamSide } from '@/types/spread-bet' +import { + TrendingUp, + TrendingDown, + Clock, + Activity, + DollarSign, + Users, + Zap, + ChevronUp, + ChevronDown, + Target, + BarChart3, +} from 'lucide-react' +import toast from 'react-hot-toast' + +interface TradingPanelProps { + event: SportEventWithBets + onBetCreated?: () => void + onBetTaken?: () => void +} + +// Helper to format time until game +function formatTimeUntil(gameTime: string): { text: string; urgent: boolean } { + const now = new Date() + const game = new Date(gameTime) + const diff = game.getTime() - now.getTime() + + if (diff <= 0) return { text: 'LIVE', urgent: true } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((diff % (1000 * 60)) / 1000) + + if (days > 0) return { text: `${days}d ${hours}h`, urgent: false } + if (hours > 0) return { text: `${hours}h ${minutes}m`, urgent: hours < 2 } + if (minutes > 0) return { text: `${minutes}m ${seconds}s`, urgent: true } + return { text: `${seconds}s`, urgent: true } +} + +// Order book row component +interface OrderRowProps { + spread: number + bets: SpreadGridBet[] + side: 'home' | 'away' + officialSpread: number + maxVolume: number + onClick: () => void +} + +const OrderRow = ({ spread, bets, side, officialSpread, maxVolume, onClick }: OrderRowProps) => { + const totalVolume = bets.reduce((sum, b) => sum + b.stake, 0) + const betCount = bets.length + const openBets = bets.filter(b => b.status === 'open') + const takeableBets = openBets.filter(b => b.can_take) + const volumePercent = maxVolume > 0 ? (totalVolume / maxVolume) * 100 : 0 + const isOfficial = spread === officialSpread + + return ( + + ) +} + +// Recent trade item +interface RecentTradeProps { + bet: SpreadGridBet & { spread: number } + homeTeam: string + awayTeam: string +} + +const RecentTrade = ({ bet, homeTeam, awayTeam }: RecentTradeProps) => { + const isHome = bet.team === 'home' + return ( +
+ + {isHome ? homeTeam : awayTeam} {bet.spread > 0 ? '+' : ''}{bet.spread} + + ${bet.stake.toFixed(0)} + + {bet.status} + +
+ ) +} + +export const TradingPanel = ({ event, onBetCreated, onBetTaken }: TradingPanelProps) => { + const { isAuthenticated } = useAuthStore() + const queryClient = useQueryClient() + const [timeUntil, setTimeUntil] = useState(formatTimeUntil(event.game_time)) + const [selectedSide, setSelectedSide] = useState<'home' | 'away'>('home') + const [selectedSpread, setSelectedSpread] = useState(event.official_spread) + const [stakeAmount, setStakeAmount] = useState('') + const [orderType, setOrderType] = useState<'create' | 'take'>('create') + + // Update countdown every second + useEffect(() => { + const interval = setInterval(() => { + setTimeUntil(formatTimeUntil(event.game_time)) + }, 1000) + return () => clearInterval(interval) + }, [event.game_time]) + + // Calculate market stats + const marketStats = useMemo(() => { + let totalVolume = 0 + let totalBets = 0 + let openBets = 0 + let matchedBets = 0 + let homeVolume = 0 + let awayVolume = 0 + + Object.entries(event.spread_grid).forEach(([, bets]) => { + bets.forEach(bet => { + totalVolume += bet.stake + totalBets++ + if (bet.status === 'open') openBets++ + if (bet.status === 'matched') matchedBets++ + if (bet.team === 'home') homeVolume += bet.stake + else awayVolume += bet.stake + }) + }) + + return { totalVolume, totalBets, openBets, matchedBets, homeVolume, awayVolume } + }, [event.spread_grid]) + + // Get sorted spreads for order book + const sortedSpreads = useMemo(() => { + return Object.keys(event.spread_grid) + .map(Number) + .sort((a, b) => b - a) // High to low + }, [event.spread_grid]) + + // Get max volume for visualization + const maxVolume = useMemo(() => { + let max = 0 + Object.values(event.spread_grid).forEach(bets => { + const vol = bets.reduce((sum, b) => sum + b.stake, 0) + if (vol > max) max = vol + }) + return max + }, [event.spread_grid]) + + // Get recent bets + const recentBets = useMemo(() => { + const allBets: (SpreadGridBet & { spread: number })[] = [] + Object.entries(event.spread_grid).forEach(([spread, bets]) => { + bets.forEach(bet => { + allBets.push({ ...bet, spread: Number(spread) }) + }) + }) + return allBets.slice(0, 10) + }, [event.spread_grid]) + + // Get available bets to take at selected spread + const availableBets = useMemo(() => { + const bets = event.spread_grid[selectedSpread.toString()] || [] + return bets.filter(b => b.status === 'open' && b.can_take && b.team !== selectedSide) + }, [event.spread_grid, selectedSpread, selectedSide]) + + // Create bet mutation + const createBetMutation = useMutation({ + mutationFn: (data: { event_id: number; spread: number; team: TeamSide; stake_amount: number }) => + spreadBetsApi.createBet(data), + onSuccess: () => { + toast.success('Bet created successfully!') + setStakeAmount('') + queryClient.invalidateQueries({ queryKey: ['sport-event'] }) + onBetCreated?.() + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to create bet') + }, + }) + + // Take bet mutation + const takeBetMutation = useMutation({ + mutationFn: (betId: number) => spreadBetsApi.takeBet(betId), + onSuccess: () => { + toast.success('Bet taken successfully!') + setStakeAmount('') + queryClient.invalidateQueries({ queryKey: ['sport-event'] }) + onBetTaken?.() + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to take bet') + }, + }) + + const handleSubmit = () => { + if (!isAuthenticated) { + toast.error('Please log in to place a bet') + return + } + + const amount = parseFloat(stakeAmount) + if (isNaN(amount) || amount < event.min_bet_amount || amount > event.max_bet_amount) { + toast.error(`Stake must be between $${event.min_bet_amount} and $${event.max_bet_amount}`) + return + } + + if (orderType === 'take' && availableBets.length > 0) { + // Take the first available bet that matches the stake + const matchingBet = availableBets.find(b => b.stake === amount) || availableBets[0] + takeBetMutation.mutate(matchingBet.bet_id) + } else { + createBetMutation.mutate({ + event_id: event.id, + spread: selectedSpread, + team: selectedSide === 'home' ? TeamSide.HOME : TeamSide.AWAY, + stake_amount: amount, + }) + } + } + + const handleSpreadSelect = (spread: number) => { + setSelectedSpread(spread) + } + + // Quick stake buttons + const quickStakes = [25, 50, 100, 250, 500, 1000] + + return ( +
+ {/* Header - Event Info */} +
+
+
+ {/* Home Team */} +
+
+ + {event.home_team.charAt(0)} + +
+

{event.home_team}

+

HOME

+
+ + {/* VS / Spread */} +
+
SPREAD
+
+ {event.official_spread > 0 ? '+' : ''}{event.official_spread} +
+
Official Line
+
+ + {/* Away Team */} +
+
+ + {event.away_team.charAt(0)} + +
+

{event.away_team}

+

AWAY

+
+
+ + {/* Game Time & Status */} +
+
+ + {timeUntil.text} +
+

+ {new Date(event.game_time).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} +

+ {event.league && ( +

{event.league}

+ )} +
+
+
+ + {/* Market Stats Ticker */} +
+
+
+ + Volume + ${marketStats.totalVolume.toLocaleString()} +
+
+ + Bets + {marketStats.totalBets} +
+
+ + Open + {marketStats.openBets} +
+
+ + Matched + {marketStats.matchedBets} +
+
+
+
+ + ${marketStats.homeVolume.toLocaleString()} +
+
+ + ${marketStats.awayVolume.toLocaleString()} +
+
+
+ + {/* Main Trading Area */} +
+ {/* Order Book */} +
+
+

+ + Order Book +

+
+ {event.home_team} + / + {event.away_team} +
+
+ +
+ {/* Home Side (Buy/Green) */} +
+
+ Count + Volume + Spread + Open +
+
+ {sortedSpreads.map(spread => { + const bets = event.spread_grid[spread.toString()] || [] + const homeBets = bets.filter(b => b.team === 'home') + if (homeBets.length === 0 && spread !== event.official_spread) return null + return ( + { + setSelectedSide('home') + handleSpreadSelect(spread) + }} + /> + ) + })} +
+
+ + {/* Away Side (Sell/Red) */} +
+
+ Open + Spread + Volume + Count +
+
+ {sortedSpreads.map(spread => { + const bets = event.spread_grid[spread.toString()] || [] + const awayBets = bets.filter(b => b.team === 'away') + if (awayBets.length === 0 && spread !== event.official_spread) return null + return ( + { + setSelectedSide('away') + handleSpreadSelect(spread) + }} + /> + ) + })} +
+
+
+ + {/* Recent Activity */} +
+

+ + Recent Activity +

+
+ {recentBets.length > 0 ? ( + recentBets.map((bet, i) => ( + + )) + ) : ( +

No bets yet

+ )} +
+
+
+ + {/* Quick Trade Panel */} +
+

+ + Place Bet +

+ + {!isAuthenticated ? ( +
+
Log in to place bets
+
+ + Log In + + + Sign Up + +
+
+ ) : ( + <> + {/* Side Selection */} +
+ + +
+ + {/* Spread Selection */} +
+ +
+ +
+ + {selectedSpread > 0 ? '+' : ''}{selectedSpread} + + {selectedSpread === event.official_spread && ( + Official + )} +
+ +
+
+ + {/* Order Type */} +
+ + +
+ + {/* Stake Amount */} +
+ +
+ $ + setStakeAmount(e.target.value)} + placeholder={`${event.min_bet_amount} - ${event.max_bet_amount}`} + className="w-full bg-gray-700 border border-gray-600 rounded-lg py-3 pl-8 pr-4 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + {/* Quick Stakes */} +
+ {quickStakes + .filter(s => s >= event.min_bet_amount && s <= event.max_bet_amount) + .map(stake => ( + + ))} +
+ + {/* Submit Button */} + + + {/* Bet Summary */} + {stakeAmount && ( +
+
+ Your Position + + {selectedSide === 'home' ? event.home_team : event.away_team} {selectedSpread > 0 ? '+' : ''}{selectedSide === 'home' ? selectedSpread : -selectedSpread} + +
+
+ Stake + ${parseFloat(stakeAmount).toFixed(2)} +
+
+ Potential Win + + ${(parseFloat(stakeAmount) * 0.9 * 2).toFixed(2)} + +
+
+ House Fee (10%) + ${(parseFloat(stakeAmount) * 0.1).toFixed(2)} +
+
+ )} + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/EventDetail.tsx b/frontend/src/pages/EventDetail.tsx index 5ff89bc..986c21a 100644 --- a/frontend/src/pages/EventDetail.tsx +++ b/frontend/src/pages/EventDetail.tsx @@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAuthStore } from '@/store' import { sportEventsApi } from '@/api/sport-events' import { SpreadGrid } from '@/components/bets/SpreadGrid' +import { TradingPanel } from '@/components/bets/TradingPanel' import { Button } from '@/components/common/Button' import { Loading } from '@/components/common/Loading' import { Header } from '@/components/layout/Header' @@ -37,7 +38,7 @@ export const EventDetail = () => { if (isLoading) { return ( -
+
@@ -56,7 +57,7 @@ export const EventDetail = () => { if (error || !event) { return ( -
+
@@ -65,8 +66,8 @@ export const EventDetail = () => { Back to Events -
-

Event not found

+
+

Event not found

@@ -74,16 +75,28 @@ export const EventDetail = () => { } return ( -
+
- -
+ + {/* Trading Panel - Binance-style interface */} +
+ +
+ + {/* Spread Grid - Visual betting grid */} +
+

Spread Grid