Skip to content

Apartment Rental Scoring Function Analysis

Date: 2026-01-18 Focus: Apartments for Rent (category=1) in top Riyadh districts

Analysis of 8,583 apartment rental listings in top Riyadh districts reveals that:

  1. CTR decays dramatically from 95% (day 0-3) to 21% (day 60+)
  2. CVR remains stable at 5-6% across all age buckets
  3. From Owner = 3x higher CVR (13.95% vs 4.8%)
  4. Favorites/Shares predict quality - 4x higher for top-converting listings

This suggests a scoring function that boosts fresh listings for 3-7 days, then ranks by engagement metrics (CTR, CVR, favorites, session rate).


District City Active Listings
النرجس (An Narjis) Riyadh 1,613
الملقا (Al Malqa) Riyadh 1,414
العارض (Al Arid) Riyadh 1,130
الرمال (Ar Rimal) Riyadh 752
الياسمين (Al Yasmin) Riyadh 718
العقيق (Al Aqiq) Riyadh 620
ظهرة لبن (Dhahrat Laban) Riyadh 606
المونسية (Al Munsiyah) Riyadh 600
اليرموك (Al Yarmuk) Riyadh 570
الحمرا (Al Hamra) Al Khobar 560

Age Listings Avg Impressions Avg Views CTR CVR Session Rate
0-3 days 268 440 45 95.4% 5.9% 23.5%
4-7 days 510 1,328 101 60.0% 5.8% 22.8%
8-14 days 651 2,880 211 39.9% 5.8% 23.4%
15-30 days 1,229 5,441 310 31.2% 5.6% 20.5%
31-60 days 1,732 10,798 581 26.2% 5.1% 19.9%
60+ days 4,193 33,488 1,310 20.8% 5.2% 18.0%

Key insight: CTR drops 4.6x from new to old listings, while CVR stays stable. The funnel bottleneck is at impressions→views, not views→contacts.

Days Since Created Avg VPD Boost Factor
0-1 days 33 3.5x
2-3 days 23.6 2.5x
4-7 days 12.5 1.3x
8-14 days 15 1.6x
15+ days 9.4 1.0x (baseline)
Attributes Listings CTR CVR
Has Images + From Owner 182 13.0% 13.95%
Has Images + Agent 4,615 21.4% 4.8%
No Images + From Owner 11 39.0% 12.46%
No Images + Agent 351 42.4% 6.3%

Owner listings have 2.9x higher CVR (13.95% vs 4.8%) despite lower CTR.

4. Engagement Signals Correlate with Quality

Section titled “4. Engagement Signals Correlate with Quality”
CVR Bucket Listings Avg Age CTR Session Rate Favorite Rate Share Rate
10%+ (High) 571 70 41.4% 32.4% 11.43% 3.01%
5-10% (Mid) 1,528 85 28.1% 22.6% 7.87% 1.88%
2-5% (Low) 1,924 92 23.1% 17.2% 5.05% 1.26%
<2% (V.Low) 1,072 76 25.6% 12.8% 2.82% 0.81%

Favorites and shares are 4x higher for top-converting listings!

Contact Method Share
WhatsApp 71.7%
Calls 25.5%
Messages 2.7%

  1. Freshness Boost (Days 0-7): New listings get temporary boost to collect engagement data
  2. Engagement-Based Ranking (Day 7+): Rank by normalized engagement metrics
  3. Quality Signals: CTR, CVR, session rate, favorites, shares
  4. Owner Boost: Owner listings get explicit boost due to higher CVR
def freshness_boost(days_since_created):
if days_since_created <= 1:
return 3.5 # Day 0-1: maximum boost
elif days_since_created <= 3:
return 2.5 # Day 2-3: high boost
elif days_since_created <= 7:
return 1.5 # Day 4-7: moderate boost
else:
return 1.0 # Day 8+: no boost

Engagement Score (for listings with sufficient data)

Section titled “Engagement Score (for listings with sufficient data)”
def engagement_score(listing):
# Normalize each metric to 0-1 range within segment
ctr_score = normalize(listing.ctr, segment_ctr_p10, segment_ctr_p90)
cvr_score = normalize(listing.cvr, segment_cvr_p10, segment_cvr_p90)
session_score = normalize(listing.session_rate, segment_session_p10, segment_session_p90)
favorite_score = normalize(listing.favorite_rate, segment_fav_p10, segment_fav_p90)
# Weighted combination (weights based on correlation with CVR)
return (
0.25 * ctr_score + # CTR: 1.6x correlation
0.30 * cvr_score + # CVR: direct conversion metric
0.20 * session_score + # Session: 2.5x correlation
0.25 * favorite_score # Favorites: 4x correlation
)
def listing_score(listing):
days = days_since_created(listing)
# Base score
if days <= 7:
# New listings: use freshness boost
base_score = freshness_boost(days)
else:
# Established listings: use engagement score
base_score = engagement_score(listing)
# Apply multipliers
score = base_score
score *= 1.0 + (0.3 * listing.from_owner) # +30% for owner
score *= 1.0 + (0.2 * listing.has_images) # +20% for images
score *= 1.0 + (0.15 * listing.has_video) # +15% for video
score *= 1.0 + (2.0 * listing.is_premium) # +200% for premium
return score
-- Calculate listing score
SELECT
l.id,
l.district_id,
days_since_created,
-- Freshness boost (first 7 days)
multiIf(
days_since_created <= 1,
3.5,
days_since_created <= 3,
2.5,
days_since_created <= 7,
1.5,
1.0
) AS freshness_boost,
-- Engagement score (normalized within segment)
(
0.25 * (ctr - segment_ctr_p10) / nullIf(segment_ctr_p90 - segment_ctr_p10, 0) + 0.30 * (cvr - segment_cvr_p10) / nullIf(segment_cvr_p90 - segment_cvr_p10, 0) + 0.20 * (session_rate - segment_session_p10) / nullIf(segment_session_p90 - segment_session_p10, 0) + 0.25 * (favorite_rate - segment_fav_p10) / nullIf(segment_fav_p90 - segment_fav_p10, 0)
) AS engagement_score,
-- Combined score
if(
days_since_created <= 7,
freshness_boost,
greatest(engagement_score, 0.1)
) * (1 + 0.3 * from_owner) * (1 + 0.2 * has_img) * (1 + 2.0 * is_premium) AS final_score
FROM
listings_with_metrics l
JOIN segment_percentiles p ON l.category = p.category
AND l.district_id = p.district_id
ORDER BY
final_score DESC

