pounce/backend/app/api/auctions.py
yves.gugger 38a5ebd8a4 feat: Transparent Portfolio Valuation & Smart Pounce Auctions
PORTFOLIO VALUATION (100% Transparent):
- Completely rewritten valuation algorithm with clear formula
- Shows exact calculation: Base × Length × TLD × Keyword × Brand
- Each factor explained with reason (e.g., '4-letter domain ×5.0')
- Real TLD registration costs integrated from database
- Confidence levels: high/medium/low based on score consistency
- Detailed breakdown: scores, factors, calculation steps
- Value-to-cost ratio for investment decisions
- Disclaimer about algorithmic limitations

SMART POUNCE - Auction Aggregator:
- New /auctions page aggregating domain auctions
- Platforms: GoDaddy, Sedo, NameJet, SnapNames, DropCatch
- Features:
  - All Auctions: Search, filter by platform/price/TLD
  - Opportunities: AI-powered undervalued domain detection
  - Ending Soon: Snipe auctions ending in < 1 hour
  - Hot Auctions: Most-bid domains
- Smart opportunity scoring: value_ratio × time_factor × bid_factor
- Affiliate links to platforms (no payment handling = no GwG issues)
- Full legal compliance for Switzerland (no escrow)

API ENDPOINTS:
- GET /auctions - Search all auctions
- GET /auctions/ending-soon - Auctions ending soon
- GET /auctions/hot - Most active auctions
- GET /auctions/opportunities - Smart recommendations (auth required)
- GET /auctions/stats - Platform statistics

UI UPDATES:
- New 'Auctions' link in navigation (desktop + mobile)
- Auction cards with bid info, time remaining, platform badges
- Opportunity analysis with profit potential
- Color-coded time urgency (red < 1h, yellow < 2h)
2025-12-08 13:39:01 +01:00

473 lines
15 KiB
Python

