| """ |
| Tests for pump_dump_manipulation_detector.py |
| """ |
|
|
| import sys |
| from pathlib import Path |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from app.pump_dump_manipulation_detector import ( |
| PUMP_DUMP_THRESHOLDS, |
| CoordinatedBuyGroup, |
| FindingSeverity, |
| ManipulationFinding, |
| ManipulationType, |
| PrePumpAccumulation, |
| PricePumpSignal, |
| PumpDumpAnalysisResult, |
| PumpDumpDetector, |
| VolumeAnomaly, |
| WashTradeCluster, |
| ) |
|
|
|
|
| def test_manipulation_type_enum() -> None: |
| """Test ManipulationType enum values.""" |
| assert ManipulationType.COORDINATED_PUMP.value == "coordinated_pump" |
| assert ManipulationType.WASH_TRADING.value == "wash_trading" |
| assert ManipulationType.VOLUME_SPIKE.value == "volume_spike" |
| assert ManipulationType.PRICE_PUMP.value == "price_pump" |
| assert ManipulationType.PRE_PUMP_ACCUMULATION.value == "pre_pump_accumulation" |
| assert ManipulationType.LIFECYCLE_MATCH.value == "lifecycle_match" |
| assert ManipulationType.SOCIAL_COORDINATION.value == "social_coordination" |
| assert ManipulationType.POST_PUMP_DISTRIBUTION.value == "post_pump_distribution" |
| assert len(ManipulationType) == 8 |
|
|
|
|
| def test_finding_severity_enum() -> None: |
| """Test FindingSeverity enum values.""" |
| assert FindingSeverity.CRITICAL.value == "critical" |
| assert FindingSeverity.HIGH.value == "high" |
| assert FindingSeverity.MEDIUM.value == "medium" |
| assert FindingSeverity.LOW.value == "low" |
| assert FindingSeverity.INFO.value == "info" |
| assert len(FindingSeverity) == 5 |
|
|
|
|
| def test_manipulation_finding_creation() -> None: |
| """Test ManipulationFinding dataclass creation and serialization.""" |
| finding = ManipulationFinding( |
| finding_type=ManipulationType.COORDINATED_PUMP, |
| severity=FindingSeverity.HIGH, |
| description="Coordinated buy group detected: 5 wallets bought $50k in 60s", |
| detail="Fresh wallets: 3/5", |
| evidence={"wallet_count": 5, "total_usd": 50000.0}, |
| ) |
| d = finding.to_dict() |
| assert d["type"] == "coordinated_pump" |
| assert d["severity"] == "high" |
| assert d["description"].startswith("Coordinated buy group") |
| assert d["evidence"]["wallet_count"] == 5 |
|
|
|
|
| def test_manipulation_finding_defaults() -> None: |
| """Test ManipulationFinding with default values.""" |
| finding = ManipulationFinding( |
| finding_type=ManipulationType.VOLUME_SPIKE, |
| severity=FindingSeverity.MEDIUM, |
| description="Volume spike detected", |
| ) |
| assert finding.detail == "" |
| assert finding.evidence == {} |
|
|
|
|
| def test_coordinated_buy_group() -> None: |
| """Test CoordinatedBuyGroup dataclass.""" |
| group = CoordinatedBuyGroup( |
| wallets=["wallet1", "wallet2", "wallet3"], |
| window_seconds=60, |
| total_buy_usd=10000.0, |
| fresh_wallet_count=2, |
| block_number=12345, |
| timestamp=1700000000, |
| chain="ethereum", |
| ) |
| d = group.to_dict() |
| assert d["wallets"][0] == "wallet1" |
| assert len(d["wallets"]) == 3 |
| assert d["total_buy_usd"] == 10000.0 |
| assert d["chain"] == "ethereum" |
|
|
|
|
| def test_volume_anomaly() -> None: |
| """Test VolumeAnomaly dataclass.""" |
| anomaly = VolumeAnomaly( |
| current_volume_usd=100000.0, |
| avg_24h_volume_usd=5000.0, |
| spike_ratio=20.0, |
| time_window="1h", |
| confidence=0.85, |
| ) |
| d = anomaly.to_dict() |
| assert d["spike_ratio"] == 20.0 |
| assert d["time_window"] == "1h" |
| assert d["confidence"] == 0.85 |
|
|
|
|
| def test_wash_trade_cluster() -> None: |
| """Test WashTradeCluster dataclass.""" |
| cluster = WashTradeCluster( |
| wallets=["a", "b", "c"], |
| volume_created_usd=25000.0, |
| trade_count=12, |
| circular_trades=6, |
| volume_pct_of_total=35.0, |
| ) |
| d = cluster.to_dict() |
| assert d["volume_pct_of_total"] == 35.0 |
| assert d["circular_trades"] == 6 |
|
|
|
|
| def test_price_pump_signal() -> None: |
| """Test PricePumpSignal dataclass.""" |
| signal = PricePumpSignal( |
| price_before_pump=0.001, |
| price_peak=0.005, |
| pump_pct=400.0, |
| current_price=0.003, |
| duration_seconds=3600, |
| dump_pct_from_peak=40.0, |
| ) |
| d = signal.to_dict() |
| assert d["pump_pct"] == 400.0 |
| assert d["dump_pct_from_peak"] == 40.0 |
|
|
|
|
| def test_pre_pump_accumulation() -> None: |
| """Test PrePumpAccumulation dataclass.""" |
| accum = PrePumpAccumulation( |
| wallets=["acc1", "acc2"], |
| total_accumulated_usd=15000.0, |
| accumulation_period_hours=6, |
| avg_entry_price=0.0005, |
| timing_gap_minutes=45, |
| ) |
| d = accum.to_dict() |
| assert d["timing_gap_minutes"] == 45 |
| assert d["accumulation_period_hours"] == 6 |
|
|
|
|
| def test_pump_dump_analysis_result_defaults() -> None: |
| """Test PumpDumpAnalysisResult default values.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xabc123", |
| chain="ethereum", |
| token_symbol="TEST", |
| token_name="Test Token", |
| risk_score=0.0, |
| risk_level="low", |
| ) |
| assert result.error is None |
| assert result.findings == [] |
| assert result.coordinated_groups == [] |
| assert result.volume_anomalies == [] |
| assert result.wash_trade_clusters == [] |
| assert result.pre_pump_accumulations == [] |
|
|
|
|
| def test_pump_dump_analysis_result_to_dict() -> None: |
| """Test PumpDumpAnalysisResult serialization.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xabc", |
| chain="ethereum", |
| token_symbol="TEST", |
| token_name="Test Token", |
| risk_score=75.0, |
| risk_level="high", |
| ) |
| result.findings.append( |
| ManipulationFinding( |
| finding_type=ManipulationType.COORDINATED_PUMP, |
| severity=FindingSeverity.HIGH, |
| description="Coordinated buy group", |
| ) |
| ) |
| d = result.to_dict() |
| assert d["token_symbol"] == "TEST" |
| assert d["risk_score"] == 75.0 |
| assert d["risk_level"] == "high" |
| assert len(d["findings"]) == 1 |
| assert d["findings"][0]["type"] == "coordinated_pump" |
|
|
|
|
| def test_pump_dump_analysis_result_with_error() -> None: |
| """Test result with error.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xdead", |
| chain="ethereum", |
| token_symbol="?", |
| token_name="?", |
| risk_score=0.0, |
| risk_level="error", |
| error="No trading pairs found", |
| ) |
| d = result.to_dict() |
| assert d["error"] == "No trading pairs found" |
| assert d["risk_level"] == "error" |
|
|
|
|
| def test_score_to_level() -> None: |
| """Test risk score to level mapping.""" |
| detector = PumpDumpDetector() |
| assert detector._score_to_level(0) == "low" |
| assert detector._score_to_level(19) == "low" |
| assert detector._score_to_level(20) == "medium" |
| assert detector._score_to_level(39) == "medium" |
| assert detector._score_to_level(40) == "high" |
| assert detector._score_to_level(69) == "high" |
| assert detector._score_to_level(70) == "critical" |
| assert detector._score_to_level(100) == "critical" |
|
|
|
|
| def test_risk_score_calculation() -> None: |
| """Test risk score calculation from findings.""" |
| findings = [ |
| ManipulationFinding( |
| finding_type=ManipulationType.COORDINATED_PUMP, |
| severity=FindingSeverity.CRITICAL, |
| description="Critical finding", |
| ), |
| ManipulationFinding( |
| finding_type=ManipulationType.WASH_TRADING, |
| severity=FindingSeverity.HIGH, |
| description="High finding", |
| ), |
| ManipulationFinding( |
| finding_type=ManipulationType.VOLUME_SPIKE, |
| severity=FindingSeverity.MEDIUM, |
| description="Medium finding", |
| ), |
| ] |
| detector = PumpDumpDetector() |
| score = detector._calculate_risk_score(findings) |
| |
| assert score == 65.0 |
|
|
|
|
| def test_empty_findings_score() -> None: |
| """Test risk score with no findings.""" |
| detector = PumpDumpDetector() |
| score = detector._calculate_risk_score([]) |
| assert score == 0.0 |
|
|
|
|
| def test_max_score_cap() -> None: |
| """Test risk score is capped at 100.""" |
| findings = [ |
| ManipulationFinding(finding_type=t, severity=FindingSeverity.CRITICAL, description=f"test {i}") |
| for i, t in enumerate([ManipulationType.COORDINATED_PUMP] * 4) |
| ] |
| detector = PumpDumpDetector() |
| score = detector._calculate_risk_score(findings) |
| assert score == 100.0 |
|
|
|
|
| def test_thresholds_are_reasonable() -> None: |
| """Test that thresholds are set to reasonable values.""" |
| assert PUMP_DUMP_THRESHOLDS["volume_spike_min"] >= 2.0 |
| assert PUMP_DUMP_THRESHOLDS["coordinated_min_wallets"] >= 2 |
| assert PUMP_DUMP_THRESHOLDS["price_pump_threshold_pct"] >= 20 |
| assert PUMP_DUMP_THRESHOLDS["liquidity_min_usd"] >= 50 |
| assert PUMP_DUMP_THRESHOLDS["wash_trade_min_volume_pct"] >= 1 |
|
|
|
|
| def test_volume_anomaly_confidence() -> None: |
| """Test volume anomaly confidence is reasonable.""" |
| |
| normal = VolumeAnomaly(1000, 200, 5.0, "1h", min(5.0 / 20, 1.0)) |
| assert normal.confidence == 0.25 |
|
|
| |
| extreme = VolumeAnomaly(10000, 100, 100.0, "5m", min(100.0 / 15, 1.0)) |
| assert extreme.confidence == 1.0 |
|
|
| |
| none = VolumeAnomaly(100, 100, 1.0, "1h", min(1.0 / 20, 1.0)) |
| assert none.confidence < 0.1 |
|
|
|
|
| def test_to_markdown_basic() -> None: |
| """Test markdown output format.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xabc123", |
| chain="ethereum", |
| token_symbol="TEST", |
| token_name="Test Token", |
| risk_score=45.0, |
| risk_level="high", |
| ) |
| md = result.to_markdown() |
| assert "Pump & Dump Analysis: TEST" in md |
| assert "45/100" in md |
| assert "HIGH" in md |
|
|
|
|
| def test_to_markdown_with_findings() -> None: |
| """Test markdown output with findings.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xabc", |
| chain="solana", |
| token_symbol="PUMP", |
| token_name="Pump Token", |
| risk_score=85.0, |
| risk_level="critical", |
| ) |
| result.findings.append( |
| ManipulationFinding( |
| finding_type=ManipulationType.COORDINATED_PUMP, |
| severity=FindingSeverity.CRITICAL, |
| description="5 wallets coordinated buy", |
| detail="Fresh wallets detected", |
| evidence={"wallet_count": 5, "total_usd": 50000}, |
| ) |
| ) |
| result.volume_anomalies.append(VolumeAnomaly(50000, 2000, 25.0, "1h", 0.95)) |
| md = result.to_markdown() |
| assert "CRITICAL" in md |
| assert "5 wallets coordinated buy" in md |
| assert "25.0x" in md |
| assert "solana" in md or "Solana" in md |
|
|
|
|
| def test_to_markdown_error_result() -> None: |
| """Test markdown for error result.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xnone", |
| chain="ethereum", |
| token_symbol="?", |
| token_name="?", |
| risk_score=0.0, |
| risk_level="error", |
| error="No trading pairs found on DexScreener", |
| ) |
| md = result.to_markdown() |
| assert "Error:" in md |
| assert "No trading pairs" in md |
|
|
|
|
| def test_to_dict_full() -> None: |
| """Test full serialization with nested objects.""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xfull", |
| chain="base", |
| token_symbol="FULL", |
| token_name="Full Test", |
| risk_score=60.0, |
| risk_level="high", |
| ) |
| result.coordinated_groups.append( |
| CoordinatedBuyGroup( |
| wallets=["w1", "w2"], |
| window_seconds=60, |
| total_buy_usd=10000.0, |
| fresh_wallet_count=2, |
| chain="base", |
| ) |
| ) |
| result.wash_trade_clusters.append( |
| WashTradeCluster( |
| wallets=["a", "b", "c"], |
| volume_created_usd=5000.0, |
| trade_count=10, |
| circular_trades=5, |
| volume_pct_of_total=20.0, |
| ) |
| ) |
| result.price_pump = PricePumpSignal( |
| price_before_pump=1.0, |
| price_peak=3.0, |
| pump_pct=200.0, |
| current_price=2.0, |
| duration_seconds=3600, |
| dump_pct_from_peak=33.0, |
| ) |
| d = result.to_dict() |
| assert d["token_symbol"] == "FULL" |
| assert len(d["coordinated_groups"]) == 1 |
| assert len(d["wash_trade_clusters"]) == 1 |
| assert d["price_pump"]["pump_pct"] == 200.0 |
|
|
|
|
| def test_analysis_timestamp_in_to_dict() -> None: |
| """Test that analysis timestamp is set in to_dict().""" |
| result = PumpDumpAnalysisResult( |
| token_address="0xabc", |
| chain="ethereum", |
| token_symbol="T", |
| token_name="T", |
| risk_score=10.0, |
| risk_level="low", |
| ) |
| d = result.to_dict() |
| assert d["analysis_timestamp"] != "" |
|
|
|
|
| def test_trader_estimate() -> None: |
| """Test trader estimation from pairs data.""" |
| pairs_data = { |
| "pairs": [ |
| { |
| "txns": { |
| "h24": {"buys": 150, "sells": 120}, |
| } |
| } |
| ] |
| } |
| detector = PumpDumpDetector() |
| traders = detector._estimate_traders(pairs_data) |
| assert traders == 270 |
|
|
|
|
| def test_trader_estimate_empty() -> None: |
| """Test trader estimation with no data.""" |
| detector = PumpDumpDetector() |
| assert detector._estimate_traders({}) == 0 |
| assert detector._estimate_traders({"pairs": []}) == 0 |
|
|
|
|
| def test_lifecycle_pattern_young_pair() -> None: |
| """Test lifecycle pattern detection for young pairs with high volume.""" |
| |
| |
| |
| pass |
|
|
|
|
| def test_thresholds_immutable() -> None: |
| """Test that thresholds dict contains all expected keys.""" |
| expected_keys = { |
| "volume_spike_min", |
| "volume_spike_high", |
| "coordinated_buy_window_s", |
| "coordinated_min_wallets", |
| "fresh_wallet_max_age_days", |
| "wash_trade_min_volume_pct", |
| "price_pump_threshold_pct", |
| "liquidity_min_usd", |
| "max_holders_for_pump", |
| } |
| assert set(PUMP_DUMP_THRESHOLDS.keys()) == expected_keys |
|
|
|
|
| if __name__ == "__main__": |
| import pytest |
|
|
| pytest.main([__file__, "-v"]) |
|
|