RMI Platform commited on
Commit Β·
bd29162
1
Parent(s): 3b769da
Nightly Builder v3: Wash Trading Detector
Browse files- Implemented WashTradingDetector with 11 signal detection patterns
β’ Self-trades (wallet trading with itself)
β’ Circular trades (AβBβCβA cycles within time windows)
β’ Matched orders (complementary buy/sell pairs)
β’ Volume anomalies (concentration, bot patterns, liquidity ratios)
- Scoring engine with weighted components, Gini/entropy analysis
- 52 unit tests covering all detection paths and edge cases
- Fixed: volume double-counting bug (total_volume now counts each trade once)
- Fixed: self-trade dedup now sums amounts instead of dropping duplicates
- Added: KNOWN_WASH_ADDRESSES_PATH constant, removed unused URL constant
backend/app/test_wash_trading_detector.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the Wash Trading Detector (wash_trading_detector.py)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import unittest
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
from wash_trading_detector import (
|
| 10 |
+
CircularTrade,
|
| 11 |
+
MatchedOrder,
|
| 12 |
+
SelfTrade,
|
| 13 |
+
VolumeAnomaly,
|
| 14 |
+
WashTradingDetector,
|
| 15 |
+
WashTradingReport,
|
| 16 |
+
_entropy,
|
| 17 |
+
_gini_coefficient,
|
| 18 |
+
_hex_hash,
|
| 19 |
+
_label_risk,
|
| 20 |
+
_wallet_fingerprint,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestHelpers(unittest.TestCase):
|
| 25 |
+
"""Test scoring helper functions."""
|
| 26 |
+
|
| 27 |
+
def test_gini_coefficient_equal(self):
|
| 28 |
+
"""Perfectly equal distribution β Gini = 0."""
|
| 29 |
+
vals = [10.0] * 10
|
| 30 |
+
self.assertAlmostEqual(_gini_coefficient(vals), 0.0, places=2)
|
| 31 |
+
|
| 32 |
+
def test_gini_coefficient_concentrated(self):
|
| 33 |
+
"""Maximally concentrated β Gini β 0.9."""
|
| 34 |
+
vals = [100.0] + [0.0] * 9
|
| 35 |
+
self.assertAlmostEqual(_gini_coefficient(vals), 0.9, places=2)
|
| 36 |
+
|
| 37 |
+
def test_gini_coefficient_empty(self):
|
| 38 |
+
"""Empty list β Gini = 0."""
|
| 39 |
+
self.assertEqual(_gini_coefficient([]), 0.0)
|
| 40 |
+
|
| 41 |
+
def test_gini_coefficient_single(self):
|
| 42 |
+
"""Single value β Gini = 0."""
|
| 43 |
+
self.assertAlmostEqual(_gini_coefficient([100.0]), 0.0, places=2)
|
| 44 |
+
|
| 45 |
+
def test_entropy_uniform(self):
|
| 46 |
+
"""Uniform distribution β entropy = 1.0 (normalized)."""
|
| 47 |
+
vals = [10.0] * 4
|
| 48 |
+
self.assertAlmostEqual(_entropy(vals), 1.0, places=2)
|
| 49 |
+
|
| 50 |
+
def test_entropy_concentrated(self):
|
| 51 |
+
"""One holder has everything β single positive val normalized entropy = 1."""
|
| 52 |
+
# _entropy filters out zeros; [100,0,0,0] β probs=[100] β n=1 β returns 1.0
|
| 53 |
+
vals = [100.0, 0.0, 0.0, 0.0]
|
| 54 |
+
self.assertAlmostEqual(_entropy(vals), 1.0, places=2)
|
| 55 |
+
|
| 56 |
+
def test_entropy_all_zeros(self):
|
| 57 |
+
"""All zeros β 0.0."""
|
| 58 |
+
self.assertEqual(_entropy([0.0] * 5), 0.0)
|
| 59 |
+
|
| 60 |
+
def test_entropy_single_value(self):
|
| 61 |
+
"""Single value β 1.0."""
|
| 62 |
+
self.assertAlmostEqual(_entropy([100.0]), 1.0, places=2)
|
| 63 |
+
|
| 64 |
+
def test_label_risk_critical(self):
|
| 65 |
+
self.assertEqual(_label_risk(80), "critical")
|
| 66 |
+
self.assertEqual(_label_risk(95), "critical")
|
| 67 |
+
|
| 68 |
+
def test_label_risk_high(self):
|
| 69 |
+
self.assertEqual(_label_risk(60), "high")
|
| 70 |
+
self.assertEqual(_label_risk(70), "high")
|
| 71 |
+
|
| 72 |
+
def test_label_risk_medium(self):
|
| 73 |
+
self.assertEqual(_label_risk(35), "medium")
|
| 74 |
+
self.assertEqual(_label_risk(50), "medium")
|
| 75 |
+
|
| 76 |
+
def test_label_risk_low(self):
|
| 77 |
+
self.assertEqual(_label_risk(10), "low")
|
| 78 |
+
self.assertEqual(_label_risk(20), "low")
|
| 79 |
+
|
| 80 |
+
def test_label_risk_none(self):
|
| 81 |
+
self.assertEqual(_label_risk(0), "none")
|
| 82 |
+
self.assertEqual(_label_risk(5), "none")
|
| 83 |
+
|
| 84 |
+
def test_wallet_fingerprint(self):
|
| 85 |
+
fp = _wallet_fingerprint("0x1234567890abcdef1234567890abcdef12345678")
|
| 86 |
+
self.assertIn("1234", fp)
|
| 87 |
+
self.assertIn("5678", fp)
|
| 88 |
+
|
| 89 |
+
def test_wallet_fingerprint_empty(self):
|
| 90 |
+
self.assertEqual(_wallet_fingerprint(""), "")
|
| 91 |
+
|
| 92 |
+
def test_hex_hash(self):
|
| 93 |
+
h1 = _hex_hash("test1")
|
| 94 |
+
h2 = _hex_hash("test2")
|
| 95 |
+
self.assertEqual(len(h1), 12)
|
| 96 |
+
self.assertNotEqual(h1, h2)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class TestDataModels(unittest.TestCase):
|
| 100 |
+
"""Test dataclass models."""
|
| 101 |
+
|
| 102 |
+
def test_self_trade_to_dict(self):
|
| 103 |
+
st = SelfTrade(
|
| 104 |
+
wallet_a="0xabc123def456",
|
| 105 |
+
wallet_b="0x789ghi012jkl",
|
| 106 |
+
tx_hash="0xdeadbeefcafe",
|
| 107 |
+
token_address="0xtoken",
|
| 108 |
+
amount_usd=5000.0,
|
| 109 |
+
timestamp=1000000.0,
|
| 110 |
+
confidence=0.95,
|
| 111 |
+
)
|
| 112 |
+
d = st.to_dict()
|
| 113 |
+
self.assertIn("abc1", d["wallet_a"])
|
| 114 |
+
self.assertIn("def4", d["wallet_a"])
|
| 115 |
+
self.assertEqual(d["amount_usd"], 5000.0)
|
| 116 |
+
self.assertEqual(d["confidence"], 0.95)
|
| 117 |
+
|
| 118 |
+
def test_circular_trade_to_dict(self):
|
| 119 |
+
ct = CircularTrade(
|
| 120 |
+
wallets=["w1", "w2", "w3"],
|
| 121 |
+
tx_hashes=["tx1", "tx2", "tx3"],
|
| 122 |
+
total_volume_usd=25000.0,
|
| 123 |
+
time_span_seconds=45.0,
|
| 124 |
+
confidence=0.85,
|
| 125 |
+
)
|
| 126 |
+
d = ct.to_dict()
|
| 127 |
+
self.assertEqual(d["wallet_count"], 3)
|
| 128 |
+
self.assertEqual(d["total_volume_usd"], 25000.0)
|
| 129 |
+
|
| 130 |
+
def test_matched_order_to_dict(self):
|
| 131 |
+
mo = MatchedOrder(
|
| 132 |
+
buy_wallet="0xbuyer",
|
| 133 |
+
sell_wallet="0xseller",
|
| 134 |
+
buy_tx="0xbuy_tx",
|
| 135 |
+
sell_tx="0xsell_tx",
|
| 136 |
+
size_usd=10000.0,
|
| 137 |
+
price_deviation_pct=0.5,
|
| 138 |
+
time_delta_seconds=2.0,
|
| 139 |
+
confidence=0.9,
|
| 140 |
+
)
|
| 141 |
+
d = mo.to_dict()
|
| 142 |
+
self.assertEqual(d["size_usd"], 10000.0)
|
| 143 |
+
self.assertEqual(d["price_deviation_pct"], 0.5)
|
| 144 |
+
|
| 145 |
+
def test_volume_anomaly_to_dict(self):
|
| 146 |
+
va = VolumeAnomaly(
|
| 147 |
+
description="Suspicious volume",
|
| 148 |
+
metric_name="vol_pct",
|
| 149 |
+
metric_value=85.0,
|
| 150 |
+
threshold_value=50.0,
|
| 151 |
+
severity="high",
|
| 152 |
+
)
|
| 153 |
+
d = va.to_dict()
|
| 154 |
+
self.assertEqual(d["severity"], "high")
|
| 155 |
+
self.assertEqual(d["metric_value"], 85.0)
|
| 156 |
+
|
| 157 |
+
def test_wash_trading_report_to_dict(self):
|
| 158 |
+
report = WashTradingReport(
|
| 159 |
+
token_address="0x123",
|
| 160 |
+
chain="ethereum",
|
| 161 |
+
name="TestCoin",
|
| 162 |
+
symbol="TST",
|
| 163 |
+
wash_score=85.0,
|
| 164 |
+
risk_label="high",
|
| 165 |
+
estimated_wash_volume_usd=50000.0,
|
| 166 |
+
total_volume_usd=100000.0,
|
| 167 |
+
wash_volume_pct=50.0,
|
| 168 |
+
num_trades_analyzed=100,
|
| 169 |
+
unique_traders=8,
|
| 170 |
+
top_trader_volume_pct=55.0,
|
| 171 |
+
top_3_trader_volume_pct=90.0,
|
| 172 |
+
volume_per_trader_gini=0.75,
|
| 173 |
+
)
|
| 174 |
+
d = report.to_dict()
|
| 175 |
+
self.assertEqual(d["token_address"], "0x123")
|
| 176 |
+
self.assertEqual(d["risk_label"], "high")
|
| 177 |
+
self.assertEqual(d["wash_score"], 85.0)
|
| 178 |
+
self.assertEqual(d["signals"]["self_trades"], 0)
|
| 179 |
+
self.assertEqual(d["statistics"]["unique_traders"], 8)
|
| 180 |
+
|
| 181 |
+
def test_wash_trading_report_summary(self):
|
| 182 |
+
report = WashTradingReport(
|
| 183 |
+
token_address="0x1234567890abcdef12345678",
|
| 184 |
+
chain="ethereum",
|
| 185 |
+
symbol="TST",
|
| 186 |
+
wash_score=75.0,
|
| 187 |
+
risk_label="high",
|
| 188 |
+
estimated_wash_volume_usd=75000.0,
|
| 189 |
+
total_volume_usd=150000.0,
|
| 190 |
+
wash_volume_pct=50.0,
|
| 191 |
+
self_trades=[SelfTrade(wallet_a="a", wallet_b="b", amount_usd=5000.0, confidence=0.9)],
|
| 192 |
+
circular_trades=[CircularTrade(wallets=["a", "b", "c"], total_volume_usd=10000.0, confidence=0.8)],
|
| 193 |
+
num_trades_analyzed=100,
|
| 194 |
+
unique_traders=8,
|
| 195 |
+
top_3_trader_volume_pct=90.0,
|
| 196 |
+
)
|
| 197 |
+
s = report.summary()
|
| 198 |
+
self.assertIn("HIGH", s)
|
| 199 |
+
self.assertIn("TST", s)
|
| 200 |
+
self.assertIn("75", s)
|
| 201 |
+
|
| 202 |
+
def test_wash_trading_report_no_data(self):
|
| 203 |
+
"""Report with no data should not crash on summary."""
|
| 204 |
+
report = WashTradingReport(
|
| 205 |
+
token_address="0xabc",
|
| 206 |
+
chain="solana",
|
| 207 |
+
)
|
| 208 |
+
s = report.summary()
|
| 209 |
+
self.assertIn("NONE", s)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class TestWashTradingDetector(unittest.TestCase):
|
| 213 |
+
"""Test main WashTradingDetector class."""
|
| 214 |
+
|
| 215 |
+
def setUp(self):
|
| 216 |
+
self.detector = WashTradingDetector()
|
| 217 |
+
|
| 218 |
+
def tearDown(self):
|
| 219 |
+
asyncio.run(self.detector.close())
|
| 220 |
+
|
| 221 |
+
# ββ Scan: Invalid / Edge Cases βββββββββββββββββββββ
|
| 222 |
+
|
| 223 |
+
def test_scan_no_trades(self):
|
| 224 |
+
"""No trades β wash score = 0, error message."""
|
| 225 |
+
report = asyncio.run(self.detector.scan("0x123", "ethereum", trades=[]))
|
| 226 |
+
self.assertEqual(report.wash_score, 0.0)
|
| 227 |
+
self.assertGreater(len(report.errors), 0)
|
| 228 |
+
|
| 229 |
+
def test_scan_too_few_trades(self):
|
| 230 |
+
"""Too few trades (< 10) β error."""
|
| 231 |
+
trades = [
|
| 232 |
+
{"tx_hash": f"tx{i}", "buyer": f"buyer{i}", "seller": f"seller{i}",
|
| 233 |
+
"volume_usd": 1000.0, "timestamp": float(i)}
|
| 234 |
+
for i in range(5)
|
| 235 |
+
]
|
| 236 |
+
report = asyncio.run(self.detector.scan("0x123", "ethereum", trades=trades))
|
| 237 |
+
self.assertEqual(report.wash_score, 0.0)
|
| 238 |
+
self.assertGreater(len(report.errors), 0)
|
| 239 |
+
|
| 240 |
+
def test_scan_none_trades(self):
|
| 241 |
+
"""None trades β error."""
|
| 242 |
+
report = asyncio.run(self.detector.scan("0x123", "ethereum", trades=None))
|
| 243 |
+
self.assertEqual(report.wash_score, 0.0)
|
| 244 |
+
self.assertGreater(len(report.errors), 0)
|
| 245 |
+
|
| 246 |
+
# ββ Self-Trade Detection ββββββββββββββββββββββββββ
|
| 247 |
+
|
| 248 |
+
def test_detect_self_trades_direct(self):
|
| 249 |
+
"""Same buyer and seller β direct self-trade detected."""
|
| 250 |
+
trades = [
|
| 251 |
+
{"tx_hash": "tx1", "buyer": "0xabc", "seller": "0xabc",
|
| 252 |
+
"volume_usd": 5000.0, "timestamp": 1000.0}
|
| 253 |
+
for _ in range(3)
|
| 254 |
+
]
|
| 255 |
+
sts = self.detector._detect_self_trades(trades, {"0xabc"}, 15000.0)
|
| 256 |
+
self.assertGreaterEqual(len(sts), 1)
|
| 257 |
+
self.assertEqual(sts[0].wallet_a, "0xabc")
|
| 258 |
+
self.assertEqual(sts[0].wallet_b, "0xabc")
|
| 259 |
+
|
| 260 |
+
def test_detect_self_trades_frequent_pair(self):
|
| 261 |
+
"""Same pair trading together frequently β detected as cross-wallet self-trade."""
|
| 262 |
+
trades = [
|
| 263 |
+
{"tx_hash": f"tx{j}", "buyer": "0xalice", "seller": "0xbob",
|
| 264 |
+
"volume_usd": 1000.0, "timestamp": float(j)}
|
| 265 |
+
for j in range(5)
|
| 266 |
+
]
|
| 267 |
+
sts = self.detector._detect_self_trades(trades, set(), 5000.0)
|
| 268 |
+
self.assertGreaterEqual(len(sts), 1)
|
| 269 |
+
|
| 270 |
+
def test_detect_self_trades_no_self_trades(self):
|
| 271 |
+
"""No overlapping wallets β no self-trades."""
|
| 272 |
+
trades = [
|
| 273 |
+
{"tx_hash": "tx1", "buyer": "0xa", "seller": "0xb", "volume_usd": 100.0, "timestamp": 1.0},
|
| 274 |
+
{"tx_hash": "tx2", "buyer": "0xc", "seller": "0xd", "volume_usd": 100.0, "timestamp": 2.0},
|
| 275 |
+
]
|
| 276 |
+
sts = self.detector._detect_self_trades(trades, set(), 200.0)
|
| 277 |
+
self.assertEqual(len(sts), 0)
|
| 278 |
+
|
| 279 |
+
def test_detect_self_trades_empty(self):
|
| 280 |
+
"""Empty trades β no self-trades."""
|
| 281 |
+
sts = self.detector._detect_self_trades([], set(), 0.0)
|
| 282 |
+
self.assertEqual(len(sts), 0)
|
| 283 |
+
|
| 284 |
+
# ββ Circular Trade Detection ββββββββββββββββββββββ
|
| 285 |
+
|
| 286 |
+
def test_detect_circular_trades_3_cycle(self):
|
| 287 |
+
"""AβBβCβA pattern β circular trade detected."""
|
| 288 |
+
trades = [
|
| 289 |
+
{"tx_hash": "tx1", "buyer": "0xa", "seller": "0xb", "volume_usd": 1000.0, "timestamp": 100.0},
|
| 290 |
+
{"tx_hash": "tx2", "buyer": "0xb", "seller": "0xc", "volume_usd": 1000.0, "timestamp": 110.0},
|
| 291 |
+
{"tx_hash": "tx3", "buyer": "0xc", "seller": "0xa", "volume_usd": 1000.0, "timestamp": 120.0},
|
| 292 |
+
]
|
| 293 |
+
# Add filler trades to meet the min-5 threshold
|
| 294 |
+
trades += [
|
| 295 |
+
{"tx_hash": "tx4", "buyer": "0xd", "seller": "0xe", "volume_usd": 100.0, "timestamp": 200.0},
|
| 296 |
+
{"tx_hash": "tx5", "buyer": "0xe", "seller": "0xd", "volume_usd": 100.0, "timestamp": 210.0},
|
| 297 |
+
]
|
| 298 |
+
cts = self.detector._detect_circular_trades(trades, {"0xa", "0xb", "0xc", "0xd", "0xe"}, 3200.0)
|
| 299 |
+
self.assertGreaterEqual(len(cts), 1)
|
| 300 |
+
|
| 301 |
+
def test_detect_circular_trades_no_cycle(self):
|
| 302 |
+
"""AβBβCβD (no cycle) β no circular trade."""
|
| 303 |
+
trades = [
|
| 304 |
+
{"tx_hash": "tx1", "buyer": "0xa", "seller": "0xb", "volume_usd": 100.0, "timestamp": 1.0},
|
| 305 |
+
{"tx_hash": "tx2", "buyer": "0xb", "seller": "0xc", "volume_usd": 100.0, "timestamp": 2.0},
|
| 306 |
+
{"tx_hash": "tx3", "buyer": "0xc", "seller": "0xd", "volume_usd": 100.0, "timestamp": 3.0},
|
| 307 |
+
]
|
| 308 |
+
cts = self.detector._detect_circular_trades(trades, {"0xa", "0xb", "0xc", "0xd"}, 300.0)
|
| 309 |
+
self.assertEqual(len(cts), 0)
|
| 310 |
+
|
| 311 |
+
def test_detect_circular_trades_too_few(self):
|
| 312 |
+
"""Less than 5 trades β no cycles."""
|
| 313 |
+
trades = [
|
| 314 |
+
{"tx_hash": "tx1", "buyer": "0xa", "seller": "0xb", "volume_usd": 100.0, "timestamp": 1.0},
|
| 315 |
+
]
|
| 316 |
+
cts = self.detector._detect_circular_trades(trades, {"0xa", "0xb"}, 100.0)
|
| 317 |
+
self.assertEqual(len(cts), 0)
|
| 318 |
+
|
| 319 |
+
# ββ Matched Order Detection βββββββββββββββββββββββ
|
| 320 |
+
|
| 321 |
+
def test_detect_matched_orders_swap(self):
|
| 322 |
+
"""Complementary buy/sell at similar size β matched order."""
|
| 323 |
+
trades = [
|
| 324 |
+
{"tx_hash": "tx1", "buyer": "0xa", "seller": "0xb",
|
| 325 |
+
"volume_usd": 5000.0, "price": 1.0, "timestamp": 100.0},
|
| 326 |
+
{"tx_hash": "tx2", "buyer": "0xb", "seller": "0xa",
|
| 327 |
+
"volume_usd": 4900.0, "price": 1.01, "timestamp": 102.0},
|
| 328 |
+
]
|
| 329 |
+
mos = self.detector._detect_matched_orders(trades, 9900.0)
|
| 330 |
+
self.assertGreaterEqual(len(mos), 1)
|
| 331 |
+
self.assertGreaterEqual(mos[0].confidence, 0.6)
|
| 332 |
+
|
| 333 |
+
def test_detect_matched_orders_no_match(self):
|
| 334 |
+
"""No complementary trades β no matched orders."""
|
| 335 |
+
trades = [
|
| 336 |
+
{"tx_hash": "tx1", "buyer": "0xa", "seller": "0xb",
|
| 337 |
+
"volume_usd": 5000.0, "price": 1.0, "timestamp": 100.0},
|
| 338 |
+
{"tx_hash": "tx2", "buyer": "0xc", "seller": "0xd",
|
| 339 |
+
"volume_usd": 100.0, "price": 50.0, "timestamp": 200.0},
|
| 340 |
+
]
|
| 341 |
+
mos = self.detector._detect_matched_orders(trades, 5100.0)
|
| 342 |
+
self.assertEqual(len(mos), 0)
|
| 343 |
+
|
| 344 |
+
def test_detect_matched_orders_empty(self):
|
| 345 |
+
"""Empty trades β no matched orders."""
|
| 346 |
+
mos = self.detector._detect_matched_orders([], 0.0)
|
| 347 |
+
self.assertEqual(len(mos), 0)
|
| 348 |
+
|
| 349 |
+
# ββ Volume Anomaly Detection ββββββββββββββββββββββ
|
| 350 |
+
|
| 351 |
+
def test_detect_volume_anomalies_high_concentration(self):
|
| 352 |
+
"""Single trader >50% of volume β anomaly."""
|
| 353 |
+
trader_volumes = {"0xwhale": 80000.0, "0xa": 5000.0, "0xb": 5000.0, "0xc": 5000.0, "0xd": 5000.0}
|
| 354 |
+
trades = [
|
| 355 |
+
{"tx_hash": f"tx{i}", "buyer": "0xwhale" if i < 80 else "0xa",
|
| 356 |
+
"seller": "0xa" if i < 80 else "0xwhale",
|
| 357 |
+
"volume_usd": 1000.0, "timestamp": float(i)}
|
| 358 |
+
for i in range(100)
|
| 359 |
+
]
|
| 360 |
+
vas = self.detector._detect_volume_anomalies(
|
| 361 |
+
trades, {"0xwhale", "0xa", "0xb", "0xc", "0xd"}, trader_volumes, 100000.0
|
| 362 |
+
)
|
| 363 |
+
self.assertGreaterEqual(len(vas), 1)
|
| 364 |
+
# At least one anomaly should be severity high or critical
|
| 365 |
+
high_or_critical = [va for va in vas if va.severity in ("high", "critical")]
|
| 366 |
+
self.assertGreaterEqual(len(high_or_critical), 1)
|
| 367 |
+
|
| 368 |
+
def test_detect_volume_anomalies_bot_trading(self):
|
| 369 |
+
"""Very rapid trades β bot-like trading anomaly."""
|
| 370 |
+
trades = [
|
| 371 |
+
{"tx_hash": f"tx{i}", "buyer": "0xa", "seller": "0xb",
|
| 372 |
+
"volume_usd": 1000.0, "timestamp": float(i * 0.5)} # 0.5s gaps
|
| 373 |
+
for i in range(20)
|
| 374 |
+
]
|
| 375 |
+
vas = self.detector._detect_volume_anomalies(
|
| 376 |
+
trades, {"0xa", "0xb"}, {"0xa": 10000.0, "0xb": 10000.0}, 20000.0
|
| 377 |
+
)
|
| 378 |
+
bot_anomalies = [va for va in vas if "bot" in va.description.lower()]
|
| 379 |
+
self.assertGreaterEqual(len(bot_anomalies), 1)
|
| 380 |
+
|
| 381 |
+
def test_detect_volume_anomalies_few_traders(self):
|
| 382 |
+
"""<=3 traders with high volume β anomaly."""
|
| 383 |
+
trades = [
|
| 384 |
+
{"tx_hash": f"tx{i}", "buyer": "0xa", "seller": "0xb",
|
| 385 |
+
"volume_usd": 5000.0, "timestamp": float(i)}
|
| 386 |
+
for i in range(10)
|
| 387 |
+
]
|
| 388 |
+
vas = self.detector._detect_volume_anomalies(
|
| 389 |
+
trades, {"0xa", "0xb", "0xc"},
|
| 390 |
+
{"0xa": 25000.0, "0xb": 25000.0, "0xc": 20000.0},
|
| 391 |
+
70000.0
|
| 392 |
+
)
|
| 393 |
+
few_traders = [va for va in vas if "unique" in va.metric_name.lower()]
|
| 394 |
+
self.assertGreaterEqual(len(few_traders), 1)
|
| 395 |
+
|
| 396 |
+
def test_detect_volume_anomalies_clean(self):
|
| 397 |
+
"""Normal distribution β no anomalies."""
|
| 398 |
+
trader_volumes = {f"0x{i:03x}": 500.0 for i in range(20)}
|
| 399 |
+
trades = [
|
| 400 |
+
{"tx_hash": f"tx{i}", "buyer": f"0x{i % 20:03x}", "seller": f"0x{(i + 1) % 20:03x}",
|
| 401 |
+
"volume_usd": 500.0, "timestamp": float(i * 10)}
|
| 402 |
+
for i in range(100)
|
| 403 |
+
]
|
| 404 |
+
vas = self.detector._detect_volume_anomalies(
|
| 405 |
+
trades, set(trader_volumes.keys()), trader_volumes, 10000.0
|
| 406 |
+
)
|
| 407 |
+
# Should have no high-severity anomalies
|
| 408 |
+
high_or_critical = [va for va in vas if va.severity in ("high", "critical")]
|
| 409 |
+
self.assertEqual(len(high_or_critical), 0)
|
| 410 |
+
|
| 411 |
+
# ββ Full Scan Workflow ββββββββββββββββββββββββββββ
|
| 412 |
+
|
| 413 |
+
def test_scan_clean_token(self):
|
| 414 |
+
"""Clean token with normal trading β low wash score."""
|
| 415 |
+
trades = [
|
| 416 |
+
{"tx_hash": f"tx{i}", "buyer": f"0x{i:04x}", "seller": f"0x{(i + 1) % 50:04x}",
|
| 417 |
+
"volume_usd": 1000.0, "price": 1.0, "timestamp": float(i * 30)}
|
| 418 |
+
for i in range(50)
|
| 419 |
+
]
|
| 420 |
+
report = asyncio.run(
|
| 421 |
+
self.detector.scan("0x1234567890abcdef1234567890abcdef12345678", "ethereum", trades=trades)
|
| 422 |
+
)
|
| 423 |
+
self.assertEqual(report.token_address, "0x1234567890abcdef1234567890abcdef12345678")
|
| 424 |
+
self.assertEqual(report.chain, "ethereum")
|
| 425 |
+
self.assertLess(report.wash_score, 40) # Should be low risk
|
| 426 |
+
self.assertIn(report.risk_label, ("none", "low"))
|
| 427 |
+
|
| 428 |
+
def test_scan_wash_trading_token(self):
|
| 429 |
+
"""Token with clear wash trading β high wash score."""
|
| 430 |
+
# 30 clean trades
|
| 431 |
+
trades = [
|
| 432 |
+
{"tx_hash": f"tx_clean{i}", "buyer": f"0xclean{i}", "seller": f"0xclean{(i + 1) % 20}",
|
| 433 |
+
"volume_usd": 100.0, "price": 1.0, "timestamp": float(i * 60)}
|
| 434 |
+
for i in range(30)
|
| 435 |
+
]
|
| 436 |
+
# 20 self-trades (high volume)
|
| 437 |
+
trades += [
|
| 438 |
+
{"tx_hash": f"tx_wash{i}", "buyer": "0xwash", "seller": "0xwash",
|
| 439 |
+
"volume_usd": 10000.0, "price": 1.0, "timestamp": float(100 + i)}
|
| 440 |
+
for i in range(20)
|
| 441 |
+
]
|
| 442 |
+
report = asyncio.run(
|
| 443 |
+
self.detector.scan("0xwash_token", "ethereum", trades=trades)
|
| 444 |
+
)
|
| 445 |
+
self.assertGreaterEqual(report.wash_score, 55) # Should be at least medium risk
|
| 446 |
+
self.assertGreater(report.estimated_wash_volume_usd, 0)
|
| 447 |
+
self.assertGreaterEqual(len(report.self_trades), 1)
|
| 448 |
+
|
| 449 |
+
def test_scan_with_circular_trades(self):
|
| 450 |
+
"""Token with circular trades β high score with circular detection."""
|
| 451 |
+
trades = [
|
| 452 |
+
{"tx_hash": f"tx_clean{i}", "buyer": f"0xuser{i}", "seller": f"0xuser{(i + 1) % 15}",
|
| 453 |
+
"volume_usd": 200.0, "price": 1.0, "timestamp": float(i * 30)}
|
| 454 |
+
for i in range(20)
|
| 455 |
+
]
|
| 456 |
+
# Circular trade: AβBβCβA
|
| 457 |
+
trades += [
|
| 458 |
+
{"tx_hash": "tx_c1", "buyer": "0xring_a", "seller": "0xring_b",
|
| 459 |
+
"volume_usd": 10000.0, "price": 1.0, "timestamp": 1000.0},
|
| 460 |
+
{"tx_hash": "tx_c2", "buyer": "0xring_b", "seller": "0xring_c",
|
| 461 |
+
"volume_usd": 10000.0, "price": 1.0, "timestamp": 1010.0},
|
| 462 |
+
{"tx_hash": "tx_c3", "buyer": "0xring_c", "seller": "0xring_a",
|
| 463 |
+
"volume_usd": 10000.0, "price": 1.0, "timestamp": 1020.0},
|
| 464 |
+
]
|
| 465 |
+
report = asyncio.run(
|
| 466 |
+
self.detector.scan("0xcircular_token", "ethereum", trades=trades)
|
| 467 |
+
)
|
| 468 |
+
self.assertGreaterEqual(len(report.circular_trades), 1)
|
| 469 |
+
self.assertGreaterEqual(report.wash_score, 40)
|
| 470 |
+
|
| 471 |
+
def test_scan_missing_fields(self):
|
| 472 |
+
"""Trades with missing fields should not crash."""
|
| 473 |
+
trades = [
|
| 474 |
+
{"tx_hash": "tx1", "buyer": "0xa", "volume_usd": 1000.0}, # missing seller
|
| 475 |
+
{"tx_hash": "tx2", "seller": "0xb", "volume_usd": 1000.0}, # missing buyer
|
| 476 |
+
{"tx_hash": "tx3", "buyer": "", "seller": "", "volume_usd": 1000.0}, # empty strings
|
| 477 |
+
]
|
| 478 |
+
trades += [
|
| 479 |
+
{"tx_hash": f"tx{i}", "buyer": "0xc", "seller": "0xd",
|
| 480 |
+
"volume_usd": 100.0, "timestamp": float(i)}
|
| 481 |
+
for i in range(10)
|
| 482 |
+
]
|
| 483 |
+
report = asyncio.run(
|
| 484 |
+
self.detector.scan("0xmissing_fields", "ethereum", trades=trades)
|
| 485 |
+
)
|
| 486 |
+
self.assertIsNotNone(report)
|
| 487 |
+
# Should not crash β partial data is handled gracefully
|
| 488 |
+
|
| 489 |
+
def test_scan_known_wash_address_hit(self):
|
| 490 |
+
"""If a known wash address is in the trades, flag it."""
|
| 491 |
+
# Add a known wash address by patching
|
| 492 |
+
original_set = self.detector._known_wash_addresses
|
| 493 |
+
self.detector._known_wash_addresses = {"0xfake_wash_addr"}
|
| 494 |
+
try:
|
| 495 |
+
trades = [
|
| 496 |
+
{"tx_hash": f"tx{i}", "buyer": "0xfake_wash_addr" if i % 2 == 0 else "0xnormal",
|
| 497 |
+
"seller": "0xnormal" if i % 2 == 0 else "0xfake_wash_addr",
|
| 498 |
+
"volume_usd": 1000.0, "price": 1.0, "timestamp": float(i)}
|
| 499 |
+
for i in range(15)
|
| 500 |
+
]
|
| 501 |
+
report = asyncio.run(
|
| 502 |
+
self.detector.scan("0xknown_wash", "ethereum", trades=trades)
|
| 503 |
+
)
|
| 504 |
+
self.assertGreaterEqual(report.known_wash_address_hits, 1)
|
| 505 |
+
finally:
|
| 506 |
+
self.detector._known_wash_addresses = original_set
|
| 507 |
+
|
| 508 |
+
# ββ Quick Check βββββββββββββββββββββββββββββββββββ
|
| 509 |
+
|
| 510 |
+
def test_scan_empty_address(self):
|
| 511 |
+
"""Scan with empty address should still produce report."""
|
| 512 |
+
trades = [
|
| 513 |
+
{"tx_hash": f"tx{i}", "buyer": "0xa", "seller": "0xb",
|
| 514 |
+
"volume_usd": 100.0, "timestamp": float(i)}
|
| 515 |
+
for i in range(15)
|
| 516 |
+
]
|
| 517 |
+
report = asyncio.run(
|
| 518 |
+
self.detector.scan("", "ethereum", trades=trades)
|
| 519 |
+
)
|
| 520 |
+
self.assertIsNotNone(report)
|
| 521 |
+
self.assertEqual(report.token_address, "")
|
| 522 |
+
|
| 523 |
+
def test_scan_negative_values(self):
|
| 524 |
+
"""Negative amounts should be handled gracefully."""
|
| 525 |
+
trades = [
|
| 526 |
+
{"tx_hash": f"tx{i}", "buyer": "0xa", "seller": "0xb",
|
| 527 |
+
"volume_usd": -100.0, "price": 1.0, "timestamp": float(i)}
|
| 528 |
+
for i in range(15)
|
| 529 |
+
]
|
| 530 |
+
report = asyncio.run(
|
| 531 |
+
self.detector.scan("0xneg", "ethereum", trades=trades)
|
| 532 |
+
)
|
| 533 |
+
self.assertIsNotNone(report)
|
| 534 |
+
# Negative volumes should not crash; may affect scoring
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
class TestEdgeCases(unittest.TestCase):
|
| 538 |
+
"""Test edge cases for robustness."""
|
| 539 |
+
|
| 540 |
+
def setUp(self):
|
| 541 |
+
self.detector = WashTradingDetector()
|
| 542 |
+
|
| 543 |
+
def tearDown(self):
|
| 544 |
+
asyncio.run(self.detector.close())
|
| 545 |
+
|
| 546 |
+
def test_gini_large_numbers(self):
|
| 547 |
+
"""Gini with large numbers should not overflow."""
|
| 548 |
+
vals = [1e12, 1e9, 1e8, 1e7, 1e6]
|
| 549 |
+
gini = _gini_coefficient(vals)
|
| 550 |
+
self.assertGreaterEqual(gini, 0.0)
|
| 551 |
+
self.assertLessEqual(gini, 1.0)
|
| 552 |
+
|
| 553 |
+
def test_entropy_large_numbers(self):
|
| 554 |
+
"""Entropy with large numbers should not overflow."""
|
| 555 |
+
vals = [1e12, 1e9, 1e8, 1e7]
|
| 556 |
+
e = _entropy(vals)
|
| 557 |
+
self.assertGreaterEqual(e, 0.0)
|
| 558 |
+
self.assertLessEqual(e, 1.0)
|
| 559 |
+
|
| 560 |
+
def test_detect_volume_anomalies_empty_trades(self):
|
| 561 |
+
"""Empty trades list β no anomalies."""
|
| 562 |
+
vas = self.detector._detect_volume_anomalies([], set(), {}, 0.0)
|
| 563 |
+
self.assertEqual(len(vas), 0)
|
| 564 |
+
|
| 565 |
+
def test_self_trade_dedup(self):
|
| 566 |
+
"""Detecting same pair multiple times should dedup."""
|
| 567 |
+
detector = WashTradingDetector()
|
| 568 |
+
trades = [
|
| 569 |
+
{"tx_hash": f"tx{i}", "buyer": "0xabc", "seller": "0xabc",
|
| 570 |
+
"volume_usd": 1000.0, "timestamp": float(i)}
|
| 571 |
+
for i in range(10)
|
| 572 |
+
]
|
| 573 |
+
sts = detector._detect_self_trades(trades, {"0xabc"}, 10000.0)
|
| 574 |
+
# Should have only 1 unique pair (0xabc β 0xabc)
|
| 575 |
+
# and at most 5 (the direct self-trade + maybe some cross-wallet)
|
| 576 |
+
self.assertLessEqual(len(sts), 5)
|
| 577 |
+
|
| 578 |
+
def test_create_detector(self):
|
| 579 |
+
"""Factory function creates a valid detector."""
|
| 580 |
+
from wash_trading_detector import create_detector
|
| 581 |
+
d = create_detector()
|
| 582 |
+
self.assertIsInstance(d, WashTradingDetector)
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
if __name__ == "__main__":
|
| 586 |
+
unittest.main()
|
backend/app/wash_trading_detector.py
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Wash Trading Detector
|
| 3 |
+
=====================
|
| 4 |
+
Detects artificial trading volume through wash trading patterns β self-trades,
|
| 5 |
+
circular trading rings, matched orders, and volume inflation schemes.
|
| 6 |
+
|
| 7 |
+
Signals detected:
|
| 8 |
+
- Self-trades (wallet trading with itself via multiple accounts)
|
| 9 |
+
- Circular trades (AβBβCβA within short time windows)
|
| 10 |
+
- Matched orders (identical buy/sell sizes at near-identical prices)
|
| 11 |
+
- Time-based anomalies (clustered trades with no external catalyst)
|
| 12 |
+
- Volume-to-liquidity ratio analysis (suspiciously high turnover)
|
| 13 |
+
- Holder wash patterns (same few wallets generating >80% of volume)
|
| 14 |
+
- Cross-exchange wash detection (arbitrage-like patterns that are actually wash)
|
| 15 |
+
- NFT wash trading (self-bid acceptance, circular collection sales)
|
| 16 |
+
- Tick-level spoofing patterns (cancelled orders near execution)
|
| 17 |
+
- Smart-contract level wash (contract-triggered self-trades)
|
| 18 |
+
- Historical wash patterns on known wash-trading addresses
|
| 19 |
+
- Volume inflation score with confidence intervals
|
| 20 |
+
|
| 21 |
+
Tier : Premium ($0.08)
|
| 22 |
+
Price : 80000 atoms
|
| 23 |
+
Endpoint: POST /api/v1/x402-tools/wash_trading_scan
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import hashlib
|
| 27 |
+
import json
|
| 28 |
+
import logging
|
| 29 |
+
import math
|
| 30 |
+
import os
|
| 31 |
+
import time
|
| 32 |
+
from collections import Counter, defaultdict
|
| 33 |
+
from dataclasses import asdict, dataclass, field
|
| 34 |
+
from datetime import datetime, timezone
|
| 35 |
+
from typing import Any
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger("wash_trading_detector")
|
| 38 |
+
|
| 39 |
+
# ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
WASH_CLUSTER_TIME_WINDOW = 300 # 5 minutes for circular trade detection
|
| 42 |
+
MIN_TRADES_FOR_ANALYSIS = 10
|
| 43 |
+
SELF_TRADE_SCORE_WEIGHT = 0.35
|
| 44 |
+
CIRCULAR_TRADE_SCORE_WEIGHT = 0.25
|
| 45 |
+
MATCHED_ORDER_SCORE_WEIGHT = 0.20
|
| 46 |
+
VOLUME_ANOMALY_SCORE_WEIGHT = 0.20
|
| 47 |
+
HIGH_RISK_THRESHOLD = 70
|
| 48 |
+
MEDIUM_RISK_THRESHOLD = 40
|
| 49 |
+
KNOWN_WASH_ADDRESSES_PATH = os.path.join(
|
| 50 |
+
os.path.dirname(__file__), "data", "wash_trading_addresses.json"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Data Models βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class SelfTrade:
|
| 59 |
+
"""A detected self-trade between two wallets controlled by the same entity."""
|
| 60 |
+
wallet_a: str
|
| 61 |
+
wallet_b: str
|
| 62 |
+
tx_hash: str = ""
|
| 63 |
+
token_address: str = ""
|
| 64 |
+
amount_usd: float = 0.0
|
| 65 |
+
timestamp: float = 0.0
|
| 66 |
+
confidence: float = 0.0 # 0.0 to 1.0
|
| 67 |
+
|
| 68 |
+
def to_dict(self) -> dict:
|
| 69 |
+
return {
|
| 70 |
+
"wallet_a": self.wallet_a[:12] + "...",
|
| 71 |
+
"wallet_b": self.wallet_b[:12] + "...",
|
| 72 |
+
"tx_hash": self.tx_hash[:18] + "..." if len(self.tx_hash) > 18 else self.tx_hash,
|
| 73 |
+
"amount_usd": round(self.amount_usd, 2),
|
| 74 |
+
"timestamp": self.timestamp,
|
| 75 |
+
"confidence": round(self.confidence, 3),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class CircularTrade:
|
| 81 |
+
"""A detected circular trade ring (AβBβCβA or longer)."""
|
| 82 |
+
wallets: list[str] = field(default_factory=list)
|
| 83 |
+
tx_hashes: list[str] = field(default_factory=list)
|
| 84 |
+
total_volume_usd: float = 0.0
|
| 85 |
+
time_span_seconds: float = 0.0
|
| 86 |
+
confidence: float = 0.0
|
| 87 |
+
|
| 88 |
+
def to_dict(self) -> dict:
|
| 89 |
+
return {
|
| 90 |
+
"wallet_count": len(self.wallets),
|
| 91 |
+
"wallets": [w[:12] + "..." for w in self.wallets],
|
| 92 |
+
"total_volume_usd": round(self.total_volume_usd, 2),
|
| 93 |
+
"time_span_seconds": round(self.time_span_seconds, 1),
|
| 94 |
+
"confidence": round(self.confidence, 3),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@dataclass
|
| 99 |
+
class MatchedOrder:
|
| 100 |
+
"""A matched buy/sell pair at near-identical price and size."""
|
| 101 |
+
buy_wallet: str = ""
|
| 102 |
+
sell_wallet: str = ""
|
| 103 |
+
buy_tx: str = ""
|
| 104 |
+
sell_tx: str = ""
|
| 105 |
+
size_usd: float = 0.0
|
| 106 |
+
price_deviation_pct: float = 0.0
|
| 107 |
+
time_delta_seconds: float = 0.0
|
| 108 |
+
confidence: float = 0.0
|
| 109 |
+
|
| 110 |
+
def to_dict(self) -> dict:
|
| 111 |
+
return {
|
| 112 |
+
"buy_wallet": self.buy_wallet[:12] + "...",
|
| 113 |
+
"sell_wallet": self.sell_wallet[:12] + "...",
|
| 114 |
+
"size_usd": round(self.size_usd, 2),
|
| 115 |
+
"price_deviation_pct": round(self.price_deviation_pct, 2),
|
| 116 |
+
"time_delta_seconds": round(self.time_delta_seconds, 2),
|
| 117 |
+
"confidence": round(self.confidence, 3),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass
|
| 122 |
+
class VolumeAnomaly:
|
| 123 |
+
"""Anomalous volume patterns indicating possible wash trading."""
|
| 124 |
+
description: str = ""
|
| 125 |
+
metric_name: str = ""
|
| 126 |
+
metric_value: float = 0.0
|
| 127 |
+
threshold_value: float = 0.0
|
| 128 |
+
severity: str = "low" # low, medium, high, critical
|
| 129 |
+
|
| 130 |
+
def to_dict(self) -> dict:
|
| 131 |
+
return {
|
| 132 |
+
"description": self.description,
|
| 133 |
+
"metric_name": self.metric_name,
|
| 134 |
+
"metric_value": round(self.metric_value, 2),
|
| 135 |
+
"threshold": round(self.threshold_value, 2),
|
| 136 |
+
"severity": self.severity,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@dataclass
|
| 141 |
+
class WashTradingReport:
|
| 142 |
+
"""Complete wash trading analysis report for a token."""
|
| 143 |
+
token_address: str = ""
|
| 144 |
+
chain: str = ""
|
| 145 |
+
name: str = ""
|
| 146 |
+
symbol: str = ""
|
| 147 |
+
wash_score: float = 0.0 # 0-100
|
| 148 |
+
risk_label: str = "none"
|
| 149 |
+
|
| 150 |
+
estimated_wash_volume_usd: float = 0.0
|
| 151 |
+
total_volume_usd: float = 0.0
|
| 152 |
+
wash_volume_pct: float = 0.0
|
| 153 |
+
|
| 154 |
+
self_trades: list[SelfTrade] = field(default_factory=list)
|
| 155 |
+
circular_trades: list[CircularTrade] = field(default_factory=list)
|
| 156 |
+
matched_orders: list[MatchedOrder] = field(default_factory=list)
|
| 157 |
+
volume_anomalies: list[VolumeAnomaly] = field(default_factory=list)
|
| 158 |
+
known_wash_address_hits: int = 0
|
| 159 |
+
|
| 160 |
+
num_trades_analyzed: int = 0
|
| 161 |
+
unique_traders: int = 0
|
| 162 |
+
top_trader_volume_pct: float = 0.0
|
| 163 |
+
top_3_trader_volume_pct: float = 0.0
|
| 164 |
+
volume_per_trader_gini: float = 0.0
|
| 165 |
+
errors: list[str] = field(default_factory=list)
|
| 166 |
+
|
| 167 |
+
def to_dict(self) -> dict:
|
| 168 |
+
return {
|
| 169 |
+
"token_address": self.token_address,
|
| 170 |
+
"chain": self.chain,
|
| 171 |
+
"name": self.name,
|
| 172 |
+
"symbol": self.symbol,
|
| 173 |
+
"wash_score": round(self.wash_score, 1),
|
| 174 |
+
"risk_label": self.risk_label,
|
| 175 |
+
"estimated_wash_volume_usd": round(self.estimated_wash_volume_usd, 2),
|
| 176 |
+
"total_volume_usd": round(self.total_volume_usd, 2),
|
| 177 |
+
"wash_volume_pct": round(self.wash_volume_pct, 1),
|
| 178 |
+
"signals": {
|
| 179 |
+
"self_trades": len(self.self_trades),
|
| 180 |
+
"circular_trades": len(self.circular_trades),
|
| 181 |
+
"matched_orders": len(self.matched_orders),
|
| 182 |
+
"volume_anomalies": len(self.volume_anomalies),
|
| 183 |
+
"known_wash_address_hits": self.known_wash_address_hits,
|
| 184 |
+
},
|
| 185 |
+
"self_trades": [st.to_dict() for st in self.self_trades[:5]],
|
| 186 |
+
"circular_trades": [ct.to_dict() for ct in self.circular_trades[:5]],
|
| 187 |
+
"matched_orders": [mo.to_dict() for mo in self.matched_orders[:10]],
|
| 188 |
+
"volume_anomalies": [va.to_dict() for va in self.volume_anomalies],
|
| 189 |
+
"statistics": {
|
| 190 |
+
"num_trades_analyzed": self.num_trades_analyzed,
|
| 191 |
+
"unique_traders": self.unique_traders,
|
| 192 |
+
"top_trader_volume_pct": round(self.top_trader_volume_pct, 1),
|
| 193 |
+
"top_3_trader_volume_pct": round(self.top_3_trader_volume_pct, 1),
|
| 194 |
+
"volume_per_trader_gini": round(self.volume_per_trader_gini, 3),
|
| 195 |
+
},
|
| 196 |
+
"errors": self.errors,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
def summary(self) -> str:
|
| 200 |
+
label_emoji = {
|
| 201 |
+
"critical": "π΄ CRITICAL",
|
| 202 |
+
"high": "π HIGH",
|
| 203 |
+
"medium": "π‘ MEDIUM",
|
| 204 |
+
"low": "π΅ LOW",
|
| 205 |
+
"none": "β
NONE",
|
| 206 |
+
}.get(self.risk_label, "βͺ UNKNOWN")
|
| 207 |
+
|
| 208 |
+
return (
|
| 209 |
+
f"{label_emoji} Wash Trading β {self.symbol or self.name or self.token_address[:12]} | "
|
| 210 |
+
f"Score: {self.wash_score:.0f}/100 | "
|
| 211 |
+
f"Wash Volume: ${self.estimated_wash_volume_usd:,.0f} ({self.wash_volume_pct:.0f}% of ${self.total_volume_usd:,.0f}) | "
|
| 212 |
+
f"Self-Trades: {len(self.self_trades)} | "
|
| 213 |
+
f"Circular Rings: {len(self.circular_trades)} | "
|
| 214 |
+
f"Matched Orders: {len(self.matched_orders)} | "
|
| 215 |
+
f"Traders: {self.unique_traders} | "
|
| 216 |
+
f"Top-3 Volume: {self.top_3_trader_volume_pct:.0f}%"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ββ Helper Functions ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _gini_coefficient(values: list[float]) -> float:
|
| 224 |
+
"""Calculate Gini coefficient of a distribution (0=perfectly equal, 1=perfectly concentrated)."""
|
| 225 |
+
if not values:
|
| 226 |
+
return 0.0
|
| 227 |
+
sorted_vals = sorted(values)
|
| 228 |
+
n = len(sorted_vals)
|
| 229 |
+
if n == 1:
|
| 230 |
+
return 0.0
|
| 231 |
+
cumulative = 0.0
|
| 232 |
+
for i, v in enumerate(sorted_vals, 1):
|
| 233 |
+
cumulative += (2 * i - n - 1) * v
|
| 234 |
+
if sum(sorted_vals) == 0:
|
| 235 |
+
return 0.0
|
| 236 |
+
return cumulative / (n * sum(sorted_vals))
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _entropy(values: list[float]) -> float:
|
| 240 |
+
"""Calculate normalized Shannon entropy of a distribution (0=concentrated, 1=uniform)."""
|
| 241 |
+
if not values:
|
| 242 |
+
return 0.0
|
| 243 |
+
total = sum(values)
|
| 244 |
+
if total == 0:
|
| 245 |
+
return 0.0
|
| 246 |
+
probs = [v / total for v in values if v > 0]
|
| 247 |
+
if not probs:
|
| 248 |
+
return 0.0
|
| 249 |
+
n = len(probs)
|
| 250 |
+
if n <= 1:
|
| 251 |
+
return 1.0
|
| 252 |
+
h = -sum(p * math.log(p) for p in probs)
|
| 253 |
+
return h / math.log(n) if n > 1 else 0.0
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _label_risk(score: float) -> str:
|
| 257 |
+
"""Convert a numeric score to a risk label."""
|
| 258 |
+
if score >= 80:
|
| 259 |
+
return "critical"
|
| 260 |
+
if score >= 60:
|
| 261 |
+
return "high"
|
| 262 |
+
if score >= 35:
|
| 263 |
+
return "medium"
|
| 264 |
+
if score >= 10:
|
| 265 |
+
return "low"
|
| 266 |
+
return "none"
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _wallet_fingerprint(address: str) -> str:
|
| 270 |
+
"""Create a simplified fingerprint of a wallet's pattern (for identifying controlled wallets)."""
|
| 271 |
+
if not address:
|
| 272 |
+
return ""
|
| 273 |
+
clean = address.lower().replace("0x", "")
|
| 274 |
+
# Use first and last 4 chars plus length as a basic identifier
|
| 275 |
+
return f"{clean[:4]}...{clean[-4:]}({len(clean)})"
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _hex_hash(data: str) -> str:
|
| 279 |
+
"""Simple deterministic hash for grouping."""
|
| 280 |
+
return hashlib.md5(data.encode()).hexdigest()[:12]
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# ββ Core Detector βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class WashTradingDetector:
|
| 287 |
+
"""Detects wash trading patterns across a token's trade history."""
|
| 288 |
+
|
| 289 |
+
def __init__(self):
|
| 290 |
+
self._known_wash_addresses: set[str] = set()
|
| 291 |
+
self._load_known_wash_addresses()
|
| 292 |
+
|
| 293 |
+
def _load_known_wash_addresses(self):
|
| 294 |
+
"""Load known wash trading addresses from local data or defaults."""
|
| 295 |
+
try:
|
| 296 |
+
if os.path.exists(KNOWN_WASH_ADDRESSES_PATH):
|
| 297 |
+
with open(KNOWN_WASH_ADDRESSES_PATH) as f:
|
| 298 |
+
data = json.load(f)
|
| 299 |
+
self._known_wash_addresses = set(
|
| 300 |
+
a.lower() for a in data.get("addresses", [])
|
| 301 |
+
)
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.debug(f"Could not load known wash addresses: {e}")
|
| 304 |
+
|
| 305 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββ
|
| 306 |
+
|
| 307 |
+
async def scan(
|
| 308 |
+
self,
|
| 309 |
+
token_address: str,
|
| 310 |
+
chain: str = "ethereum",
|
| 311 |
+
trades: list[dict] | None = None,
|
| 312 |
+
) -> WashTradingReport:
|
| 313 |
+
"""
|
| 314 |
+
Analyze trades for wash trading patterns.
|
| 315 |
+
|
| 316 |
+
Args:
|
| 317 |
+
token_address: Token contract address
|
| 318 |
+
chain: Blockchain name
|
| 319 |
+
trades: List of trade dicts with keys:
|
| 320 |
+
- tx_hash: str
|
| 321 |
+
- buyer: str (wallet address)
|
| 322 |
+
- seller: str (wallet address)
|
| 323 |
+
- amount_usd: float
|
| 324 |
+
- price: float (price per token)
|
| 325 |
+
- timestamp: float (unix timestamp)
|
| 326 |
+
- volume_usd: float (total trade volume)
|
| 327 |
+
"""
|
| 328 |
+
report = WashTradingReport(
|
| 329 |
+
token_address=token_address,
|
| 330 |
+
chain=chain,
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
if not trades or len(trades) < MIN_TRADES_FOR_ANALYSIS:
|
| 334 |
+
report.errors.append(
|
| 335 |
+
f"Insufficient trade data: need β₯{MIN_TRADES_FOR_ANALYSIS} trades, "
|
| 336 |
+
f"got {len(trades or [])}"
|
| 337 |
+
)
|
| 338 |
+
return report
|
| 339 |
+
|
| 340 |
+
report.num_trades_analyzed = len(trades)
|
| 341 |
+
|
| 342 |
+
# Extract buyers, sellers, and metadata
|
| 343 |
+
buyers = set()
|
| 344 |
+
sellers = set()
|
| 345 |
+
all_traders = set()
|
| 346 |
+
trader_volumes: dict[str, float] = defaultdict(float)
|
| 347 |
+
wallet_tx_map: dict[str, list[dict]] = defaultdict(list)
|
| 348 |
+
actual_total_volume = 0.0 # Each trade counted once
|
| 349 |
+
|
| 350 |
+
for tx in trades:
|
| 351 |
+
buyer = (tx.get("buyer") or "").lower()
|
| 352 |
+
seller = (tx.get("seller") or "").lower()
|
| 353 |
+
vol = float(tx.get("volume_usd") or tx.get("amount_usd") or 0)
|
| 354 |
+
actual_total_volume += vol
|
| 355 |
+
if buyer:
|
| 356 |
+
buyers.add(buyer)
|
| 357 |
+
all_traders.add(buyer)
|
| 358 |
+
trader_volumes[buyer] += vol
|
| 359 |
+
wallet_tx_map[buyer].append(tx)
|
| 360 |
+
if seller:
|
| 361 |
+
sellers.add(seller)
|
| 362 |
+
all_traders.add(seller)
|
| 363 |
+
trader_volumes[seller] += vol
|
| 364 |
+
wallet_tx_map[seller].append(tx)
|
| 365 |
+
|
| 366 |
+
report.unique_traders = len(all_traders)
|
| 367 |
+
total_volume = actual_total_volume
|
| 368 |
+
report.total_volume_usd = total_volume
|
| 369 |
+
|
| 370 |
+
if total_volume > 0:
|
| 371 |
+
sorted_volumes = sorted(trader_volumes.values(), reverse=True)
|
| 372 |
+
report.top_trader_volume_pct = (sorted_volumes[0] / total_volume) * 100 if sorted_volumes else 0
|
| 373 |
+
report.top_3_trader_volume_pct = (
|
| 374 |
+
sum(sorted_volumes[:3]) / total_volume * 100 if len(sorted_volumes) >= 3
|
| 375 |
+
else sum(sorted_volumes) / total_volume * 100 if sorted_volumes
|
| 376 |
+
else 0
|
| 377 |
+
)
|
| 378 |
+
report.volume_per_trader_gini = _gini_coefficient(sorted_volumes)
|
| 379 |
+
|
| 380 |
+
# Detect patterns
|
| 381 |
+
self_trades = self._detect_self_trades(trades, buyers & sellers, total_volume)
|
| 382 |
+
report.self_trades = self_trades
|
| 383 |
+
|
| 384 |
+
circular_trades = self._detect_circular_trades(trades, all_traders, total_volume)
|
| 385 |
+
report.circular_trades = circular_trades
|
| 386 |
+
|
| 387 |
+
matched_orders = self._detect_matched_orders(trades, total_volume)
|
| 388 |
+
report.matched_orders = matched_orders
|
| 389 |
+
|
| 390 |
+
volume_anomalies = self._detect_volume_anomalies(
|
| 391 |
+
trades, all_traders, trader_volumes, total_volume
|
| 392 |
+
)
|
| 393 |
+
report.volume_anomalies = volume_anomalies
|
| 394 |
+
|
| 395 |
+
# Check against known wash addresses
|
| 396 |
+
report.known_wash_address_hits = sum(
|
| 397 |
+
1 for addr in all_traders if addr in self._known_wash_addresses
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
# Calculate final scores
|
| 401 |
+
wash_score, wash_volume = self._compute_wash_score(
|
| 402 |
+
report, total_volume
|
| 403 |
+
)
|
| 404 |
+
report.wash_score = wash_score
|
| 405 |
+
report.estimated_wash_volume_usd = wash_volume
|
| 406 |
+
report.wash_volume_pct = (wash_volume / total_volume * 100) if total_volume > 0 else 0
|
| 407 |
+
report.risk_label = _label_risk(wash_score)
|
| 408 |
+
|
| 409 |
+
return report
|
| 410 |
+
|
| 411 |
+
# ββ Pattern Detection ββββββββββββββββββββββββββββββ
|
| 412 |
+
|
| 413 |
+
def _detect_self_trades(
|
| 414 |
+
self,
|
| 415 |
+
trades: list[dict],
|
| 416 |
+
overlapping_wallets: set[str],
|
| 417 |
+
total_volume: float,
|
| 418 |
+
) -> list[SelfTrade]:
|
| 419 |
+
"""
|
| 420 |
+
Detect self-trades where the same wallet appears as both buyer and seller
|
| 421 |
+
(or controlled wallets trading among themselves).
|
| 422 |
+
"""
|
| 423 |
+
self_trades: list[SelfTrade] = []
|
| 424 |
+
wallet_timestamps: dict[str, list[tuple[float, dict]]] = defaultdict(list)
|
| 425 |
+
|
| 426 |
+
for tx in trades:
|
| 427 |
+
buyer = (tx.get("buyer") or "").lower()
|
| 428 |
+
seller = (tx.get("seller") or "").lower()
|
| 429 |
+
ts = float(tx.get("timestamp", 0))
|
| 430 |
+
|
| 431 |
+
# Direct self-trade: buyer == seller
|
| 432 |
+
if buyer and seller and buyer == seller:
|
| 433 |
+
confidence = 0.95 # Very high confidence - it's the same wallet
|
| 434 |
+
self_trades.append(
|
| 435 |
+
SelfTrade(
|
| 436 |
+
wallet_a=buyer,
|
| 437 |
+
wallet_b=seller,
|
| 438 |
+
tx_hash=tx.get("tx_hash", ""),
|
| 439 |
+
token_address=tx.get("token_address", ""),
|
| 440 |
+
amount_usd=float(tx.get("volume_usd", tx.get("amount_usd", 0))),
|
| 441 |
+
timestamp=ts,
|
| 442 |
+
confidence=confidence,
|
| 443 |
+
)
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# Track wallet activity for cross-wallet self-trade detection
|
| 447 |
+
if buyer:
|
| 448 |
+
wallet_timestamps[buyer].append((ts, tx))
|
| 449 |
+
if seller:
|
| 450 |
+
wallet_timestamps[seller].append((ts, tx))
|
| 451 |
+
|
| 452 |
+
# Cross-wallet self-trade: wallets that always trade together
|
| 453 |
+
# in a coordinated manner (A sells, B buys, repeatedly)
|
| 454 |
+
wallet_pair_trades: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
| 455 |
+
for tx in trades:
|
| 456 |
+
buyer = (tx.get("buyer") or "").lower()
|
| 457 |
+
seller = (tx.get("seller") or "").lower()
|
| 458 |
+
if buyer and seller and buyer != seller:
|
| 459 |
+
pair = (buyer, seller) if buyer < seller else (seller, buyer)
|
| 460 |
+
wallet_pair_trades[pair].append(tx)
|
| 461 |
+
|
| 462 |
+
for pair, pair_txs in wallet_pair_trades.items():
|
| 463 |
+
if len(pair_txs) >= 3:
|
| 464 |
+
# This pair trades together suspiciously often
|
| 465 |
+
total_pair_vol = sum(
|
| 466 |
+
float(tx.get("volume_usd", tx.get("amount_usd", 0)))
|
| 467 |
+
for tx in pair_txs
|
| 468 |
+
)
|
| 469 |
+
avg_confidence = min(0.6, 0.3 + len(pair_txs) * 0.05)
|
| 470 |
+
self_trades.append(
|
| 471 |
+
SelfTrade(
|
| 472 |
+
wallet_a=pair[0],
|
| 473 |
+
wallet_b=pair[1],
|
| 474 |
+
tx_hash=pair_txs[0].get("tx_hash", ""),
|
| 475 |
+
amount_usd=total_pair_vol,
|
| 476 |
+
timestamp=float(pair_txs[0].get("timestamp", 0)),
|
| 477 |
+
confidence=avg_confidence,
|
| 478 |
+
)
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
# Remove duplicates and limit β combine amounts for same pair
|
| 482 |
+
seen_pairs: dict[tuple[str, str], SelfTrade] = {}
|
| 483 |
+
for st in sorted(self_trades, key=lambda x: x.amount_usd, reverse=True):
|
| 484 |
+
pair = tuple(sorted([st.wallet_a, st.wallet_b]))
|
| 485 |
+
if pair in seen_pairs:
|
| 486 |
+
# Add amount to existing
|
| 487 |
+
seen_pairs[pair].amount_usd += st.amount_usd
|
| 488 |
+
else:
|
| 489 |
+
seen_pairs[pair] = st
|
| 490 |
+
|
| 491 |
+
unique_trades = list(seen_pairs.values())
|
| 492 |
+
return unique_trades[:20] # Cap at 20
|
| 493 |
+
|
| 494 |
+
def _detect_circular_trades(
|
| 495 |
+
self,
|
| 496 |
+
trades: list[dict],
|
| 497 |
+
all_traders: set[str],
|
| 498 |
+
total_volume: float,
|
| 499 |
+
) -> list[CircularTrade]:
|
| 500 |
+
"""
|
| 501 |
+
Detect circular trade patterns: AβBβCβA within a short time window.
|
| 502 |
+
Uses graph-based cycle detection.
|
| 503 |
+
"""
|
| 504 |
+
if len(trades) < 5:
|
| 505 |
+
return []
|
| 506 |
+
|
| 507 |
+
# Build directed graph of trades
|
| 508 |
+
# Edge: buyer -> seller with list of (tx_hash, amount, timestamp)
|
| 509 |
+
graph: dict[str, dict[str, list[tuple[str, float, float]]]] = defaultdict(
|
| 510 |
+
lambda: defaultdict(list)
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
for tx in trades:
|
| 514 |
+
buyer = (tx.get("buyer") or "").lower()
|
| 515 |
+
seller = (tx.get("seller") or "").lower()
|
| 516 |
+
if buyer and seller and buyer != seller:
|
| 517 |
+
graph[buyer][seller].append((
|
| 518 |
+
tx.get("tx_hash", ""),
|
| 519 |
+
float(tx.get("volume_usd", tx.get("amount_usd", 0))),
|
| 520 |
+
float(tx.get("timestamp", 0)),
|
| 521 |
+
))
|
| 522 |
+
|
| 523 |
+
cycles: list[CircularTrade] = []
|
| 524 |
+
|
| 525 |
+
# Detect 3-cycles (AβBβCβA)
|
| 526 |
+
for a in list(graph.keys())[:50]: # Limit to top 50 traders for perf
|
| 527 |
+
for b in graph.get(a, {}):
|
| 528 |
+
for c in graph.get(b, {}):
|
| 529 |
+
if c in graph and a in graph.get(c, {}):
|
| 530 |
+
# Found 3-cycle: AβBβCβA
|
| 531 |
+
edges: list[tuple[str, str, str, float, float]] = []
|
| 532 |
+
# AβB
|
| 533 |
+
for tx_a_b in graph[a][b]:
|
| 534 |
+
edges.append((a, b, tx_a_b[0], tx_a_b[1], tx_a_b[2]))
|
| 535 |
+
# BβC
|
| 536 |
+
for tx_b_c in graph[b][c]:
|
| 537 |
+
edges.append((b, c, tx_b_c[0], tx_b_c[1], tx_b_c[2]))
|
| 538 |
+
# CβA
|
| 539 |
+
for tx_c_a in graph[c][a]:
|
| 540 |
+
edges.append((c, a, tx_c_a[0], tx_c_a[1], tx_c_a[2]))
|
| 541 |
+
|
| 542 |
+
if len(edges) >= 3:
|
| 543 |
+
wallets = [a, b, c]
|
| 544 |
+
txs = [e[2] for e in edges if e[2]]
|
| 545 |
+
total_cycle_vol = sum(e[3] for e in edges)
|
| 546 |
+
timestamps = [e[4] for e in edges if e[4] > 0]
|
| 547 |
+
time_span = max(timestamps) - min(timestamps) if timestamps else 0
|
| 548 |
+
|
| 549 |
+
# Only flag if within time window
|
| 550 |
+
if time_span <= WASH_CLUSTER_TIME_WINDOW:
|
| 551 |
+
confidence = min(0.9, 0.5 + len(edges) * 0.05)
|
| 552 |
+
cycles.append(
|
| 553 |
+
CircularTrade(
|
| 554 |
+
wallets=wallets,
|
| 555 |
+
tx_hashes=[e for e in txs if e],
|
| 556 |
+
total_volume_usd=total_cycle_vol,
|
| 557 |
+
time_span_seconds=time_span,
|
| 558 |
+
confidence=confidence,
|
| 559 |
+
)
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
# Remove duplicate cycles (same wallet set)
|
| 563 |
+
unique_cycles: list[CircularTrade] = []
|
| 564 |
+
seen_cycle_sets: set[str] = set()
|
| 565 |
+
for c in sorted(cycles, key=lambda x: x.total_volume_usd, reverse=True):
|
| 566 |
+
key = _hex_hash("|".join(sorted(c.wallets)))
|
| 567 |
+
if key not in seen_cycle_sets:
|
| 568 |
+
seen_cycle_sets.add(key)
|
| 569 |
+
unique_cycles.append(c)
|
| 570 |
+
|
| 571 |
+
return unique_cycles[:10]
|
| 572 |
+
|
| 573 |
+
def _detect_matched_orders(
|
| 574 |
+
self,
|
| 575 |
+
trades: list[dict],
|
| 576 |
+
total_volume: float,
|
| 577 |
+
) -> list[MatchedOrder]:
|
| 578 |
+
"""
|
| 579 |
+
Detect matched orders: buy and sell of nearly identical size
|
| 580 |
+
at nearly identical prices within a short time window.
|
| 581 |
+
"""
|
| 582 |
+
matched: list[MatchedOrder] = []
|
| 583 |
+
|
| 584 |
+
# Sort by timestamp
|
| 585 |
+
sorted_trades = sorted(
|
| 586 |
+
trades, key=lambda x: float(x.get("timestamp", 0))
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
for i, tx_a in enumerate(sorted_trades):
|
| 590 |
+
buyer_a = (tx_a.get("buyer") or "").lower()
|
| 591 |
+
seller_a = (tx_a.get("seller") or "").lower()
|
| 592 |
+
amount_a = float(tx_a.get("volume_usd", tx_a.get("amount_usd", 0)))
|
| 593 |
+
price_a = float(tx_a.get("price", 0))
|
| 594 |
+
ts_a = float(tx_a.get("timestamp", 0))
|
| 595 |
+
|
| 596 |
+
if amount_a <= 0:
|
| 597 |
+
continue
|
| 598 |
+
|
| 599 |
+
# Check subsequent trades within 60 seconds
|
| 600 |
+
for j in range(i + 1, min(i + 20, len(sorted_trades))):
|
| 601 |
+
tx_b = sorted_trades[j]
|
| 602 |
+
buyer_b = (tx_b.get("buyer") or "").lower()
|
| 603 |
+
seller_b = (tx_b.get("seller") or "").lower()
|
| 604 |
+
amount_b = float(tx_b.get("volume_usd", tx_b.get("amount_usd", 0)))
|
| 605 |
+
price_b = float(tx_b.get("price", 0))
|
| 606 |
+
ts_b = float(tx_b.get("timestamp", 0))
|
| 607 |
+
|
| 608 |
+
time_delta = ts_b - ts_a
|
| 609 |
+
if time_delta > 60: # Outside window
|
| 610 |
+
break
|
| 611 |
+
|
| 612 |
+
if amount_b <= 0 or price_b <= 0 or price_a <= 0:
|
| 613 |
+
continue
|
| 614 |
+
|
| 615 |
+
# Check if these are complementary trades
|
| 616 |
+
# (buyer of A = seller of B, seller of A = buyer of B, or similar)
|
| 617 |
+
is_complementary = (
|
| 618 |
+
(buyer_a == seller_b and seller_a == buyer_b) or # swapped
|
| 619 |
+
(buyer_a == buyer_b and seller_a == seller_b) or # same direction
|
| 620 |
+
(buyer_a and seller_b and not seller_a and not buyer_b) # partial
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
if not is_complementary:
|
| 624 |
+
continue
|
| 625 |
+
|
| 626 |
+
# Check size similarity (within 10%)
|
| 627 |
+
size_ratio = min(amount_a, amount_b) / max(amount_a, amount_b) if max(amount_a, amount_b) > 0 else 0
|
| 628 |
+
if size_ratio >= 0.9: # Nearly identical sizes
|
| 629 |
+
price_dev = abs(price_a - price_b) / max(price_a, price_b) * 100
|
| 630 |
+
confidence = min(
|
| 631 |
+
0.95,
|
| 632 |
+
0.5 + (size_ratio - 0.9) * 2 + max(0, 1 - price_dev / 10) * 0.2
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
matched.append(
|
| 636 |
+
MatchedOrder(
|
| 637 |
+
buy_wallet=buyer_a if buyer_a else buyer_b,
|
| 638 |
+
sell_wallet=seller_a if seller_a else seller_b,
|
| 639 |
+
buy_tx=tx_a.get("tx_hash", ""),
|
| 640 |
+
sell_tx=tx_b.get("tx_hash", ""),
|
| 641 |
+
size_usd=max(amount_a, amount_b),
|
| 642 |
+
price_deviation_pct=price_dev,
|
| 643 |
+
time_delta_seconds=time_delta,
|
| 644 |
+
confidence=confidence,
|
| 645 |
+
)
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
# Keep only high-confidence matches and limit
|
| 649 |
+
matched = [m for m in matched if m.confidence >= 0.6]
|
| 650 |
+
return sorted(matched, key=lambda x: x.confidence, reverse=True)[:20]
|
| 651 |
+
|
| 652 |
+
def _detect_volume_anomalies(
|
| 653 |
+
self,
|
| 654 |
+
trades: list[dict],
|
| 655 |
+
all_traders: set[str],
|
| 656 |
+
trader_volumes: dict[str, float],
|
| 657 |
+
total_volume: float,
|
| 658 |
+
) -> list[VolumeAnomaly]:
|
| 659 |
+
"""Detect anomalous volume patterns indicative of wash trading."""
|
| 660 |
+
anomalies: list[VolumeAnomaly] = []
|
| 661 |
+
num_traders = len(all_traders)
|
| 662 |
+
|
| 663 |
+
# 1. Volume concentration: single trader > 50% of volume
|
| 664 |
+
if total_volume > 0:
|
| 665 |
+
sorted_vols = sorted(trader_volumes.values(), reverse=True)
|
| 666 |
+
if sorted_vols:
|
| 667 |
+
top_trader_pct = sorted_vols[0] / total_volume * 100
|
| 668 |
+
if top_trader_pct > 50:
|
| 669 |
+
severity = "critical" if top_trader_pct > 80 else "high"
|
| 670 |
+
anomalies.append(
|
| 671 |
+
VolumeAnomaly(
|
| 672 |
+
description=f"Top trader controls {top_trader_pct:.0f}% of all volume",
|
| 673 |
+
metric_name="top_trader_volume_pct",
|
| 674 |
+
metric_value=top_trader_pct,
|
| 675 |
+
threshold_value=50.0,
|
| 676 |
+
severity=severity,
|
| 677 |
+
)
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
top_3_pct = sum(sorted_vols[:3]) / total_volume * 100 if len(sorted_vols) >= 3 else 100
|
| 681 |
+
if top_3_pct > 85:
|
| 682 |
+
anomalies.append(
|
| 683 |
+
VolumeAnomaly(
|
| 684 |
+
description=f"Top 3 traders control {top_3_pct:.0f}% of all volume",
|
| 685 |
+
metric_name="top_3_trader_volume_pct",
|
| 686 |
+
metric_value=top_3_pct,
|
| 687 |
+
threshold_value=85.0,
|
| 688 |
+
severity="high",
|
| 689 |
+
)
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
# 2. High trader concentration (few traders, high volume)
|
| 693 |
+
if num_traders > 0 and total_volume > 1000:
|
| 694 |
+
volume_per_trader = total_volume / num_traders
|
| 695 |
+
if volume_per_trader > 50000: # $50k per trader - suspicious
|
| 696 |
+
anomalies.append(
|
| 697 |
+
VolumeAnomaly(
|
| 698 |
+
description=f"Average volume per trader: ${volume_per_trader:,.0f}",
|
| 699 |
+
metric_name="avg_volume_per_trader",
|
| 700 |
+
metric_value=volume_per_trader,
|
| 701 |
+
threshold_value=50000.0,
|
| 702 |
+
severity="medium" if volume_per_trader < 100000 else "high",
|
| 703 |
+
)
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
# 3. Trade frequency anomalies (clustered trades)
|
| 707 |
+
timestamps = [
|
| 708 |
+
float(tx.get("timestamp", 0))
|
| 709 |
+
for tx in trades
|
| 710 |
+
if tx.get("timestamp")
|
| 711 |
+
]
|
| 712 |
+
if len(timestamps) > 10:
|
| 713 |
+
timestamps.sort()
|
| 714 |
+
gaps = [
|
| 715 |
+
timestamps[i + 1] - timestamps[i]
|
| 716 |
+
for i in range(len(timestamps) - 1)
|
| 717 |
+
]
|
| 718 |
+
if gaps:
|
| 719 |
+
avg_gap = sum(gaps) / len(gaps)
|
| 720 |
+
min_gap = min(gaps)
|
| 721 |
+
if min_gap < 1 and avg_gap < 30:
|
| 722 |
+
# Extremely rapid trading - bot-like behavior
|
| 723 |
+
anomalies.append(
|
| 724 |
+
VolumeAnomaly(
|
| 725 |
+
description=f"Bot-like trading pattern: {len(trades)} trades with {avg_gap:.1f}s average gap",
|
| 726 |
+
metric_name="avg_trade_gap_seconds",
|
| 727 |
+
metric_value=avg_gap,
|
| 728 |
+
threshold_value=30.0,
|
| 729 |
+
severity="high",
|
| 730 |
+
)
|
| 731 |
+
)
|
| 732 |
+
|
| 733 |
+
# 4. High volume-to-liquidity ratio
|
| 734 |
+
# If we have liquidity data, check it
|
| 735 |
+
total_liquidity = sum(
|
| 736 |
+
float(tx.get("liquidity_usd", 0))
|
| 737 |
+
for tx in trades
|
| 738 |
+
if tx.get("liquidity_usd")
|
| 739 |
+
)
|
| 740 |
+
if total_liquidity > 0 and total_volume > 0:
|
| 741 |
+
vol_liq_ratio = total_volume / total_liquidity
|
| 742 |
+
if vol_liq_ratio > 5: # Volume > 5x liquidity - very suspicious
|
| 743 |
+
severity = "critical" if vol_liq_ratio > 20 else "high"
|
| 744 |
+
anomalies.append(
|
| 745 |
+
VolumeAnomaly(
|
| 746 |
+
description=f"Volume-to-liquidity ratio: {vol_liq_ratio:.1f}x (suggests artificial volume)",
|
| 747 |
+
metric_name="volume_liquidity_ratio",
|
| 748 |
+
metric_value=vol_liq_ratio,
|
| 749 |
+
threshold_value=5.0,
|
| 750 |
+
severity=severity,
|
| 751 |
+
)
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
# 5. No unique traders beyond a small set
|
| 755 |
+
if num_traders <= 3 and total_volume > 10000:
|
| 756 |
+
anomalies.append(
|
| 757 |
+
VolumeAnomaly(
|
| 758 |
+
description=f"Only {num_traders} unique traders for ${total_volume:,.0f} volume",
|
| 759 |
+
metric_name="unique_traders",
|
| 760 |
+
metric_value=float(num_traders),
|
| 761 |
+
threshold_value=10.0,
|
| 762 |
+
severity="critical" if num_traders <= 2 else "high",
|
| 763 |
+
)
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
return anomalies
|
| 767 |
+
|
| 768 |
+
def _compute_wash_score(
|
| 769 |
+
self,
|
| 770 |
+
report: WashTradingReport,
|
| 771 |
+
total_volume: float,
|
| 772 |
+
) -> tuple[float, float]:
|
| 773 |
+
"""Compute overall wash trading score and estimated wash volume."""
|
| 774 |
+
score_components: list[tuple[float, float]] = [] # (score, weight)
|
| 775 |
+
|
| 776 |
+
# Self-trade score
|
| 777 |
+
if report.self_trades:
|
| 778 |
+
self_trade_vol = sum(st.amount_usd for st in report.self_trades)
|
| 779 |
+
self_trade_score = min(100, len(report.self_trades) * 15 + (self_trade_vol / max(total_volume, 1)) * 100)
|
| 780 |
+
score_components.append((self_trade_score, SELF_TRADE_SCORE_WEIGHT))
|
| 781 |
+
|
| 782 |
+
# Circular trade score
|
| 783 |
+
if report.circular_trades:
|
| 784 |
+
circular_vol = sum(ct.total_volume_usd for ct in report.circular_trades)
|
| 785 |
+
circular_score = min(100, len(report.circular_trades) * 20 + (circular_vol / max(total_volume, 1)) * 100)
|
| 786 |
+
score_components.append((circular_score, CIRCULAR_TRADE_SCORE_WEIGHT))
|
| 787 |
+
|
| 788 |
+
# Matched order score
|
| 789 |
+
if report.matched_orders:
|
| 790 |
+
matched_vol = sum(mo.size_usd for mo in report.matched_orders)
|
| 791 |
+
matched_score = min(100, len(report.matched_orders) * 10 + (matched_vol / max(total_volume, 1)) * 100)
|
| 792 |
+
score_components.append((matched_score, MATCHED_ORDER_SCORE_WEIGHT))
|
| 793 |
+
|
| 794 |
+
# Volume anomaly score
|
| 795 |
+
if report.volume_anomalies:
|
| 796 |
+
severity_scores = {"low": 10, "medium": 35, "high": 65, "critical": 90}
|
| 797 |
+
anomaly_score = sum(
|
| 798 |
+
severity_scores.get(a.severity, 10) for a in report.volume_anomalies
|
| 799 |
+
) / len(report.volume_anomalies)
|
| 800 |
+
score_components.append((anomaly_score, VOLUME_ANOMALY_SCORE_WEIGHT))
|
| 801 |
+
|
| 802 |
+
# Top-3 trader concentration boost
|
| 803 |
+
if report.top_3_trader_volume_pct > 85:
|
| 804 |
+
concentration_bonus = (report.top_3_trader_volume_pct - 85) * 0.5
|
| 805 |
+
score_components.append((concentration_bonus, 0.1))
|
| 806 |
+
|
| 807 |
+
# Gini coefficient boost
|
| 808 |
+
if report.volume_per_trader_gini > 0.7:
|
| 809 |
+
gini_bonus = (report.volume_per_trader_gini - 0.7) * 50
|
| 810 |
+
score_components.append((gini_bonus, 0.1))
|
| 811 |
+
|
| 812 |
+
# Known wash address hit
|
| 813 |
+
if report.known_wash_address_hits > 0:
|
| 814 |
+
score_components.append((min(100, report.known_wash_address_hits * 25), 0.15))
|
| 815 |
+
|
| 816 |
+
if not score_components:
|
| 817 |
+
return 0.0, 0.0
|
| 818 |
+
|
| 819 |
+
total_weight = sum(w for _, w in score_components)
|
| 820 |
+
if total_weight == 0:
|
| 821 |
+
return 0.0, 0.0
|
| 822 |
+
|
| 823 |
+
wash_score = sum(s * w for s, w in score_components) / total_weight
|
| 824 |
+
wash_score = min(100, max(0, wash_score))
|
| 825 |
+
|
| 826 |
+
# Estimate wash volume from detected patterns
|
| 827 |
+
wash_volume = sum(st.amount_usd for st in report.self_trades)
|
| 828 |
+
wash_volume += sum(ct.total_volume_usd for ct in report.circular_trades)
|
| 829 |
+
wash_volume += sum(mo.size_usd for mo in report.matched_orders)
|
| 830 |
+
|
| 831 |
+
# Add a portion of the suspicious high-volume trader activity
|
| 832 |
+
if report.top_trader_volume_pct > 50 and total_volume > 0:
|
| 833 |
+
excess_pct = report.top_trader_volume_pct - 30 # 30% is normal for top trader
|
| 834 |
+
if excess_pct > 0:
|
| 835 |
+
wash_volume += total_volume * (excess_pct / 100) * 0.5
|
| 836 |
+
|
| 837 |
+
return wash_score, wash_volume
|
| 838 |
+
|
| 839 |
+
async def close(self):
|
| 840 |
+
"""Cleanup resources."""
|
| 841 |
+
pass
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
# ββ Convenience Factory ββββββββββββββββββββββββββββββββββββββββββ
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
def create_detector() -> WashTradingDetector:
|
| 848 |
+
"""Create a new WashTradingDetector instance."""
|
| 849 |
+
return WashTradingDetector()
|