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()