Before using engagement-based scoring, ensure sufficient data:

Metric Minimum Threshold
Impressions ≥ 100
Views ≥ 20
Days Active ≥ 7

For listings below thresholds, use freshness boost or segment average.


Metric Current State Expected After
Avg CTR (new listings) 95% (too high, wasted impressions) ~60% (more targeted)
Avg CVR (overall) 5.5% 6.5%+ (better matching)
New listing visibility Over-boosted day 0-3 Sustained boost day 0-7
High-quality listing rank Based on total views Based on engagement quality

Track these to validate the new scoring:

  1. CTR by age bucket - Should become more stable
  2. CVR by age bucket - Should improve for older listings
  3. Favorite rate vs rank - Should correlate positively
  4. From Owner performance - Should improve with explicit boost
  5. New listing success rate - % converting to high-engagement after 7 days

SELECT
age_bucket,
count() AS listings,
round(avg(total_impressions), 0) AS avg_impr,
round(avg(total_views), 0) AS avg_views,
round(avg(ctr) * 100, 2) AS avg_ctr_pct,
round(avg(cvr) * 100, 2) AS avg_cvr_pct
FROM
(
SELECT
l.id,
coalesce(sum(s.impressions), 0) AS total_impressions,
coalesce(sum(s.views), 0) AS total_views,
coalesce(sum(s.calls + s.whatsapp + s.msgs), 0) AS total_contacts,
total_views / greatest(total_impressions, 1) AS ctr,
total_contacts / greatest(total_views, 1) AS cvr,
dateDiff(
'day',
toDate(fromUnixTimestamp(l.create_time)),
today()
) AS days,
multiIf(
days <= 3,
'a_0-3d',
days <= 7,
'b_4-7d',
days <= 14,
'c_8-14d',
days <= 30,
'd_15-30d',
days <= 60,
'e_31-60d',
'f_60+d'
) AS age_bucket
FROM
sadb_listings l FINAL
LEFT JOIN sadb_stats_listings_stats s FINAL ON l.id = s.id
AND toDate(s.day_time) >= '2024-01-01'
WHERE
l.category = 1
AND l.district_id IN (600, 570, 494, 446, 632, 504, 688, 590, 634, 1070)
AND l.status IN (0, 4)
AND l.published = 1
AND l.hidden = 0
AND l.rega_licensed = 1
AND l._peerdb_is_deleted = 0
AND toDate(fromUnixTimestamp(l.create_time)) >= '2024-01-01'
GROUP BY
l.id,
days
)
GROUP BY
age_bucket
ORDER BY
age_bucket
SELECT
cvr_bucket,
count() AS listings,
round(avg(days), 0) AS avg_age,
round(avg(ctr) * 100, 1) AS avg_ctr,
round(avg(session_rate) * 100, 1) AS sess_rate,
round(avg(fav_rate) * 100, 2) AS fav_pct
FROM
(
SELECT
l.id,
dateDiff(
'day',
toDate(fromUnixTimestamp(l.create_time)),
today()
) AS days,
coalesce(sum(s.impressions), 0) AS impr,
coalesce(sum(s.views), 0) AS views,
coalesce(sum(s.calls + s.whatsapp + s.msgs), 0) AS contacts,
coalesce(sum(s.session_30_seconds), 0) AS sessions,
coalesce(sum(s.favorited), 0) AS favs,
views / greatest(impr, 1) AS ctr,
contacts / greatest(views, 1) AS cvr,
sessions / greatest(views, 1) AS session_rate,
favs / greatest(views, 1) AS fav_rate,
multiIf(
cvr >= 0.1,
'a_10%+',
cvr >= 0.05,
'b_5-10%',
cvr >= 0.02,
'c_2-5%',
'd_<2%'
) AS cvr_bucket
FROM
sadb_listings l FINAL
LEFT JOIN sadb_stats_listings_stats s FINAL ON l.id = s.id
AND toDate(s.day_time) >= '2024-01-01'
WHERE
l.category = 1
AND l.district_id IN (600, 570, 494, 446, 632)
AND l.status IN (0, 4)
AND l.published = 1
AND l.hidden = 0
AND l.rega_licensed = 1
AND l._peerdb_is_deleted = 0
GROUP BY
l.id,
days
HAVING
views >= 50
)
GROUP BY
cvr_bucket
ORDER BY
cvr_bucket