MAJOR FEATURES: - New pricing tiers: Scout (Free), Trader (€19/mo), Tycoon (€49/mo) - Portfolio management: Track owned domains with purchase price, value, ROI - Domain valuation engine: Algorithmic estimates based on length, TLD, keywords, brandability - Dashboard tabs: Watchlist + Portfolio views - Valuation modal: Score breakdown with confidence level BACKEND: - New models: PortfolioDomain, DomainValuation - New API routes: /portfolio/* with full CRUD - Valuation service with multi-factor algorithm - Database migration for portfolio tables FRONTEND: - Updated pricing page with comparison table and billing toggle - Dashboard with Watchlist/Portfolio tabs - Portfolio summary stats: Total value, invested, unrealized P/L, ROI - Add portfolio domain modal with all fields - Domain valuation modal with score visualization - Updated landing page with new tier pricing - Hero section with large puma logo DESIGN: - Consistent minimalist dark theme - Responsive on all devices - Professional animations and transitions
335 lines
10 KiB
Python
335 lines
10 KiB
Python
"""Domain valuation service."""
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Optional, Dict, Any
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.portfolio import DomainValuation
|
|
from app.models.tld_price import TLDPrice
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# TLD value multipliers (higher = more valuable)
|
|
TLD_VALUES = {
|
|
# Premium TLDs
|
|
"com": 1.0,
|
|
"net": 0.7,
|
|
"org": 0.65,
|
|
"io": 0.8,
|
|
"ai": 1.2,
|
|
"co": 0.6,
|
|
|
|
# Tech TLDs
|
|
"dev": 0.5,
|
|
"app": 0.5,
|
|
"tech": 0.4,
|
|
"software": 0.3,
|
|
|
|
# Country codes
|
|
"de": 0.5,
|
|
"uk": 0.5,
|
|
"ch": 0.45,
|
|
"fr": 0.4,
|
|
"eu": 0.35,
|
|
|
|
# New gTLDs
|
|
"xyz": 0.15,
|
|
"online": 0.2,
|
|
"site": 0.2,
|
|
"store": 0.25,
|
|
"shop": 0.25,
|
|
|
|
# Default
|
|
"_default": 0.2,
|
|
}
|
|
|
|
# Common high-value keywords
|
|
HIGH_VALUE_KEYWORDS = {
|
|
"crypto", "bitcoin", "btc", "eth", "nft", "web3", "defi",
|
|
"ai", "ml", "gpt", "chat", "bot",
|
|
"cloud", "saas", "api", "app", "tech",
|
|
"finance", "fintech", "bank", "pay", "money",
|
|
"health", "med", "care", "fit",
|
|
"game", "gaming", "play", "esport",
|
|
"shop", "buy", "sell", "deal", "store",
|
|
"travel", "trip", "hotel", "fly",
|
|
"food", "eat", "chef", "recipe",
|
|
"auto", "car", "drive", "ev",
|
|
"home", "house", "real", "estate",
|
|
}
|
|
|
|
|
|
class DomainValuationService:
|
|
"""
|
|
Service for estimating domain values.
|
|
|
|
Uses a multi-factor algorithm considering:
|
|
- Domain length
|
|
- TLD value
|
|
- Keyword relevance
|
|
- Brandability
|
|
- Character composition
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.base_value = 10 # Base value in USD
|
|
|
|
async def estimate_value(
|
|
self,
|
|
domain: str,
|
|
db: Optional[AsyncSession] = None,
|
|
save_result: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Estimate the value of a domain.
|
|
|
|
Args:
|
|
domain: The domain name (e.g., "example.com")
|
|
db: Database session (optional, for saving results)
|
|
save_result: Whether to save the valuation to database
|
|
|
|
Returns:
|
|
Dictionary with valuation details
|
|
"""
|
|
domain = domain.lower().strip()
|
|
|
|
# Split domain and TLD
|
|
parts = domain.rsplit(".", 1)
|
|
if len(parts) != 2:
|
|
return {"error": "Invalid domain format"}
|
|
|
|
name, tld = parts
|
|
|
|
# Calculate scores
|
|
length_score = self._calculate_length_score(name)
|
|
tld_score = self._calculate_tld_score(tld)
|
|
keyword_score = self._calculate_keyword_score(name)
|
|
brandability_score = self._calculate_brandability_score(name)
|
|
|
|
# Calculate base value
|
|
# Formula: base * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
|
|
length_mult = length_score / 50 # 0.0 - 2.0
|
|
tld_mult = TLD_VALUES.get(tld, TLD_VALUES["_default"])
|
|
keyword_bonus = keyword_score / 200 # 0.0 - 0.5
|
|
brand_bonus = brandability_score / 200 # 0.0 - 0.5
|
|
|
|
# Short premium domains get exponential boost
|
|
if len(name) <= 3:
|
|
length_mult *= 5
|
|
elif len(name) <= 4:
|
|
length_mult *= 3
|
|
elif len(name) <= 5:
|
|
length_mult *= 2
|
|
|
|
# Calculate estimated value
|
|
estimated_value = self.base_value * length_mult * tld_mult * (1 + keyword_bonus + brand_bonus)
|
|
|
|
# Apply caps
|
|
estimated_value = max(5, min(estimated_value, 1000000)) # $5 - $1M
|
|
|
|
# Round to reasonable precision
|
|
if estimated_value < 100:
|
|
estimated_value = round(estimated_value)
|
|
elif estimated_value < 1000:
|
|
estimated_value = round(estimated_value / 10) * 10
|
|
elif estimated_value < 10000:
|
|
estimated_value = round(estimated_value / 100) * 100
|
|
else:
|
|
estimated_value = round(estimated_value / 1000) * 1000
|
|
|
|
result = {
|
|
"domain": domain,
|
|
"estimated_value": estimated_value,
|
|
"currency": "USD",
|
|
"scores": {
|
|
"length": length_score,
|
|
"tld": tld_score,
|
|
"keyword": keyword_score,
|
|
"brandability": brandability_score,
|
|
"overall": round((length_score + tld_score + keyword_score + brandability_score) / 4),
|
|
},
|
|
"factors": {
|
|
"length": len(name),
|
|
"tld": tld,
|
|
"has_numbers": bool(re.search(r"\d", name)),
|
|
"has_hyphens": "-" in name,
|
|
"is_dictionary_word": self._is_common_word(name),
|
|
},
|
|
"confidence": self._calculate_confidence(length_score, tld_score, keyword_score, brandability_score),
|
|
"source": "internal",
|
|
"calculated_at": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
# Save to database if requested
|
|
if save_result and db:
|
|
try:
|
|
valuation = DomainValuation(
|
|
domain=domain,
|
|
estimated_value=estimated_value,
|
|
length_score=length_score,
|
|
tld_score=tld_score,
|
|
keyword_score=keyword_score,
|
|
brandability_score=brandability_score,
|
|
source="internal",
|
|
)
|
|
db.add(valuation)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to save valuation: {e}")
|
|
|
|
return result
|
|
|
|
def _calculate_length_score(self, name: str) -> int:
|
|
"""Calculate score based on domain length (shorter = better)."""
|
|
length = len(name)
|
|
|
|
if length <= 2:
|
|
return 100
|
|
elif length <= 3:
|
|
return 95
|
|
elif length <= 4:
|
|
return 85
|
|
elif length <= 5:
|
|
return 75
|
|
elif length <= 6:
|
|
return 65
|
|
elif length <= 7:
|
|
return 55
|
|
elif length <= 8:
|
|
return 45
|
|
elif length <= 10:
|
|
return 35
|
|
elif length <= 15:
|
|
return 25
|
|
elif length <= 20:
|
|
return 15
|
|
else:
|
|
return 5
|
|
|
|
def _calculate_tld_score(self, tld: str) -> int:
|
|
"""Calculate score based on TLD value."""
|
|
value = TLD_VALUES.get(tld, TLD_VALUES["_default"])
|
|
return int(value * 100)
|
|
|
|
def _calculate_keyword_score(self, name: str) -> int:
|
|
"""Calculate score based on keyword value."""
|
|
name_lower = name.lower()
|
|
score = 0
|
|
|
|
# Check for high-value keywords
|
|
for keyword in HIGH_VALUE_KEYWORDS:
|
|
if keyword in name_lower:
|
|
score += 30
|
|
break
|
|
|
|
# Bonus for exact keyword match
|
|
if name_lower in HIGH_VALUE_KEYWORDS:
|
|
score += 50
|
|
|
|
# Penalty for numbers
|
|
if re.search(r"\d", name):
|
|
score -= 20
|
|
|
|
# Penalty for hyphens
|
|
if "-" in name:
|
|
score -= 30
|
|
|
|
# Bonus for being a common word
|
|
if self._is_common_word(name):
|
|
score += 40
|
|
|
|
return max(0, min(100, score))
|
|
|
|
def _calculate_brandability_score(self, name: str) -> int:
|
|
"""Calculate brandability score."""
|
|
score = 50 # Start neutral
|
|
|
|
# Bonus for pronounceable names
|
|
if self._is_pronounceable(name):
|
|
score += 20
|
|
|
|
# Bonus for memorable length
|
|
if 4 <= len(name) <= 8:
|
|
score += 15
|
|
|
|
# Penalty for hard-to-spell patterns
|
|
if re.search(r"(.)\1{2,}", name): # Triple letters
|
|
score -= 10
|
|
|
|
# Penalty for confusing patterns
|
|
if re.search(r"[0oO][1lI]|[1lI][0oO]", name): # 0/O or 1/l confusion
|
|
score -= 15
|
|
|
|
# Bonus for all letters
|
|
if name.isalpha():
|
|
score += 10
|
|
|
|
# Penalty for too many consonants in a row
|
|
if re.search(r"[bcdfghjklmnpqrstvwxyz]{5,}", name.lower()):
|
|
score -= 15
|
|
|
|
return max(0, min(100, score))
|
|
|
|
def _is_pronounceable(self, name: str) -> bool:
|
|
"""Check if a name is likely pronounceable."""
|
|
vowels = set("aeiou")
|
|
name_lower = name.lower()
|
|
|
|
# Must have at least one vowel
|
|
if not any(c in vowels for c in name_lower):
|
|
return False
|
|
|
|
# Check vowel distribution
|
|
vowel_count = sum(1 for c in name_lower if c in vowels)
|
|
vowel_ratio = vowel_count / len(name) if name else 0
|
|
|
|
return 0.2 <= vowel_ratio <= 0.6
|
|
|
|
def _is_common_word(self, name: str) -> bool:
|
|
"""Check if name is a common English word."""
|
|
# Simplified check - in production, use a dictionary API
|
|
common_words = {
|
|
"app", "web", "net", "dev", "code", "tech", "data", "cloud",
|
|
"shop", "store", "buy", "sell", "pay", "cash", "money",
|
|
"game", "play", "fun", "cool", "best", "top", "pro",
|
|
"home", "life", "love", "care", "help", "work", "job",
|
|
"news", "blog", "post", "chat", "talk", "meet", "link",
|
|
"fast", "quick", "smart", "easy", "simple", "free",
|
|
}
|
|
return name.lower() in common_words
|
|
|
|
def _calculate_confidence(self, *scores: int) -> str:
|
|
"""Calculate confidence level based on score consistency."""
|
|
avg = sum(scores) / len(scores)
|
|
variance = sum((s - avg) ** 2 for s in scores) / len(scores)
|
|
|
|
if variance < 100 and avg > 60:
|
|
return "high"
|
|
elif variance < 200 and avg > 40:
|
|
return "medium"
|
|
else:
|
|
return "low"
|
|
|
|
async def get_historical_valuations(
|
|
self,
|
|
domain: str,
|
|
db: AsyncSession,
|
|
limit: int = 10,
|
|
) -> list:
|
|
"""Get historical valuations for a domain."""
|
|
result = await db.execute(
|
|
select(DomainValuation)
|
|
.where(DomainValuation.domain == domain.lower())
|
|
.order_by(DomainValuation.created_at.desc())
|
|
.limit(limit)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
# Singleton instance
|
|
valuation_service = DomainValuationService()
|
|
|