Bet matching work done.
This commit is contained in:
@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db
|
||||
from app.routers import auth, users, wallet, bets, websocket
|
||||
from app.routers import auth, users, wallet, bets, websocket, admin, sport_events, spread_bets
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@ -35,6 +35,9 @@ app.include_router(users.router)
|
||||
app.include_router(wallet.router)
|
||||
app.include_router(bets.router)
|
||||
app.include_router(websocket.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(sport_events.router)
|
||||
app.include_router(spread_bets.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -2,6 +2,9 @@ from app.models.user import User, UserStatus
|
||||
from app.models.wallet import Wallet
|
||||
from app.models.transaction import Transaction, TransactionType, TransactionStatus
|
||||
from app.models.bet import Bet, BetProposal, BetCategory, BetStatus, BetVisibility, ProposalStatus
|
||||
from app.models.sport_event import SportEvent, SportType, EventStatus
|
||||
from app.models.spread_bet import SpreadBet, SpreadBetStatus, TeamSide
|
||||
from app.models.admin_settings import AdminSettings
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -16,4 +19,11 @@ __all__ = [
|
||||
"BetStatus",
|
||||
"BetVisibility",
|
||||
"ProposalStatus",
|
||||
"SportEvent",
|
||||
"SportType",
|
||||
"EventStatus",
|
||||
"SpreadBet",
|
||||
"SpreadBetStatus",
|
||||
"TeamSide",
|
||||
"AdminSettings",
|
||||
]
|
||||
|
||||
20
backend/app/models/admin_settings.py
Normal file
20
backend/app/models/admin_settings.py
Normal file
@ -0,0 +1,20 @@
|
||||
from sqlalchemy import String, Float, Numeric, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AdminSettings(Base):
|
||||
__tablename__ = "admin_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
default_house_commission_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("10.00"))
|
||||
default_min_bet_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("10.00"))
|
||||
default_max_bet_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("1000.00"))
|
||||
default_min_spread: Mapped[float] = mapped_column(Float, default=-10.0)
|
||||
default_max_spread: Mapped[float] = mapped_column(Float, default=10.0)
|
||||
spread_increment: Mapped[float] = mapped_column(Float, default=0.5)
|
||||
platform_name: Mapped[str] = mapped_column(String(100), default="H2H Sports Betting")
|
||||
maintenance_mode: Mapped[bool] = mapped_column(default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
50
backend/app/models/sport_event.py
Normal file
50
backend/app/models/sport_event.py
Normal file
@ -0,0 +1,50 @@
|
||||
from sqlalchemy import String, DateTime, Enum, Float, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SportType(enum.Enum):
|
||||
FOOTBALL = "football"
|
||||
BASKETBALL = "basketball"
|
||||
BASEBALL = "baseball"
|
||||
HOCKEY = "hockey"
|
||||
SOCCER = "soccer"
|
||||
|
||||
|
||||
class EventStatus(enum.Enum):
|
||||
UPCOMING = "upcoming"
|
||||
LIVE = "live"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class SportEvent(Base):
|
||||
__tablename__ = "sport_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
sport: Mapped[SportType] = mapped_column(Enum(SportType))
|
||||
home_team: Mapped[str] = mapped_column(String(100))
|
||||
away_team: Mapped[str] = mapped_column(String(100))
|
||||
official_spread: Mapped[float] = mapped_column(Float)
|
||||
game_time: Mapped[datetime] = mapped_column(DateTime)
|
||||
venue: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
league: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Spread betting config
|
||||
min_spread: Mapped[float] = mapped_column(Float, default=-10.0)
|
||||
max_spread: Mapped[float] = mapped_column(Float, default=10.0)
|
||||
min_bet_amount: Mapped[float] = mapped_column(Float, default=10.0)
|
||||
max_bet_amount: Mapped[float] = mapped_column(Float, default=1000.0)
|
||||
|
||||
status: Mapped[EventStatus] = mapped_column(Enum(EventStatus), default=EventStatus.UPCOMING)
|
||||
final_score_home: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
final_score_away: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="event")
|
||||
47
backend/app/models/spread_bet.py
Normal file
47
backend/app/models/spread_bet.py
Normal file
@ -0,0 +1,47 @@
|
||||
from sqlalchemy import String, DateTime, Enum, Float, Integer, ForeignKey, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import enum
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TeamSide(enum.Enum):
|
||||
HOME = "home"
|
||||
AWAY = "away"
|
||||
|
||||
|
||||
class SpreadBetStatus(enum.Enum):
|
||||
OPEN = "open"
|
||||
MATCHED = "matched"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
DISPUTED = "disputed"
|
||||
|
||||
|
||||
class SpreadBet(Base):
|
||||
__tablename__ = "spread_bets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("sport_events.id"))
|
||||
spread: Mapped[float] = mapped_column(Float)
|
||||
team: Mapped[TeamSide] = mapped_column(Enum(TeamSide))
|
||||
|
||||
creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
taker_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
stake_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
|
||||
house_commission_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("10.00"))
|
||||
|
||||
status: Mapped[SpreadBetStatus] = mapped_column(Enum(SpreadBetStatus), default=SpreadBetStatus.OPEN)
|
||||
payout_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
winner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
matched_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
event: Mapped["SportEvent"] = relationship(back_populates="spread_bets")
|
||||
creator: Mapped["User"] = relationship(back_populates="created_spread_bets", foreign_keys=[creator_id])
|
||||
taker: Mapped["User"] = relationship(back_populates="taken_spread_bets", foreign_keys=[taker_id])
|
||||
@ -30,6 +30,9 @@ class User(Base):
|
||||
losses: Mapped[int] = mapped_column(Integer, default=0)
|
||||
win_rate: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# Admin flag
|
||||
is_admin: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
status: Mapped[UserStatus] = mapped_column(Enum(UserStatus), default=UserStatus.ACTIVE)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@ -39,3 +42,5 @@ class User(Base):
|
||||
created_bets: Mapped[list["Bet"]] = relationship(back_populates="creator", foreign_keys="Bet.creator_id")
|
||||
accepted_bets: Mapped[list["Bet"]] = relationship(back_populates="opponent", foreign_keys="Bet.opponent_id")
|
||||
transactions: Mapped[list["Transaction"]] = relationship(back_populates="user")
|
||||
created_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="creator", foreign_keys="SpreadBet.creator_id")
|
||||
taken_spread_bets: Mapped[list["SpreadBet"]] = relationship(back_populates="taker", foreign_keys="SpreadBet.taker_id")
|
||||
|
||||
138
backend/app/routers/admin.py
Normal file
138
backend/app/routers/admin.py
Normal 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"}
|
||||
123
backend/app/routers/sport_events.py
Normal file
123
backend/app/routers/sport_events.py
Normal 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
|
||||
}
|
||||
250
backend/app/routers/spread_bets.py
Normal file
250
backend/app/routers/spread_bets.py
Normal 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"}
|
||||
59
backend/app/schemas/sport_event.py
Normal file
59
backend/app/schemas/sport_event.py
Normal file
@ -0,0 +1,59 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from app.models.sport_event import SportType, EventStatus
|
||||
|
||||
|
||||
class SportEventCreate(BaseModel):
|
||||
sport: SportType
|
||||
home_team: str
|
||||
away_team: str
|
||||
official_spread: float
|
||||
game_time: datetime
|
||||
venue: Optional[str] = None
|
||||
league: Optional[str] = None
|
||||
min_spread: float = -10.0
|
||||
max_spread: float = 10.0
|
||||
min_bet_amount: float = 10.0
|
||||
max_bet_amount: float = 1000.0
|
||||
|
||||
|
||||
class SportEventUpdate(BaseModel):
|
||||
sport: Optional[SportType] = None
|
||||
home_team: Optional[str] = None
|
||||
away_team: Optional[str] = None
|
||||
official_spread: Optional[float] = None
|
||||
game_time: Optional[datetime] = None
|
||||
venue: Optional[str] = None
|
||||
league: Optional[str] = None
|
||||
status: Optional[EventStatus] = None
|
||||
final_score_home: Optional[int] = None
|
||||
final_score_away: Optional[int] = None
|
||||
|
||||
|
||||
class SportEvent(BaseModel):
|
||||
id: int
|
||||
sport: SportType
|
||||
home_team: str
|
||||
away_team: str
|
||||
official_spread: float
|
||||
game_time: datetime
|
||||
venue: Optional[str] = None
|
||||
league: Optional[str] = None
|
||||
min_spread: float
|
||||
max_spread: float
|
||||
min_bet_amount: float
|
||||
max_bet_amount: float
|
||||
status: EventStatus
|
||||
final_score_home: Optional[int] = None
|
||||
final_score_away: Optional[int] = None
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SportEventWithBets(SportEvent):
|
||||
spread_grid: Dict[str, Any]
|
||||
45
backend/app/schemas/spread_bet.py
Normal file
45
backend/app/schemas/spread_bet.py
Normal file
@ -0,0 +1,45 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from app.models.spread_bet import TeamSide, SpreadBetStatus
|
||||
|
||||
|
||||
class SpreadBetCreate(BaseModel):
|
||||
event_id: int
|
||||
spread: float
|
||||
team: TeamSide
|
||||
stake_amount: Decimal
|
||||
|
||||
|
||||
class SpreadBetTake(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class SpreadBet(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
spread: float
|
||||
team: TeamSide
|
||||
creator_id: int
|
||||
taker_id: Optional[int] = None
|
||||
stake_amount: Decimal
|
||||
house_commission_percent: Decimal
|
||||
status: SpreadBetStatus
|
||||
payout_amount: Optional[Decimal] = None
|
||||
winner_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
matched_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SpreadBetDetail(SpreadBet):
|
||||
creator_username: str
|
||||
taker_username: Optional[str] = None
|
||||
event_home_team: str
|
||||
event_away_team: str
|
||||
event_official_spread: float
|
||||
event_game_time: datetime
|
||||
Reference in New Issue
Block a user