409 lines
14 KiB
Python
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
|