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
-
@@ -74,16 +75,28 @@ export const EventDetail = () => {
}
return (
-
+
-