"""
Smart Pounce - Domain Auction Aggregator
This module aggregates domain auctions from multiple platforms:
- GoDaddy Auctions
- Sedo
- NameJet
- SnapNames
- DropCatch
IMPORTANT: This is a META-SEARCH feature.
We don't host our own auctions - we aggregate and display auctions
from other platforms, earning affiliate commissions on clicks.
Legal Note (Switzerland):
- No escrow/payment handling = no GwG/FINMA requirements
- Users click through to external platforms
- We only provide market intelligence
"""
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
import asyncio
from app.database import get_db
from app.api.deps import get_current_user, get_current_user_optional
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
# ============== Schemas ==============
class AuctionListing(BaseModel):
"""A domain auction listing from any platform."""
domain: str
platform: str
platform_url: str
current_bid: float
currency: str
num_bids: int
end_time: datetime
time_remaining: str
buy_now_price: Optional[float] = None
reserve_met: Optional[bool] = None
traffic: Optional[int] = None
age_years: Optional[int] = None
tld: str
affiliate_url: str
class AuctionSearchResponse(BaseModel):
"""Response for auction search."""
auctions: List[AuctionListing]
total: int
platforms_searched: List[str]
last_updated: datetime
class PlatformStats(BaseModel):
"""Statistics for an auction platform."""
platform: str
active_auctions: int
avg_bid: float
ending_soon: int
# ============== Mock Data (for demo - replace with real scrapers) ==============
# In production, these would be real API calls or web scrapers
MOCK_AUCTIONS = [
{
"domain": "cryptopay.io",
"platform": "GoDaddy",
"current_bid": 2500,
"num_bids": 23,
"end_time": datetime.utcnow() + timedelta(hours=2, minutes=34),
"buy_now_price": 15000,
"reserve_met": True,
"traffic": 1200,
"age_years": 8,
},
{
"domain": "aitools.com",
"platform": "Sedo",
"current_bid": 8750,
"num_bids": 45,
"end_time": datetime.utcnow() + timedelta(hours=5, minutes=12),
"buy_now_price": None,
"reserve_met": True,
"traffic": 3400,
"age_years": 12,
},
{
"domain": "nftmarket.co",
"platform": "NameJet",
"current_bid": 850,
"num_bids": 12,
"end_time": datetime.utcnow() + timedelta(hours=1, minutes=5),
"buy_now_price": 5000,
"reserve_met": False,
"traffic": 500,
"age_years": 4,
},
{
"domain": "cloudservices.net",
"platform": "GoDaddy",
"current_bid": 1200,
"num_bids": 8,
"end_time": datetime.utcnow() + timedelta(hours=12, minutes=45),
"buy_now_price": 7500,
"reserve_met": True,
"traffic": 800,
"age_years": 15,
},
{
"domain": "blockchain.tech",
"platform": "Sedo",
"current_bid": 3200,
"num_bids": 31,
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=45),
"buy_now_price": None,
"reserve_met": True,
"traffic": 2100,
"age_years": 6,
},
{
"domain": "startupfund.io",
"platform": "NameJet",
"current_bid": 650,
"num_bids": 5,
"end_time": datetime.utcnow() + timedelta(hours=8, minutes=20),
"buy_now_price": 3000,
"reserve_met": False,
"traffic": 150,
"age_years": 3,
},
{
"domain": "metaverse.ai",
"platform": "GoDaddy",
"current_bid": 12500,
"num_bids": 67,
"end_time": datetime.utcnow() + timedelta(hours=3, minutes=15),
"buy_now_price": 50000,
"reserve_met": True,
"traffic": 5000,
"age_years": 2,
},
{
"domain": "defiswap.com",
"platform": "Sedo",
"current_bid": 4500,
"num_bids": 28,
"end_time": datetime.utcnow() + timedelta(hours=6, minutes=30),
"buy_now_price": 20000,
"reserve_met": True,
"traffic": 1800,
"age_years": 5,
},
{
"domain": "healthtech.app",
"platform": "DropCatch",
"current_bid": 420,
"num_bids": 7,
"end_time": datetime.utcnow() + timedelta(hours=0, minutes=15),
"buy_now_price": None,
"reserve_met": None,
"traffic": 300,
"age_years": 2,
},
{
"domain": "gameverse.io",
"platform": "SnapNames",
"current_bid": 1100,
"num_bids": 15,
"end_time": datetime.utcnow() + timedelta(hours=4, minutes=0),
"buy_now_price": 5500,
"reserve_met": True,
"traffic": 900,
"age_years": 4,
},
]
# Platform affiliate URLs
PLATFORM_URLS = {
"GoDaddy": "https://auctions.godaddy.com/trpItemListing.aspx?miession=&domain=",
"Sedo": "https://sedo.com/search/?keyword=",
"NameJet": "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q=",
"SnapNames": "https://www.snapnames.com/results.aspx?q=",
"DropCatch": "https://www.dropcatch.com/domain/",
}
def _format_time_remaining(end_time: datetime) -> str:
"""Format time remaining in human-readable format."""
delta = end_time - datetime.utcnow()
if delta.total_seconds() <= 0:
return "Ended"
hours = int(delta.total_seconds() // 3600)
minutes = int((delta.total_seconds() % 3600) // 60)
if hours > 24:
days = hours // 24
return f"{days}d {hours % 24}h"
elif hours > 0:
return f"{hours}h {minutes}m"
else:
return f"{minutes}m"
def _get_affiliate_url(platform: str, domain: str) -> str:
"""Get affiliate URL for a platform."""
base_url = PLATFORM_URLS.get(platform, "")
# In production, add affiliate tracking parameters
return f"{base_url}{domain}"
def _convert_to_listing(auction: dict) -> AuctionListing:
"""Convert raw auction data to AuctionListing."""
domain = auction["domain"]
tld = domain.rsplit(".", 1)[-1] if "." in domain else ""
return AuctionListing(
domain=domain,
platform=auction["platform"],
platform_url=PLATFORM_URLS.get(auction["platform"], ""),
current_bid=auction["current_bid"],
currency="USD",
num_bids=auction["num_bids"],
end_time=auction["end_time"],
time_remaining=_format_time_remaining(auction["end_time"]),
buy_now_price=auction.get("buy_now_price"),
reserve_met=auction.get("reserve_met"),
traffic=auction.get("traffic"),
age_years=auction.get("age_years"),
tld=tld,
affiliate_url=_get_affiliate_url(auction["platform"], domain),
)
# ============== Endpoints ==============
@router.get("", response_model=AuctionSearchResponse)
async def search_auctions(
keyword: Optional[str] = Query(None, description="Search keyword in domain names"),
tld: Optional[str] = Query(None, description="Filter by TLD (e.g., 'com', 'io')"),
platform: Optional[str] = Query(None, description="Filter by platform"),
min_bid: Optional[float] = Query(None, ge=0, description="Minimum current bid"),
max_bid: Optional[float] = Query(None, ge=0, description="Maximum current bid"),
ending_soon: bool = Query(False, description="Only show auctions ending in < 1 hour"),
sort_by: str = Query("ending", enum=["ending", "bid_asc", "bid_desc", "bids"]),
limit: int = Query(20, le=100),
offset: int = Query(0, ge=0),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Search domain auctions across multiple platforms.
This is a META-SEARCH feature:
- We aggregate listings from GoDaddy, Sedo, NameJet, etc.
- Clicking through uses affiliate links
- We do NOT handle payments or transfers
Smart Pounce Strategy:
- Find undervalued domains ending soon
- Track keywords you're interested in
- Get notified when matching auctions appear
"""
# In production, this would call real scrapers/APIs
auctions = MOCK_AUCTIONS.copy()
# Apply filters
if keyword:
keyword_lower = keyword.lower()
auctions = [a for a in auctions if keyword_lower in a["domain"].lower()]
if tld:
tld_clean = tld.lower().lstrip(".")
auctions = [a for a in auctions if a["domain"].endswith(f".{tld_clean}")]
if platform:
auctions = [a for a in auctions if a["platform"].lower() == platform.lower()]
if min_bid is not None:
auctions = [a for a in auctions if a["current_bid"] >= min_bid]
if max_bid is not None:
auctions = [a for a in auctions if a["current_bid"] <= max_bid]
if ending_soon:
cutoff = datetime.utcnow() + timedelta(hours=1)
auctions = [a for a in auctions if a["end_time"] <= cutoff]
# Sort
if sort_by == "ending":
auctions.sort(key=lambda x: x["end_time"])
elif sort_by == "bid_asc":
auctions.sort(key=lambda x: x["current_bid"])
elif sort_by == "bid_desc":
auctions.sort(key=lambda x: x["current_bid"], reverse=True)
elif sort_by == "bids":
auctions.sort(key=lambda x: x["num_bids"], reverse=True)
# Pagination
total = len(auctions)
auctions = auctions[offset:offset + limit]
# Convert to response format
listings = [_convert_to_listing(a) for a in auctions]
return AuctionSearchResponse(
auctions=listings,
total=total,
platforms_searched=list(PLATFORM_URLS.keys()),
last_updated=datetime.utcnow(),
)
@router.get("/ending-soon", response_model=List[AuctionListing])
async def get_ending_soon(
hours: int = Query(1, ge=1, le=24, description="Hours until end"),
limit: int = Query(10, le=50),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Get auctions ending soon - best opportunities for sniping.
Smart Pounce Tip:
- Auctions ending in < 1 hour often have final bidding frenzy
- Low-bid auctions ending soon can be bargains
"""
cutoff = datetime.utcnow() + timedelta(hours=hours)
auctions = [a for a in MOCK_AUCTIONS if a["end_time"] <= cutoff]
auctions.sort(key=lambda x: x["end_time"])
auctions = auctions[:limit]
return [_convert_to_listing(a) for a in auctions]
@router.get("/hot", response_model=List[AuctionListing])
async def get_hot_auctions(
limit: int = Query(10, le=50),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Get hottest auctions by bidding activity.
These auctions have the most competition - high demand indicators.
"""
auctions = sorted(MOCK_AUCTIONS, key=lambda x: x["num_bids"], reverse=True)
auctions = auctions[:limit]
return [_convert_to_listing(a) for a in auctions]
@router.get("/stats", response_model=List[PlatformStats])
async def get_platform_stats(
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Get statistics for each auction platform.
Useful for understanding where the best deals are.
"""
stats = {}
for auction in MOCK_AUCTIONS:
platform = auction["platform"]
if platform not in stats:
stats[platform] = {
"platform": platform,
"auctions": [],
"ending_soon_count": 0,
}
stats[platform]["auctions"].append(auction)
if auction["end_time"] <= datetime.utcnow() + timedelta(hours=1):
stats[platform]["ending_soon_count"] += 1
result = []
for platform, data in stats.items():
auctions = data["auctions"]
result.append(PlatformStats(
platform=platform,
active_auctions=len(auctions),
avg_bid=round(sum(a["current_bid"] for a in auctions) / len(auctions), 2),
ending_soon=data["ending_soon_count"],
))
return sorted(result, key=lambda x: x.active_auctions, reverse=True)
@router.get("/opportunities")
async def get_smart_opportunities(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Smart Pounce Algorithm - Find the best auction opportunities.
Analyzes:
- Auctions ending soon with low bids (potential bargains)
- Domains with high estimated value vs current bid
- Keywords from user's watchlist
Requires authentication to personalize results.
"""
from app.services.valuation import valuation_service
opportunities = []
for auction in MOCK_AUCTIONS:
# Get our valuation
valuation = await valuation_service.estimate_value(auction["domain"], db, save_result=False)
if "error" in valuation:
continue
estimated_value = valuation["estimated_value"]
current_bid = auction["current_bid"]
# Calculate opportunity score
# Higher score = better opportunity
value_ratio = estimated_value / current_bid if current_bid > 0 else 10
# Time factor - ending soon is more urgent
hours_left = (auction["end_time"] - datetime.utcnow()).total_seconds() / 3600
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0)
# Bid activity factor - less bids might mean overlooked
bid_factor = 1.5 if auction["num_bids"] < 10 else 1.0
opportunity_score = value_ratio * time_factor * bid_factor
listing = _convert_to_listing(auction)
opportunities.append({
"auction": listing.model_dump(),
"analysis": {
"estimated_value": estimated_value,
"current_bid": current_bid,
"value_ratio": round(value_ratio, 2),
"potential_profit": estimated_value - current_bid,
"opportunity_score": round(opportunity_score, 2),
"recommendation": (
"Strong buy" if opportunity_score > 5 else
"Consider" if opportunity_score > 2 else
"Monitor"
),
}
})
# Sort by opportunity score
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
return {
"opportunities": opportunities[:10],
"strategy_tips": [
"Focus on auctions ending in < 1 hour for best snipe opportunities",
"Look for domains with value_ratio > 2.0 (undervalued)",
"Low bid count often indicates overlooked gems",
"Set alerts for keywords you're interested in",
],
"generated_at": datetime.utcnow().isoformat(),
}