Skip to content

Commit 85b2793

Browse files
committed
feat: add batch trader profiling
1 parent c4d5c4a commit 85b2793

3 files changed

Lines changed: 572 additions & 0 deletions

File tree

src/analysis/trader_profiler.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import asdict, dataclass
4+
from pathlib import Path
5+
from typing import Any
6+
7+
from src.analysis.trader_alpha import build_trader_alpha_report, rank_trader_alpha
8+
from src.analysis.trader_discovery import DEFAULT_TRADER_DISCOVERY_DB, load_discovered_wallets
9+
from src.analysis.trader_network import build_trader_network, network_summary
10+
from src.analysis.trader_registry import (
11+
DEFAULT_TRADERS_DB,
12+
calculate_watch_score,
13+
load_wallet_report,
14+
save_wallet_report,
15+
)
16+
from src.analysis.trader_scanner import DEFAULT_WALLET_EXPORT_DIR, scan_wallet
17+
from src.analysis.wallet_forensics import build_wallet_forensics_report
18+
19+
DEFAULT_PROFILE_LIMIT = 25
20+
21+
22+
@dataclass
23+
class TraderProfile:
24+
wallet: str
25+
profile: str
26+
classification: str
27+
confidence: float
28+
alpha_score: int
29+
watch_score: int
30+
markets_traded: int
31+
shared_markets: int
32+
btc_volume: float
33+
eth_volume: float
34+
sol_volume: float
35+
merge_count: int
36+
redeem_count: int
37+
specialization: str
38+
39+
def to_dict(self) -> dict[str, Any]:
40+
payload = asdict(self)
41+
payload["profile"] = self.profile
42+
return payload
43+
44+
45+
def profile_wallet(
46+
wallet: str,
47+
*,
48+
scan_if_missing: bool = True,
49+
traders_db_path: str | Path = DEFAULT_TRADERS_DB,
50+
discovery_db_path: str | Path = DEFAULT_TRADER_DISCOVERY_DB,
51+
wallet_export_dir: str | Path = DEFAULT_WALLET_EXPORT_DIR,
52+
) -> TraderProfile:
53+
wallet = str(wallet or "").strip().lower()
54+
_ensure_wallet_analysis(
55+
wallet,
56+
scan_if_missing=scan_if_missing,
57+
traders_db_path=traders_db_path,
58+
wallet_export_dir=wallet_export_dir,
59+
)
60+
alpha = build_trader_alpha_report(
61+
wallet,
62+
traders_db_path=traders_db_path,
63+
discovery_db_path=discovery_db_path,
64+
)
65+
specialization = derive_specialization(
66+
classification=alpha.classification,
67+
confidence=alpha.confidence,
68+
activity_count=alpha.activity_count,
69+
markets_traded=alpha.markets_traded,
70+
shared_markets=alpha.shared_markets,
71+
overlap_ratio=alpha.overlap_ratio,
72+
merge_count=alpha.merge_count,
73+
redeem_count=alpha.redeem_count,
74+
btc_volume=alpha.asset_breakdown.get("BTC", 0.0),
75+
eth_volume=alpha.asset_breakdown.get("ETH", 0.0),
76+
sol_volume=alpha.asset_breakdown.get("SOL", 0.0),
77+
other_volume=alpha.asset_breakdown.get("OTHER", 0.0),
78+
)
79+
return TraderProfile(
80+
wallet=wallet,
81+
profile=specialization,
82+
classification=alpha.classification,
83+
confidence=alpha.confidence,
84+
alpha_score=alpha.alpha_score,
85+
watch_score=alpha.watch_score,
86+
markets_traded=alpha.markets_traded,
87+
shared_markets=alpha.shared_markets,
88+
btc_volume=alpha.asset_breakdown.get("BTC", 0.0),
89+
eth_volume=alpha.asset_breakdown.get("ETH", 0.0),
90+
sol_volume=alpha.asset_breakdown.get("SOL", 0.0),
91+
merge_count=alpha.merge_count,
92+
redeem_count=alpha.redeem_count,
93+
specialization=specialization,
94+
)
95+
96+
97+
def profile_traders(
98+
wallets: list[str] | None = None,
99+
*,
100+
wallet: str | None = None,
101+
top_connected: bool = False,
102+
top_alpha: bool = False,
103+
limit: int = DEFAULT_PROFILE_LIMIT,
104+
focus_wallet: str | None = None,
105+
scan_if_missing: bool = True,
106+
traders_db_path: str | Path = DEFAULT_TRADERS_DB,
107+
discovery_db_path: str | Path = DEFAULT_TRADER_DISCOVERY_DB,
108+
wallet_export_dir: str | Path = DEFAULT_WALLET_EXPORT_DIR,
109+
) -> list[dict[str, Any]]:
110+
selected = select_profile_wallets(
111+
wallets=wallets,
112+
wallet=wallet,
113+
top_connected=top_connected,
114+
top_alpha=top_alpha,
115+
limit=limit,
116+
focus_wallet=focus_wallet,
117+
traders_db_path=traders_db_path,
118+
discovery_db_path=discovery_db_path,
119+
wallet_export_dir=wallet_export_dir,
120+
)
121+
profiles = [
122+
profile_wallet(
123+
candidate,
124+
scan_if_missing=scan_if_missing,
125+
traders_db_path=traders_db_path,
126+
discovery_db_path=discovery_db_path,
127+
wallet_export_dir=wallet_export_dir,
128+
).to_dict()
129+
for candidate in selected
130+
]
131+
return profiles
132+
133+
134+
def select_profile_wallets(
135+
*,
136+
wallets: list[str] | None = None,
137+
wallet: str | None = None,
138+
top_connected: bool = False,
139+
top_alpha: bool = False,
140+
limit: int = DEFAULT_PROFILE_LIMIT,
141+
focus_wallet: str | None = None,
142+
traders_db_path: str | Path = DEFAULT_TRADERS_DB,
143+
discovery_db_path: str | Path = DEFAULT_TRADER_DISCOVERY_DB,
144+
wallet_export_dir: str | Path = DEFAULT_WALLET_EXPORT_DIR,
145+
) -> list[str]:
146+
if top_connected:
147+
network = build_trader_network(
148+
wallet=focus_wallet,
149+
traders_db_path=traders_db_path,
150+
discovery_db_path=discovery_db_path,
151+
wallet_export_dir=wallet_export_dir,
152+
)
153+
summary = network_summary(network, limit=limit, focus_wallet=focus_wallet)
154+
return _dedupe_wallets(
155+
[row["wallet"] for row in summary["top_connected_wallets"]],
156+
limit=limit,
157+
)
158+
if top_alpha:
159+
ranked = rank_trader_alpha(
160+
limit=limit,
161+
traders_db_path=traders_db_path,
162+
discovery_db_path=discovery_db_path,
163+
)
164+
return _dedupe_wallets([row["wallet"] for row in ranked], limit=limit)
165+
if wallet:
166+
return [str(wallet).strip().lower()]
167+
if wallets:
168+
return _dedupe_wallets(wallets, limit=limit)
169+
discovered = load_discovered_wallets(discovery_db_path, limit=limit)
170+
return _dedupe_wallets([candidate.wallet for candidate in discovered], limit=limit)
171+
172+
173+
def derive_specialization(
174+
*,
175+
classification: str,
176+
confidence: float,
177+
activity_count: int,
178+
markets_traded: int,
179+
shared_markets: int,
180+
overlap_ratio: float,
181+
merge_count: int,
182+
redeem_count: int,
183+
btc_volume: float,
184+
eth_volume: float,
185+
sol_volume: float,
186+
other_volume: float,
187+
) -> str:
188+
crypto_volume = btc_volume + eth_volume + sol_volume
189+
total_volume = crypto_volume + other_volume
190+
inventory_ops = merge_count + redeem_count
191+
192+
if classification == "arbitrage_trader" and (shared_markets >= 10 or overlap_ratio >= 0.25):
193+
return "cross-market arbitrage"
194+
195+
if classification == "quantitative_directional":
196+
return "directional trader"
197+
198+
if crypto_volume > 0:
199+
asset_volumes = {
200+
"btc": btc_volume,
201+
"eth": eth_volume,
202+
"sol": sol_volume,
203+
}
204+
top_asset, top_volume = max(asset_volumes.items(), key=lambda item: item[1])
205+
top_share = top_volume / crypto_volume
206+
if top_share >= 0.8:
207+
return f"{top_asset} specialist"
208+
209+
if inventory_ops >= 20 and activity_count >= 100 and inventory_ops / max(activity_count, 1) >= 0.12:
210+
return "high-frequency inventory manager"
211+
212+
if classification == "arbitrage_trader":
213+
return "cross-market arbitrage" if shared_markets >= 3 else "arbitrage trader"
214+
215+
if classification == "market_maker" and crypto_volume >= 100:
216+
return "crypto market maker"
217+
218+
if classification == "market_maker":
219+
return "crypto market maker" if crypto_volume > 0 else "market maker"
220+
221+
if classification == "mixed":
222+
if shared_markets >= 5 and overlap_ratio >= 0.2:
223+
return "cross-market arbitrage"
224+
if crypto_volume >= 100:
225+
return "crypto market maker"
226+
return "mixed strategy trader"
227+
228+
if total_volume > 0 and confidence >= 0.35:
229+
return "active trader"
230+
231+
return "unclassified trader"
232+
233+
234+
def _ensure_wallet_analysis(
235+
wallet: str,
236+
*,
237+
scan_if_missing: bool,
238+
traders_db_path: str | Path,
239+
wallet_export_dir: str | Path,
240+
) -> None:
241+
if load_wallet_report(wallet, db_path=str(traders_db_path)):
242+
return
243+
244+
export_path = Path(wallet_export_dir) / f"{wallet}_activity.json"
245+
if export_path.exists():
246+
report = build_wallet_forensics_report(export_path, wallet=wallet)
247+
report_payload = report.to_dict()
248+
watch_score = calculate_watch_score(
249+
report.classification,
250+
report.confidence,
251+
report.metrics,
252+
report.signals,
253+
)
254+
report_payload["watch_score"] = watch_score
255+
save_wallet_report(report_payload, watch_score=watch_score, db_path=str(traders_db_path))
256+
return
257+
258+
if scan_if_missing:
259+
scan_wallet(
260+
wallet,
261+
traders_db_path=traders_db_path,
262+
output_dir=wallet_export_dir,
263+
)
264+
265+
266+
def _dedupe_wallets(wallets: list[str], limit: int | None = None) -> list[str]:
267+
seen: set[str] = set()
268+
ordered: list[str] = []
269+
for wallet in wallets:
270+
normalized = str(wallet or "").strip().lower()
271+
if not normalized or normalized in seen:
272+
continue
273+
seen.add(normalized)
274+
ordered.append(normalized)
275+
if limit is not None and len(ordered) >= max(0, int(limit)):
276+
break
277+
return ordered

