rmi-backend/tests/test_rug_imminence_predictor.py

409 lines
14 KiB
Python

"""
Tests for Rug Pull Imminence Predictor (RIP)
"""
from unittest.mock import AsyncMock, patch
import pytest
from app.rug_imminence_predictor import (
DEFAULT_WEIGHTS,
ImminenceLevel,
RIPResult,
RugImminencePredictor,
SignalCategory,
get_predictor,
)
@pytest.fixture
def predictor():
return RugImminencePredictor()
class TestSignalCategory:
"""SignalCategory dataclass tests."""
def test_weighted_contribution(self):
sig = SignalCategory(name="test", weight=0.25, score=80)
assert sig.weighted_contribution() == 20.0
def test_zero_score(self):
sig = SignalCategory(name="test", weight=0.5, score=0)
assert sig.weighted_contribution() == 0.0
def test_default_values(self):
sig = SignalCategory(name="test", weight=0.5)
assert sig.score == 0.0
assert sig.confidence == 0.0
assert sig.evidence == []
assert sig.flags == []
class TestRIPResult:
"""RIPResult dataclass tests."""
def test_low_imminence(self):
result = RIPResult(
token_address="0x1234", chain="base", score=20, imminence=ImminenceLevel.LOW
)
assert "LOW" in result.summary()
assert "🟢" in result.summary()
def test_critical_imminence(self):
result = RIPResult(
token_address="0x1234", chain="base", score=85, imminence=ImminenceLevel.CRITICAL
)
assert "CRITICAL" in result.summary()
assert "🔴" in result.summary()
def test_summary_with_symbol(self):
result = RIPResult(
token_address="0x1234",
chain="base",
score=45,
token_symbol="RUG",
imminence=ImminenceLevel.MEDIUM,
)
assert "RUG" in result.summary()
def test_narrative_includes_score(self):
result = RIPResult(
token_address="0x1234",
chain="base",
score=67,
token_name="RugCoin",
imminence=ImminenceLevel.HIGH,
last_updated="2026-06-14T12:00:00",
scan_duration_ms=1234.5,
)
narrative = result.narrative()
assert "67" in narrative
assert "HIGH" in narrative
assert "RugCoin" in narrative
def test_narrative_with_warnings(self):
result = RIPResult(
token_address="0x1234",
chain="base",
score=80,
imminence=ImminenceLevel.CRITICAL,
warnings=["HONEYPOT detected"],
)
narrative = result.narrative()
assert "HONEYPOT" in narrative
def test_narrative_recommendations(self):
result = RIPResult(
token_address="0x1234",
chain="base",
score=80,
imminence=ImminenceLevel.CRITICAL,
recommendations=["EXIT NOW"],
)
narrative = result.narrative()
assert "EXIT NOW" in narrative
def test_to_dict(self):
result = RIPResult(
token_address="0x1234",
chain="base",
score=75,
token_name="BadToken",
token_symbol="BAD",
imminence=ImminenceLevel.CRITICAL,
warnings=["Warning 1"],
recommendations=["Sell now"],
)
d = result.to_dict()
assert d["score"] == 75.0
assert d["imminence"] == "critical"
assert d["verdict"] == "CRITICAL"
assert d["warnings"] == ["Warning 1"]
assert d["recommendations"] == ["Sell now"]
def test_to_dict_includes_signals(self):
result = RIPResult(
token_address="0x1234",
chain="base",
score=50,
imminence=ImminenceLevel.MEDIUM,
)
result.signals["lp_health"] = SignalCategory(
name="LP Health",
weight=0.25,
score=80,
evidence=["LP unlocked"],
)
d = result.to_dict()
assert "lp_health" in d["signals"]
assert d["signals"]["lp_health"]["score"] == 80.0
assert d["signals"]["lp_health"]["evidence"] == ["LP unlocked"]
class TestRugImminencePredictor:
"""Main predictor tests."""
def test_init_default_weights(self):
p = RugImminencePredictor()
total = sum(p.weights.values())
assert abs(total - 1.0) < 0.01
assert len(p.weights) == 7
assert "lp_health" in p.weights
assert p.weights["lp_health"] == 0.25
def test_init_custom_weights(self):
weights = {"lp_health": 0.5, "deployer_risk": 0.5}
p = RugImminencePredictor(weights)
assert abs(sum(p.weights.values()) - 1.0) < 0.01
def test_verify_weights_normalizes(self):
weights = {"a": 0.8, "b": 0.8} # Sums to 1.6
p = RugImminencePredictor(weights)
assert abs(sum(p.weights.values()) - 1.0) < 0.01
assert p.weights["a"] == 0.5
def test_cache_key(self):
p = RugImminencePredictor()
key = p._cache_key("0xABC", "base")
assert key == "base:0xabc"
def test_cache_lifecycle(self, predictor):
result = RIPResult(
token_address="0x1234", chain="base", score=30, imminence=ImminenceLevel.LOW
)
predictor._set_cache(result)
cached = predictor._get_cached("0x1234", "base")
assert cached is not None
assert cached.score == 30
@pytest.mark.asyncio
async def test_predict_returns_result(self, predictor):
with patch.object(predictor, "_analyze_lp_health", new=AsyncMock()):
with patch.object(predictor, "_analyze_deployer_risk", new=AsyncMock()):
with patch.object(predictor, "_analyze_smart_money_flow", new=AsyncMock()):
with patch.object(predictor, "_analyze_bundle_pattern", new=AsyncMock()):
with patch.object(predictor, "_analyze_social_velocity", new=AsyncMock()):
with patch.object(predictor, "_analyze_contract_risk", new=AsyncMock()):
with patch.object(
predictor, "_analyze_liquidity_migration", new=AsyncMock()
):
result = await predictor.predict(
"0x1234567890123456789012345678901234567890", "base"
)
assert isinstance(result, RIPResult)
assert result.token_address == "0x1234567890123456789012345678901234567890"
assert result.chain == "base"
assert result.score >= 0
assert isinstance(result.imminence, ImminenceLevel)
assert result.scan_duration_ms > 0
def test_generate_warnings_no_flags(self):
result = RIPResult(
token_address="0x1", chain="base", score=20, imminence=ImminenceLevel.LOW
)
predictor = RugImminencePredictor()
predictor._generate_warnings(result)
assert any("No imminent rug" in w for w in result.warnings)
def test_generate_warnings_with_flags(self):
result = RIPResult(
token_address="0x1", chain="base", score=80, imminence=ImminenceLevel.HIGH
)
result.signals["lp_health"] = SignalCategory(
name="LP Health",
weight=0.25,
score=80,
flags=["honeypot"],
)
predictor = RugImminencePredictor()
predictor._generate_warnings(result)
assert any("HONEYPOT" in w for w in result.warnings)
def test_generate_recommendations_priority(self):
result = RIPResult(
token_address="0x1", chain="base", score=85, imminence=ImminenceLevel.CRITICAL
)
result.signals["lp_health"] = SignalCategory(
name="LP Health",
weight=0.25,
score=100,
flags=["lp_removed"],
)
predictor = RugImminencePredictor()
predictor._generate_recommendations(result)
assert any("URGENT" in r for r in result.recommendations)
assert any("EXIT" in r for r in result.recommendations)
@pytest.mark.asyncio
async def test_lp_health_low_liquidity(self, predictor):
result = RIPResult(token_address="0xdead", chain="base")
for sig_id, weight in DEFAULT_WEIGHTS.items():
result.signals[sig_id] = SignalCategory(
name=sig_id.replace("_", " ").title(),
weight=weight,
)
with patch.object(
predictor,
"_fetch_dexscreener_pool",
new=AsyncMock(
return_value={
"pairs": [
{
"liquidity": {"usd": 500},
"volume": {"h24": 100},
"txns": {"h24": {"buys": 5, "sells": 20}},
}
]
}
),
), patch.object(
predictor,
"_check_lp_lock",
new=AsyncMock(
return_value={"locked": False, "unlocked": True, "unlock_date": None}
),
):
await predictor._analyze_lp_health(result)
sig = result.signals["lp_health"]
assert sig.score > 0 # Should detect low liquidity + sell pressure
assert "critically_low_liquidity" in sig.flags
assert sig.evidence
@pytest.mark.asyncio
async def test_social_velocity_with_spike(self, predictor):
result = RIPResult(token_address="0xtoken", chain="base", token_symbol="SHILL")
for sig_id, weight in DEFAULT_WEIGHTS.items():
result.signals[sig_id] = SignalCategory(
name=sig_id.replace("_", " ").title(),
weight=weight,
)
with patch("app.scanners.social_velocity.SocialVelocityAnalyzer") as mock:
mock_instance = AsyncMock()
mock_instance.analyze = AsyncMock(
return_value={
"mention_spike_pct": 800,
"shill_score": 0.85,
"sentiment_shift": -0.5,
}
)
mock.return_value = mock_instance
await predictor._analyze_social_velocity(result)
sig = result.signals["social_velocity"]
assert sig.score >= 20 # Spike + shilling + sentiment drop
assert "massive_mention_spike" in sig.flags
@pytest.mark.asyncio
async def test_contract_risk_honeypot(self, predictor):
result = RIPResult(token_address="0xhoney", chain="base")
for sig_id, weight in DEFAULT_WEIGHTS.items():
result.signals[sig_id] = SignalCategory(
name=sig_id.replace("_", " ").title(),
weight=weight,
)
with patch("app.scanners.honeypot_detector.HoneypotDetector") as mock_hp:
mock_instance = AsyncMock()
mock_instance.detect = AsyncMock(
return_value={
"is_honeypot": True,
"reason": "Cannot sell - transfer tax > 90%",
}
)
mock_hp.return_value = mock_instance
with patch("app.scanners.contract_authority.ContractAuthorityScanner") as mock_auth:
auth_instance = AsyncMock()
auth_instance.scan = AsyncMock(
return_value={
"ownership_renounced": False,
"has_proxy": True,
}
)
mock_auth.return_value = auth_instance
await predictor._analyze_contract_risk(result)
sig = result.signals["contract_risk"]
assert "honeypot" in sig.flags
assert "active_ownership" in sig.flags
assert sig.score >= 40 # Honeypot + active ownership
def test_get_predictor_singleton(self):
p1 = get_predictor()
p2 = get_predictor()
assert p1 is p2
class TestIntegration:
"""Integration-level confidence tests (mock external deps)."""
@pytest.mark.asyncio
async def test_end_to_end_predict(self):
"""Full prediction pipeline with all external deps mocked."""
p = RugImminencePredictor()
result = RIPResult(token_address="0x1234", chain="base")
# Manually set known signal values for integration test
result.signals["lp_health"] = SignalCategory(
name="LP Health",
weight=0.25,
score=80,
evidence=["LP UNLOCKED"],
)
result.signals["deployer_risk"] = SignalCategory(
name="Deployer Risk",
weight=0.20,
score=70,
evidence=["Known rug deployer"],
)
result.signals["smart_money_flow"] = SignalCategory(
name="Smart Money Flow",
weight=0.15,
score=60,
evidence=["Insider selling"],
)
result.signals["bundle_pattern"] = SignalCategory(
name="Bundle Pattern",
weight=0.15,
score=50,
evidence=["Bundled launch"],
)
result.signals["social_velocity"] = SignalCategory(
name="Social Velocity",
weight=0.10,
score=40,
)
result.signals["contract_risk"] = SignalCategory(
name="Contract Risk",
weight=0.10,
score=30,
)
result.signals["liquidity_migration"] = SignalCategory(
name="Liquidity Migration",
weight=0.05,
score=20,
)
# Fuse
result.score = sum(sig.weighted_contribution() for sig in result.signals.values())
for level, (lo, hi) in [
(ImminenceLevel.LOW, (0, 30)),
(ImminenceLevel.MEDIUM, (30, 55)),
(ImminenceLevel.HIGH, (55, 75)),
(ImminenceLevel.CRITICAL, (75, 101)),
]:
if lo <= result.score < hi:
result.imminence = level
break
p._generate_warnings(result)
p._generate_recommendations(result)
# 80 * 0.25 + 70 * 0.20 + 60 * 0.15 + 50 * 0.15 + 40 * 0.10 + 30 * 0.10 + 20 * 0.05
# = 20 + 14 + 9 + 7.5 + 4 + 3 + 1 = 58.5
assert result.score == 58.5
assert result.imminence == ImminenceLevel.HIGH
assert len(result.warnings) > 0
assert len(result.recommendations) > 0