Bet matching work done.

This commit is contained in:
2026-01-04 17:13:32 -06:00
parent 93fb46f19b
commit 2e9b2c83de
60 changed files with 7183 additions and 1 deletions

View File

@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import List
from datetime import datetime
from decimal import Decimal
from app.database import get_db
from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus
from app.schemas.sport_event import SportEventCreate, SportEventUpdate, SportEvent as SportEventSchema
from app.routers.auth import get_current_user
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# Dependency to check if user is admin
async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user
@router.get("/settings")
async def get_admin_settings(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
result = await db.execute(select(AdminSettings).limit(1))
settings = result.scalar_one_or_none()
if not settings:
settings = AdminSettings()
db.add(settings)
await db.commit()
await db.refresh(settings)
return settings
@router.patch("/settings")
async def update_admin_settings(
updates: dict,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
result = await db.execute(select(AdminSettings).limit(1))
settings = result.scalar_one_or_none()
if not settings:
settings = AdminSettings()
db.add(settings)
for key, value in updates.items():
if hasattr(settings, key):
setattr(settings, key, value)
await db.commit()
await db.refresh(settings)
return settings
@router.post("/events", response_model=SportEventSchema)
async def create_event(
event_data: SportEventCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
event = SportEvent(
**event_data.model_dump(),
created_by=admin.id
)
db.add(event)
await db.commit()
await db.refresh(event)
return event
@router.get("/events", response_model=List[SportEventSchema])
async def list_events(
skip: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
result = await db.execute(
select(SportEvent).offset(skip).limit(limit).order_by(SportEvent.game_time.desc())
)
return result.scalars().all()
@router.patch("/events/{event_id}", response_model=SportEventSchema)
async def update_event(
event_id: int,
updates: SportEventUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
result = await db.execute(select(SportEvent).where(SportEvent.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
update_data = updates.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(event, key, value)
await db.commit()
await db.refresh(event)
return event
@router.delete("/events/{event_id}")
async def delete_event(
event_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
result = await db.execute(select(SportEvent).where(SportEvent.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check if there are any matched bets
bets_result = await db.execute(
select(SpreadBet).where(
SpreadBet.event_id == event_id,
SpreadBet.status != "open"
)
)
if bets_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail="Cannot delete event with matched bets"
)
await db.delete(event)
await db.commit()
return {"message": "Event deleted"}

View File

@ -0,0 +1,123 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from typing import List
from datetime import datetime
from app.database import get_db
from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus, SpreadBetStatus, TeamSide
from app.schemas.sport_event import SportEvent as SportEventSchema, SportEventWithBets
from app.routers.auth import get_current_user
router = APIRouter(prefix="/api/v1/sport-events", tags=["sport-events"])
def generate_spread_grid(min_spread: float, max_spread: float, increment: float = 0.5) -> List[float]:
"""Generate list of spread values from min to max with given increment"""
spreads = []
current = min_spread
while current <= max_spread:
spreads.append(round(current, 1))
current += increment
return spreads
@router.get("", response_model=List[SportEventSchema])
async def list_upcoming_events(
skip: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(
select(SportEvent)
.where(SportEvent.status == EventStatus.UPCOMING)
.order_by(SportEvent.game_time.asc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
@router.get("/{event_id}")
async def get_event_with_grid(
event_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Get event
result = await db.execute(
select(SportEvent).where(SportEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Get admin settings for spread increment
settings_result = await db.execute(select(AdminSettings).limit(1))
settings = settings_result.scalar_one_or_none()
increment = settings.spread_increment if settings else 0.5
# Generate spread grid
spreads = generate_spread_grid(event.min_spread, event.max_spread, increment)
# Get existing bets for this event
bets_result = await db.execute(
select(SpreadBet)
.options(selectinload(SpreadBet.creator))
.where(
and_(
SpreadBet.event_id == event_id,
SpreadBet.status.in_([SpreadBetStatus.OPEN, SpreadBetStatus.MATCHED])
)
)
)
bets = bets_result.scalars().all()
# Build spread grid with bet info
spread_grid = {}
for spread in spreads:
# Check for HOME team bet at this spread
bet_at_spread = next(
(b for b in bets if b.spread == spread and b.team == TeamSide.HOME),
None
)
if bet_at_spread:
spread_grid[str(spread)] = {
"bet_id": bet_at_spread.id,
"creator_id": bet_at_spread.creator_id,
"creator_username": bet_at_spread.creator.username,
"stake": float(bet_at_spread.stake_amount),
"status": bet_at_spread.status.value,
"team": bet_at_spread.team.value,
"can_take": (
bet_at_spread.status == SpreadBetStatus.OPEN and
bet_at_spread.creator_id != current_user.id
)
}
else:
spread_grid[str(spread)] = None
return {
"id": event.id,
"sport": event.sport.value,
"home_team": event.home_team,
"away_team": event.away_team,
"official_spread": event.official_spread,
"game_time": event.game_time.isoformat(),
"venue": event.venue,
"league": event.league,
"min_spread": event.min_spread,
"max_spread": event.max_spread,
"min_bet_amount": event.min_bet_amount,
"max_bet_amount": event.max_bet_amount,
"status": event.status.value,
"final_score_home": event.final_score_home,
"final_score_away": event.final_score_away,
"created_by": event.created_by,
"created_at": event.created_at.isoformat(),
"updated_at": event.updated_at.isoformat(),
"spread_grid": spread_grid
}

View File

@ -0,0 +1,250 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from typing import List
from datetime import datetime
from decimal import Decimal
from app.database import get_db
from app.models import User, SportEvent, SpreadBet, Wallet, Transaction, AdminSettings
from app.models import EventStatus, SpreadBetStatus, TeamSide, TransactionType, TransactionStatus
from app.schemas.spread_bet import SpreadBet as SpreadBetSchema, SpreadBetCreate, SpreadBetDetail
from app.routers.auth import get_current_user
router = APIRouter(prefix="/api/v1/spread-bets", tags=["spread-bets"])
@router.post("", response_model=SpreadBetSchema)
async def create_spread_bet(
bet_data: SpreadBetCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Get the event
event_result = await db.execute(
select(SportEvent).where(SportEvent.id == bet_data.event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
if event.status != EventStatus.UPCOMING:
raise HTTPException(status_code=400, detail="Event is not open for betting")
# Validate stake amount
if bet_data.stake_amount < Decimal(str(event.min_bet_amount)):
raise HTTPException(
status_code=400,
detail=f"Minimum bet amount is ${event.min_bet_amount}"
)
if bet_data.stake_amount > Decimal(str(event.max_bet_amount)):
raise HTTPException(
status_code=400,
detail=f"Maximum bet amount is ${event.max_bet_amount}"
)
# Check if spread is within range
if bet_data.spread < event.min_spread or bet_data.spread > event.max_spread:
raise HTTPException(
status_code=400,
detail=f"Spread must be between {event.min_spread} and {event.max_spread}"
)
# Check for existing bet at this spread
existing_result = await db.execute(
select(SpreadBet).where(
and_(
SpreadBet.event_id == bet_data.event_id,
SpreadBet.spread == bet_data.spread,
SpreadBet.team == bet_data.team,
SpreadBet.status == SpreadBetStatus.OPEN
)
)
)
if existing_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="A bet already exists at this spread")
# Get admin settings for commission
settings_result = await db.execute(select(AdminSettings).limit(1))
settings = settings_result.scalar_one_or_none()
commission = settings.default_house_commission_percent if settings else Decimal("10.00")
# Create the bet
bet = SpreadBet(
event_id=bet_data.event_id,
spread=bet_data.spread,
team=bet_data.team,
creator_id=current_user.id,
stake_amount=bet_data.stake_amount,
house_commission_percent=commission,
status=SpreadBetStatus.OPEN
)
db.add(bet)
await db.commit()
await db.refresh(bet)
return bet
@router.post("/{bet_id}/take", response_model=SpreadBetSchema)
async def take_spread_bet(
bet_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Get the bet
bet_result = await db.execute(
select(SpreadBet)
.options(selectinload(SpreadBet.event))
.where(SpreadBet.id == bet_id)
)
bet = bet_result.scalar_one_or_none()
if not bet:
raise HTTPException(status_code=404, detail="Bet not found")
if bet.status != SpreadBetStatus.OPEN:
raise HTTPException(status_code=400, detail="Bet is not open")
if bet.creator_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot take your own bet")
if bet.event.status != EventStatus.UPCOMING:
raise HTTPException(status_code=400, detail="Event is no longer open for betting")
# Get wallets
creator_wallet_result = await db.execute(
select(Wallet).where(Wallet.user_id == bet.creator_id)
)
creator_wallet = creator_wallet_result.scalar_one_or_none()
taker_wallet_result = await db.execute(
select(Wallet).where(Wallet.user_id == current_user.id)
)
taker_wallet = taker_wallet_result.scalar_one_or_none()
if not taker_wallet:
raise HTTPException(status_code=400, detail="Wallet not found")
# Check taker has sufficient balance
if taker_wallet.balance < bet.stake_amount:
raise HTTPException(
status_code=400,
detail=f"Insufficient balance. Need ${bet.stake_amount}"
)
# Check creator still has sufficient balance
if creator_wallet.balance < bet.stake_amount:
raise HTTPException(
status_code=400,
detail="Creator no longer has sufficient balance"
)
# Lock funds for both parties
creator_wallet.balance -= bet.stake_amount
creator_wallet.escrow += bet.stake_amount
taker_wallet.balance -= bet.stake_amount
taker_wallet.escrow += bet.stake_amount
# Create transactions
creator_tx = Transaction(
user_id=bet.creator_id,
type=TransactionType.ESCROW_LOCK,
amount=-bet.stake_amount,
balance_after=creator_wallet.balance,
reference_id=bet.id,
description=f"Escrow lock for spread bet #{bet.id}",
status=TransactionStatus.COMPLETED
)
taker_tx = Transaction(
user_id=current_user.id,
type=TransactionType.ESCROW_LOCK,
amount=-bet.stake_amount,
balance_after=taker_wallet.balance,
reference_id=bet.id,
description=f"Escrow lock for spread bet #{bet.id}",
status=TransactionStatus.COMPLETED
)
db.add(creator_tx)
db.add(taker_tx)
# Update bet
bet.taker_id = current_user.id
bet.status = SpreadBetStatus.MATCHED
bet.matched_at = datetime.utcnow()
await db.commit()
await db.refresh(bet)
return bet
@router.get("/my-active", response_model=List[SpreadBetDetail])
async def get_my_active_bets(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(
select(SpreadBet)
.options(
selectinload(SpreadBet.event),
selectinload(SpreadBet.creator),
selectinload(SpreadBet.taker)
)
.where(
and_(
SpreadBet.status.in_([SpreadBetStatus.OPEN, SpreadBetStatus.MATCHED]),
(SpreadBet.creator_id == current_user.id) | (SpreadBet.taker_id == current_user.id)
)
)
.order_by(SpreadBet.created_at.desc())
)
bets = result.scalars().all()
return [
SpreadBetDetail(
id=bet.id,
event_id=bet.event_id,
spread=bet.spread,
team=bet.team,
creator_id=bet.creator_id,
taker_id=bet.taker_id,
stake_amount=bet.stake_amount,
house_commission_percent=bet.house_commission_percent,
status=bet.status,
payout_amount=bet.payout_amount,
winner_id=bet.winner_id,
created_at=bet.created_at,
matched_at=bet.matched_at,
completed_at=bet.completed_at,
creator_username=bet.creator.username,
taker_username=bet.taker.username if bet.taker else None,
event_home_team=bet.event.home_team,
event_away_team=bet.event.away_team,
event_official_spread=bet.event.official_spread,
event_game_time=bet.event.game_time
)
for bet in bets
]
@router.delete("/{bet_id}")
async def cancel_spread_bet(
bet_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
bet_result = await db.execute(
select(SpreadBet).where(SpreadBet.id == bet_id)
)
bet = bet_result.scalar_one_or_none()
if not bet:
raise HTTPException(status_code=404, detail="Bet not found")
if bet.creator_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the creator can cancel this bet")
if bet.status != SpreadBetStatus.OPEN:
raise HTTPException(status_code=400, detail="Can only cancel open bets")
bet.status = SpreadBetStatus.CANCELLED
await db.commit()
return {"message": "Bet cancelled"}