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:
@ -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),
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user