fix(trust): treat feedback_ratio=0.0 as missing data for buyer-only/returning sellers (#52)
eBay omits the 12-month positive percentage for returning sellers and buyer-only accounts with no recent sales. Previously ratio=0.0 with count>0 triggered established_bad_actor; now it returns None from the scorer (score_is_partial=True) and emits a soft no_recent_seller_data flag instead. ratio=0.0 with count=0 is still treated as no-history.
This commit is contained in:
parent
89d3862f62
commit
bccedb1fe5
4 changed files with 70 additions and 2 deletions
|
|
@ -126,7 +126,12 @@ class Aggregator:
|
||||||
# Hard filters
|
# Hard filters
|
||||||
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
|
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
|
||||||
red_flags.append("new_account")
|
red_flags.append("new_account")
|
||||||
if seller and seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD:
|
if seller and seller.feedback_ratio == 0.0 and seller.feedback_count > 0:
|
||||||
|
# 12-month ratio missing from page — returning seller or buyer-only account.
|
||||||
|
# Score will be partial (metadata._feedback_ratio returns None). Soft flag
|
||||||
|
# only: do NOT fire established_bad_actor on what is likely missing data.
|
||||||
|
red_flags.append("no_recent_seller_data")
|
||||||
|
elif seller and seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD:
|
||||||
if HARD_FILTER_BAD_RATIO_MIN_COUNT < seller.feedback_count <= HARD_FILTER_BAD_RATIO_MAX_COUNT:
|
if HARD_FILTER_BAD_RATIO_MIN_COUNT < seller.feedback_count <= HARD_FILTER_BAD_RATIO_MAX_COUNT:
|
||||||
# Moderate-volume account with consistently bad ratio → hard flag.
|
# Moderate-volume account with consistently bad ratio → hard flag.
|
||||||
red_flags.append("established_bad_actor")
|
red_flags.append("established_bad_actor")
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,13 @@ class MetadataScorer:
|
||||||
if count < 200: return 15
|
if count < 200: return 15
|
||||||
return 20
|
return 20
|
||||||
|
|
||||||
def _feedback_ratio(self, ratio: float, count: int) -> int:
|
def _feedback_ratio(self, ratio: float, count: int) -> Optional[int]:
|
||||||
|
# ratio=0.0 with count>0 means the 12-month percentage wasn't on the page —
|
||||||
|
# eBay omits the ratio for returning/buyer-only sellers with no recent sales.
|
||||||
|
# Treat as missing rather than "literally 0% positive" (which eBay doesn't allow
|
||||||
|
# on active accounts — those get suspended long before reaching 0%).
|
||||||
|
if ratio == 0.0 and count > 0:
|
||||||
|
return None
|
||||||
if ratio < 0.80 and count > 20: return 0
|
if ratio < 0.80 and count > 20: return 0
|
||||||
if ratio < 0.90: return 5
|
if ratio < 0.90: return 5
|
||||||
if ratio < 0.95: return 10
|
if ratio < 0.95: return 10
|
||||||
|
|
|
||||||
|
|
@ -296,3 +296,37 @@ def test_non_retailer_does_not_suppress_duplicate_photo():
|
||||||
)
|
)
|
||||||
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=seller)
|
result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=seller)
|
||||||
assert "duplicate_photo" in result.red_flags_json
|
assert "duplicate_photo" in result.red_flags_json
|
||||||
|
|
||||||
|
|
||||||
|
# ── #52: buyer-only / returning seller (ratio=0.0, count>0) ──────────────────
|
||||||
|
|
||||||
|
def test_zero_ratio_with_count_gives_no_recent_seller_data_flag():
|
||||||
|
"""Seller with 117 lifetime feedbacks (buyer-only) has ratio=0.0 parsed from page.
|
||||||
|
Must get no_recent_seller_data soft flag, NOT established_bad_actor."""
|
||||||
|
agg = Aggregator()
|
||||||
|
scores = {k: 10 for k in ["account_age", "feedback_count",
|
||||||
|
"feedback_ratio", "price_vs_market", "category_history"]}
|
||||||
|
buyer_only = Seller(
|
||||||
|
platform="ebay", platform_seller_id="u", username="jjcpryz",
|
||||||
|
account_age_days=1200, feedback_count=117, feedback_ratio=0.0,
|
||||||
|
category_history_json="{}",
|
||||||
|
)
|
||||||
|
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=buyer_only)
|
||||||
|
assert "no_recent_seller_data" in result.red_flags_json
|
||||||
|
assert "established_bad_actor" not in result.red_flags_json
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_established_bad_actor_still_fires_for_genuinely_bad_ratio():
|
||||||
|
"""ratio=0.75 (not zero) with moderate count → established_bad_actor still fires."""
|
||||||
|
agg = Aggregator()
|
||||||
|
scores = {k: 10 for k in ["account_age", "feedback_count",
|
||||||
|
"feedback_ratio", "price_vs_market", "category_history"]}
|
||||||
|
bad = Seller(
|
||||||
|
platform="ebay", platform_seller_id="u", username="u",
|
||||||
|
account_age_days=500, feedback_count=100, feedback_ratio=0.75,
|
||||||
|
category_history_json="{}",
|
||||||
|
)
|
||||||
|
result = agg.aggregate(scores, photo_hash_duplicate=False, seller=bad)
|
||||||
|
assert "established_bad_actor" in result.red_flags_json
|
||||||
|
assert "no_recent_seller_data" not in result.red_flags_json
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,26 @@ def test_no_market_data_returns_none():
|
||||||
scores = scorer.score(_seller(), market_median=None, listing_price=950.0)
|
scores = scorer.score(_seller(), market_median=None, listing_price=950.0)
|
||||||
# None signals "data unavailable" — aggregator will set score_is_partial=True
|
# None signals "data unavailable" — aggregator will set score_is_partial=True
|
||||||
assert scores["price_vs_market"] is None
|
assert scores["price_vs_market"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_ratio_with_nonzero_count_returns_none():
|
||||||
|
"""ratio=0.0 with count>0 means eBay didn't show a 12-month percentage.
|
||||||
|
Must return None (missing data) not 0 (catastrophically bad)."""
|
||||||
|
scorer = MetadataScorer()
|
||||||
|
scores = scorer.score(
|
||||||
|
_seller(feedback_ratio=0.0, feedback_count=117),
|
||||||
|
market_median=None, listing_price=500.0,
|
||||||
|
)
|
||||||
|
assert scores["feedback_ratio"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_ratio_with_zero_count_scores_low():
|
||||||
|
"""feedback_ratio=0.0 with count=0 is a real 'no data at all' case, not missing."""
|
||||||
|
scorer = MetadataScorer()
|
||||||
|
scores = scorer.score(
|
||||||
|
_seller(feedback_ratio=0.0, feedback_count=0),
|
||||||
|
market_median=None, listing_price=500.0,
|
||||||
|
)
|
||||||
|
# count=0 means zero_feedback; ratio=0 with count=0 is the standard no-history path
|
||||||
|
# (not the "missing 12-month window" path)
|
||||||
|
assert scores["feedback_ratio"] == 5 # ratio < 0.90 → 5
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue