fix(auctions): prevent time drift + consistent now

- Clamp end_time on updates to prevent drift from rounded time-left sources (e.g. Sav)
- Use a single request 'now' for time_remaining formatting across endpoints
- Avoid 'Ended' flicker caused by processing delays
This commit is contained in:
2025-12-11 22:14:18 +01:00
parent 325a684809
commit 675b857323
2 changed files with 30 additions and 13 deletions

View File

@ -161,11 +161,14 @@ class MarketFeedResponse(BaseModel):
# ============== Helper Functions ==============
def _format_time_remaining(end_time: datetime) -> str:
def _format_time_remaining(end_time: datetime, now: Optional[datetime] = None) -> str:
"""Format time remaining in human-readable format."""
delta = end_time - datetime.utcnow()
ref = now or datetime.utcnow()
delta = end_time - ref
if delta.total_seconds() <= 0:
# Small grace window to avoid displaying "Ended" due to request processing time.
# If an auction ends within the next ~2 seconds, we show "0m".
if delta.total_seconds() <= -2:
return "Ended"
hours = int(delta.total_seconds() // 3600)
@ -177,7 +180,7 @@ def _format_time_remaining(end_time: datetime) -> str:
elif hours > 0:
return f"{hours}h {minutes}m"
else:
return f"{minutes}m"
return f"{max(minutes, 0)}m"
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
@ -203,7 +206,8 @@ def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
async def _convert_to_listing(
auction: DomainAuction,
db: AsyncSession,
include_valuation: bool = True
include_valuation: bool = True,
now: Optional[datetime] = None,
) -> AuctionListing:
"""Convert database auction to API response."""
valuation_data = None
@ -234,7 +238,7 @@ async def _convert_to_listing(
currency=auction.currency,
num_bids=auction.num_bids,
end_time=auction.end_time,
time_remaining=_format_time_remaining(auction.end_time),
time_remaining=_format_time_remaining(auction.end_time, now=now),
buy_now_price=auction.buy_now_price,
reserve_met=auction.reserve_met,
traffic=auction.traffic,
@ -383,7 +387,7 @@ async def search_auctions(
# Convert to response with valuations
listings = []
for auction in auctions:
listing = await _convert_to_listing(auction, db, include_valuation=True)
listing = await _convert_to_listing(auction, db, include_valuation=True, now=now)
listings.append(listing)
# Sort by value_ratio if requested (after valuation)
@ -426,7 +430,8 @@ async def get_ending_soon(
Data is scraped from public auction sites - no mock data.
"""
cutoff = datetime.utcnow() + timedelta(hours=hours)
now = datetime.utcnow()
cutoff = now + timedelta(hours=hours)
query = (
select(DomainAuction)
@ -434,7 +439,7 @@ async def get_ending_soon(
and_(
DomainAuction.is_active == True,
DomainAuction.end_time <= cutoff,
DomainAuction.end_time > datetime.utcnow(),
DomainAuction.end_time > now,
)
)
.order_by(DomainAuction.end_time.asc())
@ -446,7 +451,7 @@ async def get_ending_soon(
listings = []
for auction in auctions:
listing = await _convert_to_listing(auction, db, include_valuation=True)
listing = await _convert_to_listing(auction, db, include_valuation=True, now=now)
listings.append(listing)
return listings
@ -481,7 +486,7 @@ async def get_hot_auctions(
listings = []
for auction in auctions:
listing = await _convert_to_listing(auction, db, include_valuation=True)
listing = await _convert_to_listing(auction, db, include_valuation=True, now=now)
listings.append(listing)
return listings
@ -716,7 +721,7 @@ async def get_smart_opportunities(
if opportunity_score < 3:
continue
listing = await _convert_to_listing(auction, db, include_valuation=False)
listing = await _convert_to_listing(auction, db, include_valuation=False, now=now)
recommendation = (
"🔥 Hot" if opportunity_score >= 10 else
@ -1064,7 +1069,7 @@ async def get_market_feed(
source=auction.platform,
is_pounce=False,
verified=False,
time_remaining=_format_time_remaining(auction.end_time),
time_remaining=_format_time_remaining(auction.end_time, now=now),
end_time=auction.end_time,
num_bids=auction.num_bids,
url=_get_affiliate_url(auction.platform, auction.domain, auction.auction_url),

View File

@ -312,6 +312,18 @@ class AuctionScraperService:
existing = existing.scalar_one_or_none()
if existing:
# Prevent "end_time drift" on sources that only provide rounded time-left.
# `end_time` must be monotonically decreasing (or stable) across scrapes.
try:
incoming_end = cleaned.get("end_time")
if isinstance(incoming_end, datetime) and existing.end_time:
# Allow tiny increases due to rounding/clock skew, but never extend materially.
tolerance = timedelta(minutes=2)
if incoming_end > existing.end_time + tolerance:
cleaned["end_time"] = existing.end_time
except Exception:
pass
for key, value in cleaned.items():
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()