src/cli.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from src.analysis.trader_scanner import DEFAULT_WATCHLIST_PATH, scan_wallets as scan_trader_wallets
5353
from src.analysis.trader_alpha import rank_trader_alpha, trader_alpha_report
5454
from src.analysis.trader_network import build_trader_network, network_summary
55+
from src.analysis.trader_profiler import profile_traders
5556
from src.analysis.trader_registry import (
5657
list_traders,
5758
top_traders,
@@ -328,6 +329,28 @@ def trader_network_report_cli(
328329
return result
329330

330331

332+
def profile_traders_cli(
333+
wallet: str | None = None,
334+
top_connected: bool = False,
335+
top_alpha: bool = False,
336+
limit: int = 25,
337+
as_json: bool = False,
338+
) -> dict[str, Any]:
339+
profiles = profile_traders(
340+
wallet=wallet,
341+
top_connected=top_connected,
342+
top_alpha=top_alpha,
343+
limit=limit,
344+
focus_wallet=wallet if top_connected else None,
345+
)
346+
result = {"profiles": profiles}
347+
if as_json:
348+
print(json.dumps(result, indent=2, sort_keys=True))
349+
else:
350+
print(json.dumps(result, indent=2, sort_keys=True))
351+
return result
352+
353+
331354
def trader_registry_summary_cli(
332355
classification: str | None = None,
333356
min_watch_score: int = 0,
@@ -2285,6 +2308,13 @@ def main() -> None:
22852308
trader_network_parser.add_argument("--limit", type=int, default=25)
22862309
trader_network_parser.add_argument("--json", action="store_true")
22872310

2311+
profile_traders_parser = sub.add_parser("profile-traders", help="batch profile discovered traders from activity, forensics, and alpha")
2312+
profile_traders_parser.add_argument("--wallet")
2313+
profile_traders_parser.add_argument("--top-connected", action="store_true")
2314+
profile_traders_parser.add_argument("--top-alpha", action="store_true")
2315+
profile_traders_parser.add_argument("--limit", type=int, default=25)
2316+
profile_traders_parser.add_argument("--json", action="store_true")
2317+
22882318
trader_registry_summary_parser = sub.add_parser("trader-registry-summary", help="summary of tracked trader wallets")
22892319
trader_registry_summary_parser.add_argument("--classification", choices=["market_maker", "arbitrage_trader", "quantitative_directional", "mixed", "unknown"])
22902320
trader_registry_summary_parser.add_argument("--min-watch-score", type=int, default=0)
@@ -2775,6 +2805,14 @@ def main() -> None:
27752805
trader_alpha_report_cli(wallet=args.wallet, limit=args.limit, as_json=args.json)
27762806
elif args.command == "trader-network-report":
27772807
trader_network_report_cli(wallet=args.wallet, depth=args.depth, limit=args.limit, as_json=args.json)
2808+
elif args.command == "profile-traders":
2809+
profile_traders_cli(
2810+
wallet=args.wallet,
2811+
top_connected=args.top_connected,
2812+
top_alpha=args.top_alpha,
2813+
limit=args.limit,
2814+
as_json=args.json,
2815+
)
27782816
elif args.command == "trader-registry-summary":
27792817
trader_registry_summary_cli(
27802818
classification=args.classification,

0 commit comments

Comments
 (0)