refactor(scanners): finish app.scanners → app.domains.scanners import migration + lint + mypy config (P4.8 cont)
Some checks failed
CI / build (push) Failing after 2s
Some checks failed
CI / build (push) Failing after 2s
This commit is contained in:
parent
d666ad2664
commit
4686cb3cfd
18 changed files with 440 additions and 352 deletions
|
|
@ -168,7 +168,7 @@ async def investigation_graph(req: GraphRequest):
|
|||
)
|
||||
|
||||
# ── Convert to cytoscape.js JSON ──────────────────────────
|
||||
NODE_COLORS = {
|
||||
node_colors = {
|
||||
"wallet": "#3498db",
|
||||
"token": "#2ecc71",
|
||||
"contract": "#e67e22",
|
||||
|
|
@ -177,7 +177,7 @@ async def investigation_graph(req: GraphRequest):
|
|||
"chain": "#1abc9c",
|
||||
"address": "#95a5a6",
|
||||
}
|
||||
NODE_SHAPES = {
|
||||
node_shapes = {
|
||||
"wallet": "ellipse",
|
||||
"token": "round-rectangle",
|
||||
"contract": "rectangle",
|
||||
|
|
@ -198,8 +198,8 @@ async def investigation_graph(req: GraphRequest):
|
|||
"id": key,
|
||||
"label": nd.get("label", nd.get("id", "")),
|
||||
"type": ntype,
|
||||
"color": NODE_COLORS.get(ntype, "#95a5a6"),
|
||||
"shape": NODE_SHAPES.get(ntype, "ellipse"),
|
||||
"color": node_colors.get(ntype, "#95a5a6"),
|
||||
"shape": node_shapes.get(ntype, "ellipse"),
|
||||
"size": 45 if key == raw.get("center") else 30,
|
||||
"metadata": nd.get("metadata", {}),
|
||||
},
|
||||
|
|
@ -246,7 +246,7 @@ async def fund_flow_sankey(req: GraphRequest):
|
|||
address = req.address.strip()
|
||||
|
||||
# Get fund flow data using existing visualizer
|
||||
from app.scanners.fund_flow_visualizer import (
|
||||
from app.domains.scanners.fund_flow_visualizer import (
|
||||
_build_flow_graph,
|
||||
_fetch_evm_wallets,
|
||||
_fetch_solana_wallets,
|
||||
|
|
@ -271,7 +271,9 @@ async def fund_flow_sankey(req: GraphRequest):
|
|||
sankey_nodes.append(
|
||||
{
|
||||
"id": idx,
|
||||
"name": n.address[:10] + "..." + n.address[-6:] if len(n.address) > 16 else n.address,
|
||||
"name": n.address[:10] + "..." + n.address[-6:]
|
||||
if len(n.address) > 16
|
||||
else n.address,
|
||||
"address": n.address,
|
||||
"role": n.role,
|
||||
"amount_usd": n.amount_usd,
|
||||
|
|
@ -293,14 +295,14 @@ async def fund_flow_sankey(req: GraphRequest):
|
|||
# Also generate cytoscape nodes/edges for graph overlay
|
||||
cy_nodes = []
|
||||
cy_edges = []
|
||||
ROLE_COLORS = {
|
||||
role_colors = {
|
||||
"creator": "#9b59b6",
|
||||
"funder": "#2ecc71",
|
||||
"lp": "#f1c40f",
|
||||
"seller": "#e74c3c",
|
||||
"other": "#95a5a6",
|
||||
}
|
||||
ROLE_LABELS = {
|
||||
role_labels = {
|
||||
"creator": "Creator/Deployer",
|
||||
"funder": "Early Funder",
|
||||
"lp": "LP Holder",
|
||||
|
|
@ -314,9 +316,9 @@ async def fund_flow_sankey(req: GraphRequest):
|
|||
{
|
||||
"data": {
|
||||
"id": n.address,
|
||||
"label": f"{ROLE_LABELS.get(n.role, n.role)}\n{short}",
|
||||
"label": f"{role_labels.get(n.role, n.role)}\n{short}",
|
||||
"type": n.role,
|
||||
"color": ROLE_COLORS.get(n.role, "#95a5a6"),
|
||||
"color": role_colors.get(n.role, "#95a5a6"),
|
||||
"size": 40 if n.role == "creator" else 28,
|
||||
"amount_usd": n.amount_usd,
|
||||
},
|
||||
|
|
|
|||
1
app/domains/__init__.py
Normal file
1
app/domains/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# app/domains package
|
||||
|
|
@ -20,6 +20,7 @@ decompiler, address labels, contract diff, fund flow, etc.) live in
|
|||
tools/report_tools.py. label_tools.py hosts the standalone address
|
||||
labeling endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
|
@ -43,7 +44,7 @@ async def sentinel_full_scan(req: SentinelScanRequest):
|
|||
Returns composite risk score (0-100), per-module breakdown, and aggregated red flags.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import dataclass_to_dict, run_sentinel_scan
|
||||
from app.domains.scanners.sentinel_pipeline import dataclass_to_dict, run_sentinel_scan
|
||||
|
||||
report = await run_sentinel_scan(
|
||||
token_address=req.address,
|
||||
|
|
@ -72,7 +73,7 @@ async def holder_analysis_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.05
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_holder_analysis
|
||||
from app.domains.scanners.sentinel_pipeline import run_holder_analysis
|
||||
|
||||
result = await run_holder_analysis(req.address, req.chain)
|
||||
await record_x402_payment("holder_analysis", "0.05", req.address)
|
||||
|
|
@ -97,7 +98,7 @@ async def bundle_detect_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.08
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_bundle_detection
|
||||
from app.domains.scanners.sentinel_pipeline import run_bundle_detection
|
||||
|
||||
result = await run_bundle_detection(req.address, req.chain)
|
||||
await record_x402_payment("bundle_detect", "0.08", req.address)
|
||||
|
|
@ -122,7 +123,7 @@ async def exchange_fund_check_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.05
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_exchange_funding
|
||||
from app.domains.scanners.sentinel_pipeline import run_exchange_funding
|
||||
|
||||
result = await run_exchange_funding(req.address, req.chain)
|
||||
await record_x402_payment("exchange_fund_check", "0.05", req.address)
|
||||
|
|
@ -147,7 +148,7 @@ async def liquidity_verify_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.05
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_liquidity_verification
|
||||
from app.domains.scanners.sentinel_pipeline import run_liquidity_verification
|
||||
|
||||
result = await run_liquidity_verification(req.address, req.chain)
|
||||
await record_x402_payment("liquidity_verify", "0.05", req.address)
|
||||
|
|
@ -173,7 +174,7 @@ async def dev_reputation_endpoint(req: SentinelModuleRequest):
|
|||
Requires dev_address (deployer/creator wallet). Falls back to address if dev_address not provided.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_dev_reputation
|
||||
from app.domains.scanners.sentinel_pipeline import run_dev_reputation
|
||||
|
||||
dev_wallet = req.dev_address or req.address
|
||||
result = await run_dev_reputation(dev_wallet, chains=[req.chain])
|
||||
|
|
@ -199,7 +200,7 @@ async def wash_trading_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.08
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_wash_trading
|
||||
from app.domains.scanners.sentinel_pipeline import run_wash_trading
|
||||
|
||||
result = await run_wash_trading(req.address, req.chain)
|
||||
await record_x402_payment("wash_trading", "0.08", req.address)
|
||||
|
|
@ -254,7 +255,7 @@ async def metadata_fingerprint_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.05
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_metadata_fingerprint
|
||||
from app.domains.scanners.sentinel_pipeline import run_metadata_fingerprint
|
||||
|
||||
result = await run_metadata_fingerprint(req.address, req.chain)
|
||||
await record_x402_payment("metadata_fingerprint", "0.05", req.address)
|
||||
|
|
@ -279,7 +280,7 @@ async def sentiment_check_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.05
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_sentiment
|
||||
from app.domains.scanners.sentinel_pipeline import run_sentiment
|
||||
|
||||
result = await run_sentiment(req.address, req.chain)
|
||||
await record_x402_payment("sentiment_check", "0.05", req.address)
|
||||
|
|
@ -312,7 +313,7 @@ async def pumpfun_analysis_endpoint(req: SentinelModuleRequest):
|
|||
f"Received chain={req.chain}. Use chain='solana'.",
|
||||
)
|
||||
|
||||
from app.scanners.sentinel_pipeline import run_pumpfun_analysis
|
||||
from app.domains.scanners.sentinel_pipeline import run_pumpfun_analysis
|
||||
|
||||
result = await run_pumpfun_analysis(req.address)
|
||||
await record_x402_payment("pumpfun_analysis", "0.08", req.address)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Resolved sources:
|
|||
|
||||
Other SENTINEL modules live in tools/evidence_tools.py and tools/report_tools.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
|
@ -34,7 +35,7 @@ async def address_labels_endpoint(req: SentinelModuleRequest):
|
|||
known scammer, deployer, etc.).
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_address_labels
|
||||
from app.domains.scanners.sentinel_pipeline import run_address_labels
|
||||
|
||||
result = await run_address_labels(req.address, req.chain)
|
||||
await record_x402_payment("address_labels", "0.08", req.address)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Address labels live in tools/label_tools.py.
|
|||
TIER 1 modules (holder analysis, bundle detect, etc.) live in
|
||||
tools/evidence_tools.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
|
@ -42,7 +43,7 @@ async def flash_loan_detect_endpoint(req: SentinelModuleRequest):
|
|||
volume exceeding pool liquidity ratio.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_flash_loan_detection
|
||||
from app.domains.scanners.sentinel_pipeline import run_flash_loan_detection
|
||||
|
||||
result = await run_flash_loan_detection(req.address, req.chain)
|
||||
await record_x402_payment("flash_loan_detect", "0.08", req.address)
|
||||
|
|
@ -70,7 +71,7 @@ async def pump_dump_detect_endpoint(req: SentinelModuleRequest):
|
|||
and rug pull lifecycle stage (deploy/pump/distribution/dump).
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_pump_dump_detection
|
||||
from app.domains.scanners.sentinel_pipeline import run_pump_dump_detection
|
||||
|
||||
result = await run_pump_dump_detection(req.address, req.chain)
|
||||
await record_x402_payment("pump_dump_detect", "0.08", req.address)
|
||||
|
|
@ -98,7 +99,7 @@ async def oracle_manipulation_endpoint(req: SentinelModuleRequest):
|
|||
Critical for lending/borrowing protocols that rely on price feeds.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_oracle_manipulation
|
||||
from app.domains.scanners.sentinel_pipeline import run_oracle_manipulation
|
||||
|
||||
result = await run_oracle_manipulation(req.address, req.chain)
|
||||
await record_x402_payment("oracle_manipulation", "0.08", req.address)
|
||||
|
|
@ -157,7 +158,7 @@ async def proxy_detect_endpoint(req: SentinelModuleRequest):
|
|||
rug contract patterns. Essential for EVM tokens behind proxies.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_proxy_detection
|
||||
from app.domains.scanners.sentinel_pipeline import run_proxy_detection
|
||||
|
||||
result = await run_proxy_detection(req.address, req.chain)
|
||||
await record_x402_payment("proxy_detect", "0.08", req.address)
|
||||
|
|
@ -185,7 +186,7 @@ async def static_analysis_endpoint(req: SentinelModuleRequest):
|
|||
and active threat alerts.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_static_analysis
|
||||
from app.domains.scanners.sentinel_pipeline import run_static_analysis
|
||||
|
||||
result = await run_static_analysis(req.address, req.chain)
|
||||
await record_x402_payment("static_analysis", "0.12", req.address)
|
||||
|
|
@ -213,7 +214,7 @@ async def decompiler_analysis_endpoint(req: SentinelModuleRequest):
|
|||
Essential for tokens with unverified source code.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_decompiler_analysis
|
||||
from app.domains.scanners.sentinel_pipeline import run_decompiler_analysis
|
||||
|
||||
result = await run_decompiler_analysis(req.address, req.chain)
|
||||
await record_x402_payment("decompiler_analysis", "0.10", req.address)
|
||||
|
|
@ -237,7 +238,7 @@ async def fund_flow_endpoint(req: SentinelModuleRequest):
|
|||
Pricing: $0.10
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_fund_flow
|
||||
from app.domains.scanners.sentinel_pipeline import run_fund_flow
|
||||
|
||||
result = await run_fund_flow(req.address, req.chain)
|
||||
await record_x402_payment("fund_flow", "0.10", req.address)
|
||||
|
|
@ -265,7 +266,7 @@ async def contract_diff_endpoint(req: SentinelModuleRequest):
|
|||
(withdrawAll, drain, setOwner, emergencyWithdraw) and rug-specific bytecode patterns.
|
||||
"""
|
||||
try:
|
||||
from app.scanners.sentinel_pipeline import run_contract_diff
|
||||
from app.domains.scanners.sentinel_pipeline import run_contract_diff
|
||||
|
||||
result = await run_contract_diff(req.address, req.chain)
|
||||
await record_x402_payment("contract_diff", "0.10", req.address)
|
||||
|
|
|
|||
|
|
@ -760,7 +760,7 @@ class RugImminencePredictor:
|
|||
try:
|
||||
# Try social velocity analyzer
|
||||
try:
|
||||
from app.scanners.social_velocity import SocialVelocityAnalyzer
|
||||
from app.domains.scanners.social_velocity import SocialVelocityAnalyzer
|
||||
|
||||
analyzer = SocialVelocityAnalyzer()
|
||||
velocity = await analyzer.analyze(result.token_address)
|
||||
|
|
@ -799,7 +799,7 @@ class RugImminencePredictor:
|
|||
|
||||
# Check for recent FUD/panic signals
|
||||
try:
|
||||
from app.scanners.sentiment_analyzer import SentimentAnalyzer
|
||||
from app.domains.scanners.sentiment_analyzer import SentimentAnalyzer
|
||||
|
||||
sentiment = SentimentAnalyzer()
|
||||
sent_result = await sentiment.analyze_token_sentiment(
|
||||
|
|
@ -837,7 +837,7 @@ class RugImminencePredictor:
|
|||
try:
|
||||
# Try honeypot detector
|
||||
try:
|
||||
from app.scanners.honeypot_detector import HoneypotDetector
|
||||
from app.domains.scanners.honeypot_detector import HoneypotDetector
|
||||
|
||||
hp = HoneypotDetector()
|
||||
hp_result = await hp.detect(result.token_address, result.chain)
|
||||
|
|
@ -863,7 +863,7 @@ class RugImminencePredictor:
|
|||
|
||||
# Check ownership status
|
||||
try:
|
||||
from app.scanners.contract_authority import ContractAuthorityScanner
|
||||
from app.domains.scanners.contract_authority import ContractAuthorityScanner
|
||||
|
||||
auth = ContractAuthorityScanner()
|
||||
authority = await auth.scan(result.token_address, result.chain)
|
||||
|
|
@ -907,7 +907,7 @@ class RugImminencePredictor:
|
|||
try:
|
||||
# Check recent LP changes via on-chain data
|
||||
try:
|
||||
from app.scanners.liquidity_verifier import LiquidityVerifier
|
||||
from app.domains.scanners.liquidity_verifier import LiquidityVerifier
|
||||
|
||||
verifier = LiquidityVerifier()
|
||||
lp_status = await verifier.verify(
|
||||
|
|
@ -959,7 +959,7 @@ class RugImminencePredictor:
|
|||
sig.flags.append("liquidity_drop")
|
||||
sig.evidence.append(f"Liquidity dropped {abs(liq_change):.0f}% in 24h")
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("liquidity change parse failed", exc_info=True)
|
||||
|
||||
sig.confidence = min(0.7, 0.3 + (sig.score / 40))
|
||||
|
||||
|
|
@ -1083,7 +1083,7 @@ class RugImminencePredictor:
|
|||
if pairs:
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("dexscreener fetch failed", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _resolve_deployer(self, address: str, chain: str) -> str | None:
|
||||
|
|
@ -1120,9 +1120,8 @@ class RugImminencePredictor:
|
|||
},
|
||||
)
|
||||
# This doesn't work directly - need the creation tx
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("deployer resolve failed", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _check_wallet_age(self, wallet: str, chain: str) -> dict | None:
|
||||
|
|
@ -1180,7 +1179,7 @@ class RugImminencePredictor:
|
|||
result = {"locked": False, "unlocked": True, "unlock_date": None}
|
||||
|
||||
try:
|
||||
from app.scanners.liquidity_verifier import LiquidityVerifier
|
||||
from app.domains.scanners.liquidity_verifier import LiquidityVerifier
|
||||
|
||||
verifier = LiquidityVerifier()
|
||||
lp_info = await verifier.check_lock(address, chain)
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ async def _cache_get(key: str) -> dict | None:
|
|||
if cached:
|
||||
return json.loads(cached)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("wallet cache get failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ async def _cache_set(key: str, data: dict, ttl: int = 3600):
|
|||
await r.setex(f"rmi:wallet_cache:{key}", ttl, json.dumps(data))
|
||||
await r.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("wallet cache set failed", exc_info=True)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
|
|
@ -232,6 +232,7 @@ async def _helius_call(method: str, params: list) -> dict | None:
|
|||
if resp.status_code in (401, 429):
|
||||
continue
|
||||
except Exception:
|
||||
logger.debug("helius call failed, trying next key", exc_info=True)
|
||||
continue
|
||||
return None
|
||||
|
||||
|
|
@ -362,7 +363,7 @@ async def _fetch_moralis_wallet(address: str, chain: str) -> dict[str, Any]:
|
|||
result["tx_count"] = len(txs)
|
||||
result["data_sources"] = [*result.get("data_sources", []), "moralis_tx"]
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("moralis wallet fetch failed", exc_info=True)
|
||||
|
||||
return result
|
||||
|
||||
|
|
@ -415,13 +416,15 @@ async def _fetch_etherscan_wallet(address: str, chain: str) -> dict[str, Any]:
|
|||
first_ts = int(txs[0].get("timeStamp", 0))
|
||||
last_ts = int(txs[-1].get("timeStamp", 0))
|
||||
if first_ts:
|
||||
result["wallet_age_days"] = int((datetime.now(UTC).timestamp() - first_ts) / 86400)
|
||||
result["wallet_age_days"] = int(
|
||||
(datetime.now(UTC).timestamp() - first_ts) / 86400
|
||||
)
|
||||
if last_ts:
|
||||
result["last_tx"] = last_ts
|
||||
result["tx_count_sample"] = len(txs)
|
||||
result["data_sources"] = ["etherscan"]
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("etherscan wallet fetch failed", exc_info=True)
|
||||
|
||||
return result
|
||||
|
||||
|
|
@ -434,7 +437,7 @@ async def _fetch_etherscan_wallet(address: str, chain: str) -> dict[str, Any]:
|
|||
async def _fetch_sentinel_labels(address: str, chain: str) -> dict[str, Any]:
|
||||
"""Get rich labels from SENTINEL address_labeler (6 sources)."""
|
||||
try:
|
||||
from app.scanners.address_labeler import AddressLabeler
|
||||
from app.domains.scanners.address_labeler import AddressLabeler
|
||||
|
||||
labeler = AddressLabeler()
|
||||
result = await labeler.analyze(address, chain)
|
||||
|
|
@ -477,7 +480,9 @@ async def _scan_held_tokens(tokens: list, chain: str) -> dict[str, Any]:
|
|||
return {"scanned": 0, "risks": [], "deployers_to_flag": []}
|
||||
|
||||
# Sort by amount (largest first), cap at 5 for speed
|
||||
sorted_tokens = sorted(tokens, key=lambda t: float(t.get("amount", 0) if isinstance(t, dict) else 0), reverse=True)
|
||||
sorted_tokens = sorted(
|
||||
tokens, key=lambda t: float(t.get("amount", 0) if isinstance(t, dict) else 0), reverse=True
|
||||
)
|
||||
mints = []
|
||||
for token in sorted_tokens[:5]: # Top 5 by balance
|
||||
mint = token.get("mint", "") if isinstance(token, dict) else token
|
||||
|
|
@ -722,8 +727,10 @@ async def _trace_funding_source(address: str, chain: str) -> dict[str, Any]:
|
|||
return {
|
||||
"source": source,
|
||||
"source_label": source_label.get("label", "unknown"),
|
||||
"is_cex": "exchange" in str(source_label).lower() or "cex" in str(source_label).lower(),
|
||||
"is_mixer": "mixer" in str(source_label).lower() or "tornado" in str(source_label).lower(),
|
||||
"is_cex": "exchange" in str(source_label).lower()
|
||||
or "cex" in str(source_label).lower(),
|
||||
"is_mixer": "mixer" in str(source_label).lower()
|
||||
or "tornado" in str(source_label).lower(),
|
||||
"hops": 1,
|
||||
}
|
||||
return {"source": "unknown", "hops": 0}
|
||||
|
|
@ -735,7 +742,7 @@ async def _resolve_entity_free(address: str, chain: str) -> dict[str, Any]:
|
|||
"""Basic cross-chain entity resolution - FREE tier (no Wallet Memory Bank)."""
|
||||
try:
|
||||
# Check SENTINEL labels first
|
||||
from app.scanners.address_labeler import AddressLabeler
|
||||
from app.domains.scanners.address_labeler import AddressLabeler
|
||||
|
||||
labeler = AddressLabeler()
|
||||
labels = await labeler.analyze(address, chain)
|
||||
|
|
@ -754,7 +761,9 @@ async def _resolve_entity_free(address: str, chain: str) -> dict[str, Any]:
|
|||
return {"label": "unknown", "source": "none", "chains": [chain]}
|
||||
|
||||
|
||||
async def _calculate_basic_pnl(address: str, chain: str, tokens: list, native_balance: float = 0) -> dict[str, Any]:
|
||||
async def _calculate_basic_pnl(
|
||||
address: str, chain: str, tokens: list, native_balance: float = 0
|
||||
) -> dict[str, Any]:
|
||||
"""Basic PnL calculation - FREE tier (no GMGN)."""
|
||||
try:
|
||||
total_value = native_balance or 0
|
||||
|
|
@ -765,14 +774,18 @@ async def _calculate_basic_pnl(address: str, chain: str, tokens: list, native_ba
|
|||
mints = [t.get("mint", "") for t in tokens[:10] if t.get("mint")]
|
||||
if mints:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
resp = await client.get("https://quote-api.jup.ag/v6/price", params={"ids": ",".join(mints[:5])})
|
||||
resp = await client.get(
|
||||
"https://quote-api.jup.ag/v6/price", params={"ids": ",".join(mints[:5])}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
prices = resp.json().get("data", {})
|
||||
for mint, pdata in prices.items():
|
||||
price = float(pdata.get("price", 0))
|
||||
for t in tokens:
|
||||
if t.get("mint") == mint:
|
||||
balance = float(t.get("amount", 0)) / (10 ** (t.get("decimals", 0) or 0))
|
||||
balance = float(t.get("amount", 0)) / (
|
||||
10 ** (t.get("decimals", 0) or 0)
|
||||
)
|
||||
total_value += balance * price
|
||||
|
||||
return {
|
||||
|
|
@ -810,7 +823,9 @@ async def _close_data_flywheel(deployers: list):
|
|||
logger.warning(f"Data flywheel failed: {e}")
|
||||
|
||||
|
||||
async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "free") -> dict[str, Any]:
|
||||
async def scan_wallet(
|
||||
wallet_address: str, chain: str = "solana", tier: str = "free"
|
||||
) -> dict[str, Any]:
|
||||
"""Unified wallet scanner - SENTINEL + Helius/Moralis/Etherscan pool + Wallet Memory + Token Scanner."""
|
||||
import time
|
||||
|
||||
|
|
@ -852,7 +867,7 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
"sol_balance": bal / 1e9 if bal else 0,
|
||||
"data_sources": ["solana_public"],
|
||||
}
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
else:
|
||||
# EVM chain: Moralis (primary) + Etherscan family (fallback)
|
||||
|
|
@ -860,7 +875,9 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
etherscan = await _fetch_etherscan_wallet(wallet_address, chain)
|
||||
if etherscan and not onchain.get("wallet_age_days"):
|
||||
onchain["wallet_age_days"] = etherscan.get("wallet_age_days", 0)
|
||||
onchain["data_sources"] = onchain.get("data_sources", []) + etherscan.get("data_sources", [])
|
||||
onchain["data_sources"] = onchain.get("data_sources", []) + etherscan.get(
|
||||
"data_sources", []
|
||||
)
|
||||
|
||||
if onchain:
|
||||
factors.wallet_age_days = onchain.get("wallet_age_days", 0)
|
||||
|
|
@ -877,31 +894,38 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
# ── DexScreener volume + labels (FREE tier, all chains) ──
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get("https://api.dexscreener.com/latest/dex/search", params={"q": wallet_address})
|
||||
resp = await client.get(
|
||||
"https://api.dexscreener.com/latest/dex/search", params={"q": wallet_address}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
pairs = resp.json().get("pairs", [])
|
||||
if pairs:
|
||||
factors.total_volume_usd = sum(float(p.get("volume", {}).get("h24", 0) or 0) for p in pairs)
|
||||
factors.total_volume_usd = sum(
|
||||
float(p.get("volume", {}).get("h24", 0) or 0) for p in pairs
|
||||
)
|
||||
factors.dex_swaps = len(pairs)
|
||||
factors.token_launches_participated = sum(
|
||||
1
|
||||
for p in pairs
|
||||
if p.get("pairCreatedAt")
|
||||
and (
|
||||
datetime.now(UTC) - datetime.fromtimestamp(p.get("pairCreatedAt", 0) / 1000, tz=UTC)
|
||||
datetime.now(UTC)
|
||||
- datetime.fromtimestamp(p.get("pairCreatedAt", 0) / 1000, tz=UTC)
|
||||
).total_seconds()
|
||||
/ 3600
|
||||
< 24
|
||||
)
|
||||
data_sources.append("dexscreener")
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
# ── SENTINEL address labels (6-source label resolution, FREE tier) ──
|
||||
sentinel_labels = await _fetch_sentinel_labels(wallet_address, chain)
|
||||
if sentinel_labels:
|
||||
factors.entity_label = sentinel_labels.get("category", "")
|
||||
factors.is_labeled_scammer = factors.is_labeled_scammer or "scam" in str(sentinel_labels).lower()
|
||||
factors.is_labeled_scammer = (
|
||||
factors.is_labeled_scammer or "scam" in str(sentinel_labels).lower()
|
||||
)
|
||||
factors.is_labeled_sanctioned = (
|
||||
factors.is_labeled_sanctioned
|
||||
or "sanction" in str(sentinel_labels).lower()
|
||||
|
|
@ -910,10 +934,14 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
factors.is_labeled_exchange = (
|
||||
"exchange" in str(sentinel_labels).lower() or "cex" in str(sentinel_labels).lower()
|
||||
)
|
||||
factors.is_labeled_bot = "bot" in str(sentinel_labels).lower() or "mev" in str(sentinel_labels).lower()
|
||||
factors.is_labeled_bot = (
|
||||
"bot" in str(sentinel_labels).lower() or "mev" in str(sentinel_labels).lower()
|
||||
)
|
||||
factors.is_labeled_whale = "whale" in str(sentinel_labels).lower()
|
||||
factors.is_labeled_insider = "insider" in str(sentinel_labels).lower()
|
||||
factors.entity_confidence = max(factors.entity_confidence, sentinel_labels.get("risk_score", 50))
|
||||
factors.entity_confidence = max(
|
||||
factors.entity_confidence, sentinel_labels.get("risk_score", 50)
|
||||
)
|
||||
data_sources.append("sentinel_labels")
|
||||
|
||||
# ── FREE: External threat intelligence (HAPI, MistTrack, TRM sanctions) ──
|
||||
|
|
@ -942,7 +970,9 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
wallet_memory = await _fetch_wallet_memory(wallet_address, chain)
|
||||
if wallet_memory:
|
||||
factors.linked_wallets_count = len(wallet_memory.get("linked_wallets", []))
|
||||
factors.cluster_size = wallet_memory.get("linked_wallets_count", factors.linked_wallets_count)
|
||||
factors.cluster_size = wallet_memory.get(
|
||||
"linked_wallets_count", factors.linked_wallets_count
|
||||
)
|
||||
factors.cluster_id = wallet_memory.get("entity_id", "")
|
||||
factors.cross_chain_funding = bool(wallet_memory.get("cross_chain_presence"))
|
||||
scam_assoc = wallet_memory.get("scam_associations", [])
|
||||
|
|
@ -970,7 +1000,7 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
factors.unrealized_pnl_usd = float(pnl.get("unrealized", 0))
|
||||
factors.win_rate = float(intel.get("win_rate", 0))
|
||||
data_sources.append("gmgn")
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
# Entity clustering
|
||||
|
|
@ -983,8 +1013,10 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
factors.cluster_size = max(factors.cluster_size, cluster.get("size", 0))
|
||||
factors.cluster_shared_funding = cluster.get("shared_funding", False)
|
||||
factors.known_cluster_type = cluster.get("type", "")
|
||||
factors.linked_wallets_count = max(factors.linked_wallets_count, cluster.get("member_count", 0))
|
||||
except Exception:
|
||||
factors.linked_wallets_count = max(
|
||||
factors.linked_wallets_count, cluster.get("member_count", 0)
|
||||
)
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
# ── FREE: Token portfolio risk scan (parallel + enrichments) ──
|
||||
|
|
@ -999,7 +1031,7 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
# Data flywheel: flag deployers of scam tokens
|
||||
deployers = token_scan.get("deployers_to_flag", [])
|
||||
if deployers:
|
||||
asyncio.create_task(_close_data_flywheel(deployers))
|
||||
asyncio.create_task(_close_data_flywheel(deployers)) # noqa: RUF006
|
||||
|
||||
# ── FREE: Funding source tracing ──
|
||||
funding = await _trace_funding_source(wallet_address, chain)
|
||||
|
|
@ -1024,7 +1056,9 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
native_bal = onchain.get("sol_balance", onchain.get("native_balance", 0)) if onchain else 0
|
||||
basic_pnl = await _calculate_basic_pnl(wallet_address, chain, token_list, native_bal)
|
||||
if basic_pnl:
|
||||
factors.current_balance_usd = max(factors.current_balance_usd or 0, basic_pnl.get("estimated_value_usd", 0))
|
||||
factors.current_balance_usd = max(
|
||||
factors.current_balance_usd or 0, basic_pnl.get("estimated_value_usd", 0)
|
||||
)
|
||||
factors.realized_pnl_usd = factors.realized_pnl_usd or 0 # keep if GMGN set it
|
||||
data_sources.append("jupiter_pnl")
|
||||
|
||||
|
|
@ -1037,7 +1071,7 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
anomaly = await detect_wallet_anomaly(wallet_address, chain)
|
||||
if anomaly:
|
||||
factors.sybil_score = anomaly.get("sybil_score", 0)
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
# Portfolio tracker
|
||||
|
|
@ -1053,7 +1087,7 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
factors.stablecoin_ratio = portfolio.get("stablecoin_pct", 0)
|
||||
factors.bluechip_ratio = portfolio.get("bluechip_pct", 0)
|
||||
factors.memecoin_ratio = portfolio.get("memecoin_pct", 0)
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
|
|
@ -1285,7 +1319,7 @@ async def scan_wallet(wallet_address: str, chain: str = "solana", tier: str = "f
|
|||
}
|
||||
|
||||
# Cache result for 1hr
|
||||
asyncio.create_task(_cache_set(cache_key, result, ttl=3600))
|
||||
asyncio.create_task(_cache_set(cache_key, result, ttl=3600)) # noqa: RUF006
|
||||
|
||||
return result
|
||||
|
||||
|
|
@ -1320,7 +1354,9 @@ async def get_trending_tokens(chain: str | None = None, limit: int = 20) -> dict
|
|||
continue
|
||||
age_hours = 999
|
||||
if created:
|
||||
age_hours = (now - datetime.fromtimestamp(created / 1000, tz=UTC)).total_seconds() / 3600
|
||||
age_hours = (
|
||||
now - datetime.fromtimestamp(created / 1000, tz=UTC)
|
||||
).total_seconds() / 3600
|
||||
trending_score = (vol * 0.6 + liq * 0.3) / max(age_hours, 1)
|
||||
trending.append(
|
||||
{
|
||||
|
|
@ -1361,12 +1397,14 @@ async def get_trending_tokens(chain: str | None = None, limit: int = 20) -> dict
|
|||
"price_usd": float(attrs.get("base_token_price_usd", 0)),
|
||||
"liquidity_usd": float(attrs.get("reserve_in_usd", 0)),
|
||||
"volume_24h": float(attrs.get("volume_usd", {}).get("h24", 0)),
|
||||
"price_change_24h": float(attrs.get("price_change_percentage", {}).get("h24", 0)),
|
||||
"price_change_24h": float(
|
||||
attrs.get("price_change_percentage", {}).get("h24", 0)
|
||||
),
|
||||
"source": "geckoterminal",
|
||||
}
|
||||
)
|
||||
sources_used.append("geckoterminal")
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
try:
|
||||
|
|
@ -1388,7 +1426,7 @@ async def get_trending_tokens(chain: str | None = None, limit: int = 20) -> dict
|
|||
}
|
||||
)
|
||||
sources_used.append("coingecko")
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
seen = set()
|
||||
|
|
@ -1432,7 +1470,9 @@ async def get_new_launches(chain: str = "solana", since_seconds: int = 300) -> d
|
|||
launches = []
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.get("https://api.dexscreener.com/latest/dex/search", params={"q": chain, "limit": 50})
|
||||
resp = await client.get(
|
||||
"https://api.dexscreener.com/latest/dex/search", params={"q": chain, "limit": 50}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
pairs = resp.json().get("pairs", [])
|
||||
for pair in pairs:
|
||||
|
|
@ -1453,7 +1493,7 @@ async def get_new_launches(chain: str = "solana", since_seconds: int = 300) -> d
|
|||
"dex": pair.get("dexId", ""),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
chain_key = chain or "all"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from typing import Any
|
|||
import httpx
|
||||
|
||||
from app.chain_registry import is_evm, is_solana
|
||||
from app.scanners.dev_reputation import ETHERSCAN_NETWORKS
|
||||
from app.domains.scanners.dev_reputation import ETHERSCAN_NETWORKS
|
||||
|
||||
logger = logging.getLogger("wallet_memory.ingestion")
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ class NormalizedTransaction:
|
|||
tx_hash: str = "",
|
||||
tx_type: str = "transfer",
|
||||
token_address: str = "",
|
||||
token_value_raw: str = "0",
|
||||
token_value_raw: str = "0", # noqa: S107
|
||||
token_value_decimal: float = 0.0,
|
||||
transfer_type: str = "native",
|
||||
block_number: int = 0,
|
||||
|
|
@ -495,7 +495,9 @@ class WalletIngestionPipeline:
|
|||
|
||||
# ── Normalization ──────────────────────────────────────────────
|
||||
|
||||
def _normalize_transactions(self, raw_txs: list[dict], address: str, chain: str) -> list[NormalizedTransaction]:
|
||||
def _normalize_transactions(
|
||||
self, raw_txs: list[dict], address: str, chain: str
|
||||
) -> list[NormalizedTransaction]:
|
||||
"""Convert raw API responses into NormalizedTransaction objects."""
|
||||
normalized = []
|
||||
addr_lower = address.lower().strip()
|
||||
|
|
@ -507,7 +509,9 @@ class WalletIngestionPipeline:
|
|||
|
||||
return normalized
|
||||
|
||||
def _normalize_helius(self, txs: list[dict], address: str, chain: str) -> list[NormalizedTransaction]:
|
||||
def _normalize_helius(
|
||||
self, txs: list[dict], address: str, chain: str
|
||||
) -> list[NormalizedTransaction]:
|
||||
"""Parse Helius Enhanced Transactions API response."""
|
||||
normalized = []
|
||||
|
||||
|
|
@ -516,7 +520,11 @@ class WalletIngestionPipeline:
|
|||
# Helius Enhanced Transaction schema
|
||||
tx_hash = tx.get("signature", "")
|
||||
timestamp_ms = tx.get("timestamp", 0)
|
||||
ts = datetime.fromtimestamp(timestamp_ms, tz=UTC) if timestamp_ms else datetime.now(UTC)
|
||||
ts = (
|
||||
datetime.fromtimestamp(timestamp_ms, tz=UTC)
|
||||
if timestamp_ms
|
||||
else datetime.now(UTC)
|
||||
)
|
||||
|
||||
block_slot = tx.get("slot", 0)
|
||||
tx.get("fee", 0) or 0
|
||||
|
|
@ -540,8 +548,8 @@ class WalletIngestionPipeline:
|
|||
programs_involved.add(ip)
|
||||
|
||||
# Detect token deployments: create/mint instructions
|
||||
spl_token_program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||
token_metadata_program = "metaqbxxU9dKaqAta4M99M7C5pWm7adgGpSxq9BDpL4"
|
||||
spl_token_program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" # noqa: S105
|
||||
token_metadata_program = "metaqbxxU9dKaqAta4M99M7C5pWm7adgGpSxq9BDpL4" # noqa: S105
|
||||
token_deploy_programs = {spl_token_program, token_metadata_program}
|
||||
if token_deploy_programs & programs_involved:
|
||||
is_deploy = True
|
||||
|
|
@ -569,7 +577,9 @@ class WalletIngestionPipeline:
|
|||
timestamp=ts,
|
||||
amount=amount_sol,
|
||||
chain_id=chain,
|
||||
program=",".join(programs_involved) if programs_involved else "system",
|
||||
program=",".join(programs_involved)
|
||||
if programs_involved
|
||||
else "system",
|
||||
tx_hash=tx_hash,
|
||||
tx_type="send" if is_send else "receive",
|
||||
transfer_type="native",
|
||||
|
|
@ -607,7 +617,9 @@ class WalletIngestionPipeline:
|
|||
timestamp=ts,
|
||||
amount=dec_val,
|
||||
chain_id=chain,
|
||||
program=",".join(programs_involved) if programs_involved else "spl-token",
|
||||
program=",".join(programs_involved)
|
||||
if programs_involved
|
||||
else "spl-token",
|
||||
tx_hash=tx_hash,
|
||||
tx_type="send" if is_send else "receive",
|
||||
token_address=token_addr,
|
||||
|
|
@ -643,7 +655,9 @@ class WalletIngestionPipeline:
|
|||
|
||||
return normalized
|
||||
|
||||
def _normalize_etherscan(self, txs: list[dict], address: str, chain: str) -> list[NormalizedTransaction]:
|
||||
def _normalize_etherscan(
|
||||
self, txs: list[dict], address: str, chain: str
|
||||
) -> list[NormalizedTransaction]:
|
||||
"""Parse Etherscan-family API response."""
|
||||
normalized = []
|
||||
|
||||
|
|
@ -819,7 +833,9 @@ class WalletIngestionPipeline:
|
|||
is_scam=False,
|
||||
risk_score=0.0,
|
||||
)
|
||||
logger.info(f"Recorded deployer event: {tx.address} deployed {token_addr} on {tx.chain_id}")
|
||||
logger.info(
|
||||
f"Recorded deployer event: {tx.address} deployed {token_addr} on {tx.chain_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Deployer event record failed: {e}")
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class LabelService:
|
|||
for _chain, wallets in CEX_HOT_WALLETS.items():
|
||||
for addr, label in wallets.items():
|
||||
self._builtin_cex[addr.lower()] = label
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
async def resolve(self, address: str, chain: str) -> dict[str, Any] | None:
|
||||
|
|
@ -116,10 +116,14 @@ class LabelService:
|
|||
"address_name",
|
||||
label_data.get(
|
||||
"dapp_name",
|
||||
label_data.get("protocol_name", label_data.get("label_type", "")),
|
||||
label_data.get(
|
||||
"protocol_name", label_data.get("label_type", "")
|
||||
),
|
||||
),
|
||||
"category": label_data.get("label_type", label_data.get("label_subtype", "")),
|
||||
),
|
||||
"category": label_data.get(
|
||||
"label_type", label_data.get("label_subtype", "")
|
||||
),
|
||||
"confidence": 0.95, # Static labels are high confidence
|
||||
}
|
||||
)
|
||||
|
|
@ -149,7 +153,9 @@ class LabelService:
|
|||
all_labels.append(
|
||||
{
|
||||
"source": "omega_forensic",
|
||||
"label": wallet_data.get("display_name", wallet_data.get("category", "")),
|
||||
"label": wallet_data.get(
|
||||
"display_name", wallet_data.get("category", "")
|
||||
),
|
||||
"category": cat,
|
||||
"confidence": 0.9,
|
||||
}
|
||||
|
|
@ -233,7 +239,7 @@ class LabelService:
|
|||
|
||||
async def _query_etherscan(self, address: str, chain: str) -> list[dict]:
|
||||
"""Query Etherscan account API for address labels."""
|
||||
from app.scanners.dev_reputation import ETHERSCAN_NETWORKS
|
||||
from app.domains.scanners.dev_reputation import ETHERSCAN_NETWORKS
|
||||
|
||||
network = ETHERSCAN_NETWORKS.get(chain)
|
||||
if not network:
|
||||
|
|
@ -286,11 +292,13 @@ class LabelService:
|
|||
now = time.time()
|
||||
if _ROLODETH_CACHE is None or (now - _ROLODETH_LAST_FETCH) > _ROLODETH_TTL:
|
||||
try:
|
||||
resp = await self._http.get("https://raw.githubusercontent.com/RoleTiker/rolodETH/main/rolodETH.json")
|
||||
resp = await self._http.get(
|
||||
"https://raw.githubusercontent.com/RoleTiker/rolodETH/main/rolodETH.json"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
_ROLODETH_CACHE = resp.json()
|
||||
_ROLODETH_LAST_FETCH = now
|
||||
except Exception:
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
if not _ROLODETH_CACHE:
|
||||
|
|
@ -411,7 +419,10 @@ class LabelService:
|
|||
|
||||
sorted_labels = sorted(
|
||||
labels,
|
||||
key=lambda line: (priority.get(line.get("category", "unknown"), 8), -line.get("confidence", 0)),
|
||||
key=lambda line: (
|
||||
priority.get(line.get("category", "unknown"), 8),
|
||||
-line.get("confidence", 0),
|
||||
),
|
||||
)
|
||||
return sorted_labels[0] if sorted_labels else {"label": "", "category": "unknown"}
|
||||
|
||||
|
|
|
|||
6
mypy.ini
6
mypy.ini
|
|
@ -1,8 +1,4 @@
|
|||
[mypy]
|
||||
strict = true
|
||||
ignore_missing_imports = true
|
||||
exclude = (?x)(
|
||||
.venv/
|
||||
| tests/
|
||||
| __pycache__/
|
||||
)
|
||||
exclude = (\.venv/|tests/|__pycache__/)
|
||||
|
|
|
|||
|
|
@ -120,8 +120,8 @@ warn_unreachable = true
|
|||
|
||||
[[tool.mypy.overrides]]
|
||||
# Domain layer is the strictest — no FastAPI leakage allowed.
|
||||
module = ["app.domain.*"]
|
||||
disallow_any_express_imports = true
|
||||
module = ["app.domains.*"]
|
||||
disallow_any_explicit = true
|
||||
disallow_any_decorated = false # Pydantic decorators need this
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
|
|
@ -129,7 +129,7 @@ warn_return_any = true
|
|||
[[tool.mypy.overrides]]
|
||||
# Core (cross-cutting) is also strict.
|
||||
module = ["app.core.*"]
|
||||
disallow_any_express_imports = true
|
||||
disallow_any_explicit = true
|
||||
no_implicit_optional = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Tests for app/domain/reports/citation_validator.py"""
|
||||
|
||||
|
||||
from app.domain.reports.citation_validator import validate_section
|
||||
from app.domains.reports.citation_validator import validate_section
|
||||
|
||||
|
||||
class TestCitationValidator:
|
||||
|
|
@ -10,34 +9,32 @@ class TestCitationValidator:
|
|||
def test_valid_citation(self):
|
||||
"""Test that valid citations pass validation."""
|
||||
result = validate_section(
|
||||
'This token has a risk score of 75/100 [1]. It is flagged [2].',
|
||||
['Risk score 75/100 detected', 'Token flagged as suspicious'],
|
||||
on_unciteable='strip'
|
||||
"This token has a risk score of 75/100 [1]. It is flagged [2].",
|
||||
["Risk score 75/100 detected", "Token flagged as suspicious"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
assert result['validation_rate'] == 1.0
|
||||
assert result['unciteable_count'] == 0
|
||||
assert '75/100' in result['validated_text']
|
||||
assert result["validation_rate"] == 1.0
|
||||
assert result["unciteable_count"] == 0
|
||||
assert "75/100" in result["validated_text"]
|
||||
|
||||
def test_invalid_citation(self):
|
||||
"""Test that invalid citations are stripped."""
|
||||
result = validate_section(
|
||||
'This claim [99] is invalid [1].',
|
||||
['Only source 1 available'],
|
||||
on_unciteable='strip'
|
||||
"This claim [99] is invalid [1].", ["Only source 1 available"], on_unciteable="strip"
|
||||
)
|
||||
assert result['validation_rate'] == 0.0
|
||||
assert result['unciteable_count'] == 1
|
||||
assert '[Data not available]' in result['validated_text']
|
||||
assert result["validation_rate"] == 0.0
|
||||
assert result["unciteable_count"] == 1
|
||||
assert "[Data not available]" in result["validated_text"]
|
||||
|
||||
def test_no_citations(self):
|
||||
"""Test that text without citations is marked unciteable."""
|
||||
result = validate_section(
|
||||
'This has no citations but should match source.',
|
||||
['Source text for validation'],
|
||||
on_unciteable='strip'
|
||||
"This has no citations but should match source.",
|
||||
["Source text for validation"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
assert result['validation_rate'] == 0.0
|
||||
assert result['unciteable_count'] == 1
|
||||
assert result["validation_rate"] == 0.0
|
||||
assert result["unciteable_count"] == 1
|
||||
|
||||
def test_multiple_citations_same_sentence(self):
|
||||
"""Test that multiple citations in one sentence are parsed.
|
||||
|
|
@ -49,13 +46,13 @@ class TestCitationValidator:
|
|||
the support check (unciteable_count > 0).
|
||||
"""
|
||||
result = validate_section(
|
||||
'This is supported by [1,2,3].',
|
||||
['Source one content', 'Source two content', 'Source three content'],
|
||||
on_unciteable='strip'
|
||||
"This is supported by [1,2,3].",
|
||||
["Source one content", "Source two content", "Source three content"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
# Parsing succeeded (multi-citation [1,2,3] all valid indices)
|
||||
# But claim doesn't actually overlap with any source content
|
||||
assert result['unciteable_count'] == 1 # claim unsupported
|
||||
assert result["unciteable_count"] == 1 # claim unsupported
|
||||
|
||||
def test_citation_range(self):
|
||||
"""Test that citation ranges [1-3] are parsed correctly.
|
||||
|
|
@ -65,65 +62,51 @@ class TestCitationValidator:
|
|||
with 'Source one content' terms, so should fail support.
|
||||
"""
|
||||
result = validate_section(
|
||||
'This is supported by [1-2].',
|
||||
['Source one content', 'Source two content', 'Source three content'],
|
||||
on_unciteable='strip'
|
||||
"This is supported by [1-2].",
|
||||
["Source one content", "Source two content", "Source three content"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
# Parsed correctly (range expanded)
|
||||
# But claim doesn't match source content
|
||||
assert result['unciteable_count'] == 1
|
||||
assert result["unciteable_count"] == 1
|
||||
|
||||
def test_empty_sources(self):
|
||||
"""Test that empty sources list marks all as unciteable."""
|
||||
result = validate_section(
|
||||
'Some text [1] with citations.',
|
||||
[],
|
||||
on_unciteable='strip'
|
||||
)
|
||||
assert result['validation_rate'] == 0.0
|
||||
assert 'Data not available' in result['validated_text']
|
||||
result = validate_section("Some text [1] with citations.", [], on_unciteable="strip")
|
||||
assert result["validation_rate"] == 0.0
|
||||
assert "Data not available" in result["validated_text"]
|
||||
|
||||
def test_keep_unciteable(self):
|
||||
"""Test that on_unciteable='keep' preserves unciteable text."""
|
||||
result = validate_section(
|
||||
'Some text [99] invalid.',
|
||||
['Source one content'],
|
||||
on_unciteable='keep'
|
||||
"Some text [99] invalid.", ["Source one content"], on_unciteable="keep"
|
||||
)
|
||||
assert result['unciteable_count'] == 1
|
||||
assert 'Some text [99] invalid.' in result['validated_text']
|
||||
assert result["unciteable_count"] == 1
|
||||
assert "Some text [99] invalid." in result["validated_text"]
|
||||
|
||||
def test_strip_unciteable(self):
|
||||
"""Test that on_unciteable='strip' removes unciteable text."""
|
||||
result = validate_section(
|
||||
'Some text [99] invalid.',
|
||||
['Source one content'],
|
||||
on_unciteable='strip'
|
||||
"Some text [99] invalid.", ["Source one content"], on_unciteable="strip"
|
||||
)
|
||||
assert result['unciteable_count'] == 1
|
||||
assert 'Data not available' in result['validated_text']
|
||||
assert result["unciteable_count"] == 1
|
||||
assert "Data not available" in result["validated_text"]
|
||||
|
||||
def test_validation_report_structure(self):
|
||||
"""Test that the validation report has the correct structure."""
|
||||
result = validate_section(
|
||||
'Test [1].',
|
||||
['Source one content']
|
||||
)
|
||||
assert 'validated_text' in result
|
||||
assert 'citations' in result
|
||||
assert 'unciteable_count' in result
|
||||
assert 'validation_rate' in result
|
||||
assert isinstance(result['citations'], list)
|
||||
result = validate_section("Test [1].", ["Source one content"])
|
||||
assert "validated_text" in result
|
||||
assert "citations" in result
|
||||
assert "unciteable_count" in result
|
||||
assert "validation_rate" in result
|
||||
assert isinstance(result["citations"], list)
|
||||
|
||||
def test_citation_details(self):
|
||||
"""Test that citations include claim, source_idx, source_text, supported."""
|
||||
result = validate_section(
|
||||
'Test [1].',
|
||||
['Source one content']
|
||||
)
|
||||
if result['citations']:
|
||||
c = result['citations'][0]
|
||||
assert 'claim' in c
|
||||
assert 'source_idx' in c
|
||||
assert 'source_text' in c
|
||||
assert 'supported' in c
|
||||
result = validate_section("Test [1].", ["Source one content"])
|
||||
if result["citations"]:
|
||||
c = result["citations"][0]
|
||||
assert "claim" in c
|
||||
assert "source_idx" in c
|
||||
assert "source_text" in c
|
||||
assert "supported" in c
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Tests for app/domain/reports/citation_validator edge cases."""
|
||||
|
||||
|
||||
from app.domain.reports.citation_validator import validate_section
|
||||
from app.domains.reports.citation_validator import validate_section
|
||||
|
||||
|
||||
class TestCitationValidatorEdgeCases:
|
||||
|
|
@ -16,14 +15,10 @@ class TestCitationValidatorEdgeCases:
|
|||
'Source 0' / 'Source 4' content - so claim is unciteable.
|
||||
"""
|
||||
sources = [f"Source {i}" for i in range(20)]
|
||||
result = validate_section(
|
||||
'Test [1-5] citation.',
|
||||
sources,
|
||||
on_unciteable='strip'
|
||||
)
|
||||
result = validate_section("Test [1-5] citation.", sources, on_unciteable="strip")
|
||||
# Parsing correct (1-5 expanded to 5 indices)
|
||||
# But claim doesn't overlap with source content
|
||||
assert result['unciteable_count'] == 1
|
||||
assert result["unciteable_count"] == 1
|
||||
|
||||
def test_overlapping_citations(self):
|
||||
"""Test overlapping citations like [1, 2-3, 4].
|
||||
|
|
@ -33,43 +28,29 @@ class TestCitationValidatorEdgeCases:
|
|||
doesn't overlap with 'Source N' content.
|
||||
"""
|
||||
sources = ["Source 1", "Source 2", "Source 3", "Source 4"]
|
||||
result = validate_section(
|
||||
'Test [1, 2-3, 4] citation.',
|
||||
sources,
|
||||
on_unciteable='strip'
|
||||
)
|
||||
result = validate_section("Test [1, 2-3, 4] citation.", sources, on_unciteable="strip")
|
||||
# Parsing correct ([1, 2-3, 4] expanded to [1, 2, 3, 4])
|
||||
# But content overlap fails for generic claim
|
||||
assert result['unciteable_count'] == 1
|
||||
assert result["unciteable_count"] == 1
|
||||
|
||||
def test_single_char_source_text(self):
|
||||
"""Test with very short source text."""
|
||||
result = validate_section(
|
||||
'Test [1] citation.',
|
||||
['a'],
|
||||
on_unciteable='strip'
|
||||
)
|
||||
assert 'validated_text' in result
|
||||
assert 'citations' in result
|
||||
result = validate_section("Test [1] citation.", ["a"], on_unciteable="strip")
|
||||
assert "validated_text" in result
|
||||
assert "citations" in result
|
||||
|
||||
def test_source_text_with_only_stopwords(self):
|
||||
"""Test with source text that has only stopwords."""
|
||||
result = validate_section(
|
||||
'Test [1] citation.',
|
||||
['the and is a'],
|
||||
on_unciteable='strip'
|
||||
)
|
||||
assert 'validated_text' in result
|
||||
result = validate_section("Test [1] citation.", ["the and is a"], on_unciteable="strip")
|
||||
assert "validated_text" in result
|
||||
|
||||
def test_very_long_text(self):
|
||||
"""Test with very long input text."""
|
||||
long_text = "This is a very long sentence. " * 100
|
||||
result = validate_section(
|
||||
long_text + " [1].",
|
||||
["Source text for validation"],
|
||||
on_unciteable='strip'
|
||||
long_text + " [1].", ["Source text for validation"], on_unciteable="strip"
|
||||
)
|
||||
assert 'validated_text' in result
|
||||
assert "validated_text" in result
|
||||
|
||||
def test_multiple_sentences_with_citations(self):
|
||||
"""Test multiple sentences each with different citations."""
|
||||
|
|
@ -81,10 +62,10 @@ class TestCitationValidatorEdgeCases:
|
|||
result = validate_section(
|
||||
"First sentence [1]. Second sentence [2]. Third sentence [3].",
|
||||
sources,
|
||||
on_unciteable='strip'
|
||||
on_unciteable="strip",
|
||||
)
|
||||
assert result['validation_rate'] == 1.0
|
||||
assert result['unciteable_count'] == 0
|
||||
assert result["validation_rate"] == 1.0
|
||||
assert result["unciteable_count"] == 0
|
||||
|
||||
def test_mixed_citeable_and_unciteable(self):
|
||||
"""Test mix of citeable and unciteable content.
|
||||
|
|
@ -97,25 +78,19 @@ class TestCitationValidatorEdgeCases:
|
|||
"""
|
||||
sources = ["Source one"]
|
||||
result = validate_section(
|
||||
"Valid [1]. Invalid [99]. Also valid [1].",
|
||||
sources,
|
||||
on_unciteable='strip'
|
||||
"Valid [1]. Invalid [99]. Also valid [1].", sources, on_unciteable="strip"
|
||||
)
|
||||
# All 3 sentences fail content overlap check (with strict default)
|
||||
# The 1st and 3rd have valid index [1] but claim "Valid" doesn't
|
||||
# overlap with "Source one" content. The 2nd has out-of-range [99].
|
||||
assert result['unciteable_count'] == 3
|
||||
assert result["unciteable_count"] == 3
|
||||
|
||||
def test_citation_without_closing_bracket(self):
|
||||
"""Test citation with missing closing bracket (malformed)."""
|
||||
sources = ["Source one"]
|
||||
result = validate_section(
|
||||
'Test [1 unciteable.',
|
||||
sources,
|
||||
on_unciteable='strip'
|
||||
)
|
||||
result = validate_section("Test [1 unciteable.", sources, on_unciteable="strip")
|
||||
# Should still process and mark as unciteable
|
||||
assert 'validated_text' in result
|
||||
assert "validated_text" in result
|
||||
|
||||
def test_citation_with_extra_whitespace(self):
|
||||
"""Test citation with extra whitespace like [ 1 , 2 ].
|
||||
|
|
@ -125,11 +100,7 @@ class TestCitationValidatorEdgeCases:
|
|||
but claim text 'Test citation' doesn't overlap with sources.
|
||||
"""
|
||||
sources = ["Source 1", "Source 2"]
|
||||
result = validate_section(
|
||||
'Test [ 1 , 2 ] citation.',
|
||||
sources,
|
||||
on_unciteable='strip'
|
||||
)
|
||||
result = validate_section("Test [ 1 , 2 ] citation.", sources, on_unciteable="strip")
|
||||
# Parsed [ 1 , 2 ] → [1, 2] successfully (whitespace tolerated)
|
||||
# But content overlap fails for generic claim
|
||||
assert result['unciteable_count'] == 1
|
||||
assert result["unciteable_count"] == 1
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Tests for app/domain/reports/generator.py - report generation tests."""
|
||||
|
||||
|
||||
from app.domain.reports.generator import (
|
||||
from app.domains.reports.generator import (
|
||||
_compute_risk_token,
|
||||
_compute_risk_wallet,
|
||||
)
|
||||
|
|
@ -13,14 +12,18 @@ class TestRiskComputation:
|
|||
def test_compute_risk_token_low_risk(self):
|
||||
"""Test risk score for low-risk token."""
|
||||
token_data = {
|
||||
"token": type('Token', (), {
|
||||
'is_honeypot': False,
|
||||
'is_mintable': False,
|
||||
'is_proxy': False,
|
||||
'tax_buy_bps': 100,
|
||||
'tax_sell_bps': 100,
|
||||
'risk_factors': [],
|
||||
})()
|
||||
"token": type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"is_honeypot": False,
|
||||
"is_mintable": False,
|
||||
"is_proxy": False,
|
||||
"tax_buy_bps": 100,
|
||||
"tax_sell_bps": 100,
|
||||
"risk_factors": [],
|
||||
},
|
||||
)()
|
||||
}
|
||||
score, _factors, tier = _compute_risk_token(token_data)
|
||||
assert score < 25
|
||||
|
|
@ -38,14 +41,18 @@ class TestRiskComputation:
|
|||
A score of 100 with all those flags = CRITICAL.
|
||||
"""
|
||||
token_data = {
|
||||
"token": type('Token', (), {
|
||||
'is_honeypot': True,
|
||||
'is_mintable': True,
|
||||
'is_proxy': True,
|
||||
'tax_buy_bps': 2000,
|
||||
'tax_sell_bps': 2000,
|
||||
'risk_factors': ['test1', 'test2'],
|
||||
})()
|
||||
"token": type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"is_honeypot": True,
|
||||
"is_mintable": True,
|
||||
"is_proxy": True,
|
||||
"tax_buy_bps": 2000,
|
||||
"tax_sell_bps": 2000,
|
||||
"risk_factors": ["test1", "test2"],
|
||||
},
|
||||
)()
|
||||
}
|
||||
score, _factors, tier = _compute_risk_token(token_data)
|
||||
assert score >= 75 # Multiple critical flags → high score
|
||||
|
|
@ -54,14 +61,18 @@ class TestRiskComputation:
|
|||
def test_compute_risk_token_max_risk(self):
|
||||
"""Test risk score is capped at 100."""
|
||||
token_data = {
|
||||
"token": type('Token', (), {
|
||||
'is_honeypot': True,
|
||||
'is_mintable': True,
|
||||
'is_proxy': True,
|
||||
'tax_buy_bps': 5000,
|
||||
'tax_sell_bps': 5000,
|
||||
'risk_factors': ['a', 'b', 'c', 'd', 'e'],
|
||||
})()
|
||||
"token": type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"is_honeypot": True,
|
||||
"is_mintable": True,
|
||||
"is_proxy": True,
|
||||
"tax_buy_bps": 5000,
|
||||
"tax_sell_bps": 5000,
|
||||
"risk_factors": ["a", "b", "c", "d", "e"],
|
||||
},
|
||||
)()
|
||||
}
|
||||
score, _factors, _tier = _compute_risk_token(token_data)
|
||||
assert score == 100 # Should be capped
|
||||
|
|
@ -69,10 +80,14 @@ class TestRiskComputation:
|
|||
def test_compute_risk_wallet_low_risk(self):
|
||||
"""Test risk score for low-risk wallet."""
|
||||
wallet_data = {
|
||||
"wallet": type('Wallet', (), {
|
||||
'is_suspicious': False,
|
||||
'tx_count': 100,
|
||||
})(),
|
||||
"wallet": type(
|
||||
"Wallet",
|
||||
(),
|
||||
{
|
||||
"is_suspicious": False,
|
||||
"tx_count": 100,
|
||||
},
|
||||
)(),
|
||||
"entity": {},
|
||||
"news": [],
|
||||
}
|
||||
|
|
@ -83,10 +98,14 @@ class TestRiskComputation:
|
|||
def test_compute_risk_wallet_high_risk(self):
|
||||
"""Test risk score for high-risk wallet."""
|
||||
wallet_data = {
|
||||
"wallet": type('Wallet', (), {
|
||||
'is_suspicious': True,
|
||||
'tx_count': 15000,
|
||||
})(),
|
||||
"wallet": type(
|
||||
"Wallet",
|
||||
(),
|
||||
{
|
||||
"is_suspicious": True,
|
||||
"tx_count": 15000,
|
||||
},
|
||||
)(),
|
||||
"entity": {"wallets": ["a", "b", "c", "d", "e"]},
|
||||
"news": [],
|
||||
}
|
||||
|
|
@ -100,7 +119,8 @@ class TestTemplateFallback:
|
|||
|
||||
def test_template_fallback_executive_summary(self):
|
||||
"""Test executive summary template."""
|
||||
from app.domain.reports.generator import _template_fallback
|
||||
from app.domains.reports.generator import _template_fallback
|
||||
|
||||
ctx = {
|
||||
"subject_id": "eth:0x123",
|
||||
"risk_score": 75,
|
||||
|
|
@ -114,7 +134,8 @@ class TestTemplateFallback:
|
|||
|
||||
def test_template_fallback_recommendation(self):
|
||||
"""Test recommendation template."""
|
||||
from app.domain.reports.generator import _template_fallback
|
||||
from app.domains.reports.generator import _template_fallback
|
||||
|
||||
ctx = {
|
||||
"subject_id": "eth:0x123",
|
||||
"risk_score": 75,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for app/domain/reports/router.py - report router tests."""
|
||||
|
||||
from app.domain.reports.router import router
|
||||
from app.domains.reports.router import router
|
||||
|
||||
|
||||
class TestReportRouter:
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import pytest
|
|||
@pytest.mark.asyncio
|
||||
async def test_scan_token_exists():
|
||||
"""Test that scan_token function exists in scanner service."""
|
||||
from app.domain.scanner import service
|
||||
from app.domains.scanner import service
|
||||
|
||||
assert hasattr(service, 'scan_token')
|
||||
assert hasattr(service, "scan_token")
|
||||
assert callable(service.scan_token)
|
||||
|
||||
|
||||
|
|
@ -21,13 +21,13 @@ async def test_scan_token_signature():
|
|||
"""Test scan_token function signature."""
|
||||
import inspect
|
||||
|
||||
from app.domain.scanner import service
|
||||
from app.domains.scanner import service
|
||||
|
||||
sig = inspect.signature(service.scan_token)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert 'token_address' in params
|
||||
assert 'chain' in params
|
||||
assert "token_address" in params
|
||||
assert "chain" in params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -35,20 +35,20 @@ async def test_scan_token_defaults():
|
|||
"""Test scan_token has proper defaults."""
|
||||
import inspect
|
||||
|
||||
from app.domain.scanner import service
|
||||
from app.domains.scanner import service
|
||||
|
||||
sig = inspect.signature(service.scan_token)
|
||||
|
||||
assert sig.parameters['chain'].default == 'ethereum'
|
||||
assert sig.parameters['tiers'].default is None
|
||||
assert sig.parameters['include_market_data'].default is True
|
||||
assert sig.parameters['include_social'].default is True
|
||||
assert sig.parameters["chain"].default == "ethereum"
|
||||
assert sig.parameters["tiers"].default is None
|
||||
assert sig.parameters["include_market_data"].default is True
|
||||
assert sig.parameters["include_social"].default is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_token_is_async():
|
||||
"""Test scan_token is an async function."""
|
||||
from app.domain.scanner import service
|
||||
from app.domains.scanner import service
|
||||
|
||||
# Check it's a coroutine function
|
||||
assert asyncio.iscoroutinefunction(service.scan_token)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""Unit tests for T03 (news clusterer) and T12 (CertStream match_brand)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.domain.news.clusterer import NewsItem, cluster_items
|
||||
from app.domain.threat.certstream_listener import match_brand
|
||||
from app.domains.news.clusterer import NewsItem, cluster_items
|
||||
from app.domains.threat.certstream_listener import match_brand
|
||||
|
||||
|
||||
# ── T03: clusterer tests ───────────────────────────────────────────
|
||||
|
|
@ -16,19 +17,25 @@ def test_clusterer_groups_similar_stories():
|
|||
id="a1",
|
||||
title="Bitcoin hits new all-time high above 120000",
|
||||
body="BTC surged past 120000 today as ETF inflows hit record",
|
||||
source="coindesk", url="https://coindesk.com/1", published_at=base,
|
||||
source="coindesk",
|
||||
url="https://coindesk.com/1",
|
||||
published_at=base,
|
||||
),
|
||||
NewsItem(
|
||||
id="a2",
|
||||
title="Bitcoin hits new all-time high above 120000",
|
||||
body="BTC surged past 120000 today as ETF inflows hit record",
|
||||
source="the block", url="https://theblock.co/2", published_at=base + timedelta(minutes=5),
|
||||
source="the block",
|
||||
url="https://theblock.co/2",
|
||||
published_at=base + timedelta(minutes=5),
|
||||
),
|
||||
NewsItem(
|
||||
id="b1",
|
||||
title="Ethereum upgrade scheduled for next month",
|
||||
body="Core developers announce Pectra hard fork for July",
|
||||
source="decrypt", url="https://decrypt.co/3", published_at=base + timedelta(minutes=2),
|
||||
source="decrypt",
|
||||
url="https://decrypt.co/3",
|
||||
published_at=base + timedelta(minutes=2),
|
||||
),
|
||||
]
|
||||
stories = cluster_items(items)
|
||||
|
|
@ -46,9 +53,12 @@ def test_clusterer_handles_singleton():
|
|||
base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
|
||||
items = [
|
||||
NewsItem(
|
||||
id="x1", title="Unique story nobody else is covering",
|
||||
id="x1",
|
||||
title="Unique story nobody else is covering",
|
||||
body="Something happened once",
|
||||
source="reddit", url="https://reddit.com/x", published_at=base,
|
||||
source="reddit",
|
||||
url="https://reddit.com/x",
|
||||
published_at=base,
|
||||
),
|
||||
]
|
||||
stories = cluster_items(items)
|
||||
|
|
@ -61,14 +71,20 @@ def test_clusterer_respects_time_window():
|
|||
base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
|
||||
items = [
|
||||
NewsItem(
|
||||
id="m1", title="Bitcoin hits new high",
|
||||
id="m1",
|
||||
title="Bitcoin hits new high",
|
||||
body="BTC surged past $120K",
|
||||
source="coindesk", url="", published_at=base,
|
||||
source="coindesk",
|
||||
url="",
|
||||
published_at=base,
|
||||
),
|
||||
NewsItem(
|
||||
id="m2", title="Bitcoin hits new high",
|
||||
id="m2",
|
||||
title="Bitcoin hits new high",
|
||||
body="BTC surged past $120K",
|
||||
source="the block", url="", published_at=base + timedelta(hours=2),
|
||||
source="the block",
|
||||
url="",
|
||||
published_at=base + timedelta(hours=2),
|
||||
),
|
||||
]
|
||||
# With 30-min windows, these are in separate buckets → 2 singleton stories
|
||||
|
|
|
|||
|
|
@ -22,88 +22,74 @@ def run_test(name, fn):
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
# CITATION VALIDATOR TESTS
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
print("CITATION VALIDATOR TESTS")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
from app.domain.reports.citation_validator import validate_section # noqa: E402
|
||||
from app.domains.reports.citation_validator import validate_section # noqa: E402
|
||||
|
||||
|
||||
def test_valid_citation():
|
||||
result = validate_section(
|
||||
'Risk score 75/100 [1]. Token flagged [2].',
|
||||
['Risk score 75/100 detected', 'Token flagged as suspicious'],
|
||||
on_unciteable='strip'
|
||||
"Risk score 75/100 [1]. Token flagged [2].",
|
||||
["Risk score 75/100 detected", "Token flagged as suspicious"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
assert result['validation_rate'] == 1.0
|
||||
assert result['unciteable_count'] == 0
|
||||
assert result["validation_rate"] == 1.0
|
||||
assert result["unciteable_count"] == 0
|
||||
|
||||
|
||||
def test_invalid_citation():
|
||||
result = validate_section(
|
||||
'Risk score 75/100 [99].',
|
||||
['Only source 1 available'],
|
||||
on_unciteable='strip'
|
||||
"Risk score 75/100 [99].", ["Only source 1 available"], on_unciteable="strip"
|
||||
)
|
||||
assert result['unciteable_count'] == 1
|
||||
assert 'Data not available' in result['validated_text']
|
||||
assert result["unciteable_count"] == 1
|
||||
assert "Data not available" in result["validated_text"]
|
||||
|
||||
|
||||
def test_no_citations():
|
||||
result = validate_section(
|
||||
'This has no citations.',
|
||||
['Source text'],
|
||||
on_unciteable='strip'
|
||||
)
|
||||
assert result['validation_rate'] == 0.0
|
||||
assert result['unciteable_count'] == 1
|
||||
result = validate_section("This has no citations.", ["Source text"], on_unciteable="strip")
|
||||
assert result["validation_rate"] == 0.0
|
||||
assert result["unciteable_count"] == 1
|
||||
|
||||
|
||||
def test_empty_sources():
|
||||
result = validate_section(
|
||||
'Some text [1].',
|
||||
[],
|
||||
on_unciteable='strip'
|
||||
)
|
||||
assert result['validation_rate'] == 0.0
|
||||
assert 'Data not available' in result['validated_text']
|
||||
result = validate_section("Some text [1].", [], on_unciteable="strip")
|
||||
assert result["validation_rate"] == 0.0
|
||||
assert "Data not available" in result["validated_text"]
|
||||
|
||||
|
||||
def test_validation_report_structure():
|
||||
result = validate_section('Test [1].', ['Source'])
|
||||
assert 'validated_text' in result
|
||||
assert 'citations' in result
|
||||
assert 'unciteable_count' in result
|
||||
assert 'validation_rate' in result
|
||||
assert isinstance(result['citations'], list)
|
||||
result = validate_section("Test [1].", ["Source"])
|
||||
assert "validated_text" in result
|
||||
assert "citations" in result
|
||||
assert "unciteable_count" in result
|
||||
assert "validation_rate" in result
|
||||
assert isinstance(result["citations"], list)
|
||||
|
||||
|
||||
def test_citation_range():
|
||||
result = validate_section(
|
||||
'Token risk is high [1-2].',
|
||||
['Token risk is high', 'Risk score elevated'],
|
||||
on_unciteable='strip'
|
||||
"Token risk is high [1-2].",
|
||||
["Token risk is high", "Risk score elevated"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
assert result['validation_rate'] == 1.0
|
||||
assert result["validation_rate"] == 1.0
|
||||
|
||||
|
||||
def test_multiple_citations():
|
||||
result = validate_section(
|
||||
'Token is risky [1]. Risk factors detected [2]. High buy tax [3].',
|
||||
['Token is risky', 'Risk factors detected', 'High buy tax detected'],
|
||||
on_unciteable='strip'
|
||||
"Token is risky [1]. Risk factors detected [2]. High buy tax [3].",
|
||||
["Token is risky", "Risk factors detected", "High buy tax detected"],
|
||||
on_unciteable="strip",
|
||||
)
|
||||
assert result['validation_rate'] == 1.0
|
||||
assert result["validation_rate"] == 1.0
|
||||
|
||||
|
||||
def test_keep_unciteable():
|
||||
result = validate_section(
|
||||
'Test [99].',
|
||||
['Source'],
|
||||
on_unciteable='keep'
|
||||
)
|
||||
assert result['unciteable_count'] == 1
|
||||
assert 'Test [99].' in result['validated_text']
|
||||
result = validate_section("Test [99].", ["Source"], on_unciteable="keep")
|
||||
assert result["unciteable_count"] == 1
|
||||
assert "Test [99]." in result["validated_text"]
|
||||
|
||||
|
||||
print("\nRunning citation validator tests...")
|
||||
|
|
@ -120,9 +106,9 @@ run_test("test_keep_unciteable", test_keep_unciteable)
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
# HEALTH MODULE TESTS
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("HEALTH MODULE TESTS")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
from app.core.health import DomainHealth, register_health_check # noqa: E402
|
||||
|
||||
|
|
@ -165,6 +151,7 @@ def test_domain_health_large_details():
|
|||
def test_health_registry():
|
||||
def mock_health():
|
||||
return DomainHealth(name="mock", healthy=True)
|
||||
|
||||
register_health_check("mock", mock_health)
|
||||
|
||||
|
||||
|
|
@ -181,53 +168,89 @@ run_test("test_health_registry", test_health_registry)
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
# RISK COMPUTATION TESTS
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("RISK COMPUTATION TESTS")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
from app.domain.reports.generator import _compute_risk_token, _compute_risk_wallet # noqa: E402
|
||||
from app.domains.reports.generator import _compute_risk_token, _compute_risk_wallet # noqa: E402
|
||||
|
||||
|
||||
def test_compute_risk_token_low():
|
||||
token_data = {"token": type('Token', (), {
|
||||
'is_honeypot': False, 'is_mintable': False, 'is_proxy': False,
|
||||
'tax_buy_bps': 100, 'tax_sell_bps': 100, 'risk_factors': [],
|
||||
})()}
|
||||
token_data = {
|
||||
"token": type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"is_honeypot": False,
|
||||
"is_mintable": False,
|
||||
"is_proxy": False,
|
||||
"tax_buy_bps": 100,
|
||||
"tax_sell_bps": 100,
|
||||
"risk_factors": [],
|
||||
},
|
||||
)()
|
||||
}
|
||||
score, _factors, tier = _compute_risk_token(token_data)
|
||||
assert score < 25
|
||||
assert tier.name == "LOW"
|
||||
|
||||
|
||||
def test_compute_risk_token_high():
|
||||
token_data = {"token": type('Token', (), {
|
||||
'is_honeypot': True, 'is_mintable': True, 'is_proxy': True,
|
||||
'tax_buy_bps': 2000, 'tax_sell_bps': 2000, 'risk_factors': ['a', 'b'],
|
||||
})()}
|
||||
token_data = {
|
||||
"token": type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"is_honeypot": True,
|
||||
"is_mintable": True,
|
||||
"is_proxy": True,
|
||||
"tax_buy_bps": 2000,
|
||||
"tax_sell_bps": 2000,
|
||||
"risk_factors": ["a", "b"],
|
||||
},
|
||||
)()
|
||||
}
|
||||
score, _factors, tier = _compute_risk_token(token_data)
|
||||
assert score >= 75
|
||||
assert tier.name in ["HIGH", "CRITICAL"]
|
||||
|
||||
|
||||
def test_compute_risk_token_max():
|
||||
token_data = {"token": type('Token', (), {
|
||||
'is_honeypot': True, 'is_mintable': True, 'is_proxy': True,
|
||||
'tax_buy_bps': 5000, 'tax_sell_bps': 5000, 'risk_factors': ['a', 'b', 'c', 'd', 'e'],
|
||||
})()}
|
||||
token_data = {
|
||||
"token": type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"is_honeypot": True,
|
||||
"is_mintable": True,
|
||||
"is_proxy": True,
|
||||
"tax_buy_bps": 5000,
|
||||
"tax_sell_bps": 5000,
|
||||
"risk_factors": ["a", "b", "c", "d", "e"],
|
||||
},
|
||||
)()
|
||||
}
|
||||
score, _factors, _tier = _compute_risk_token(token_data)
|
||||
assert score == 100
|
||||
|
||||
|
||||
def test_compute_risk_wallet_low():
|
||||
wallet_data = {"wallet": type('Wallet', (), {'is_suspicious': False, 'tx_count': 100})(),
|
||||
"entity": {}, "news": []}
|
||||
wallet_data = {
|
||||
"wallet": type("Wallet", (), {"is_suspicious": False, "tx_count": 100})(),
|
||||
"entity": {},
|
||||
"news": [],
|
||||
}
|
||||
score, _factors, tier = _compute_risk_wallet(wallet_data)
|
||||
assert score < 25
|
||||
assert tier.name == "LOW"
|
||||
|
||||
|
||||
def test_compute_risk_wallet_high():
|
||||
wallet_data = {"wallet": type('Wallet', (), {'is_suspicious': True, 'tx_count': 15000})(),
|
||||
"entity": {"wallets": ["a", "b", "c", "d", "e"]}, "news": []}
|
||||
wallet_data = {
|
||||
"wallet": type("Wallet", (), {"is_suspicious": True, "tx_count": 15000})(),
|
||||
"entity": {"wallets": ["a", "b", "c", "d", "e"]},
|
||||
"news": [],
|
||||
}
|
||||
score, _factors, tier = _compute_risk_wallet(wallet_data)
|
||||
assert score >= 50
|
||||
assert tier.name in ["MEDIUM", "HIGH", "CRITICAL"]
|
||||
|
|
@ -244,22 +267,28 @@ run_test("test_compute_risk_wallet_high", test_compute_risk_wallet_high)
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
# TEMPLATE FALLBACK TESTS
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEMPLATE FALLBACK TESTS")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
from app.domain.reports.generator import _template_fallback # noqa: E402
|
||||
from app.domains.reports.generator import _template_fallback # noqa: E402
|
||||
|
||||
|
||||
def test_template_fallback_executive_summary():
|
||||
result = _template_fallback("executive_summary", {"subject_id": "eth:0x1", "risk_score": 75, "risk_tier": "HIGH", "risk_factors": "test"})
|
||||
result = _template_fallback(
|
||||
"executive_summary",
|
||||
{"subject_id": "eth:0x1", "risk_score": 75, "risk_tier": "HIGH", "risk_factors": "test"},
|
||||
)
|
||||
assert "Executive Summary" in result
|
||||
assert "75" in result
|
||||
assert "HIGH" in result
|
||||
|
||||
|
||||
def test_template_fallback_recommendation():
|
||||
result = _template_fallback("recommendation", {"subject_id": "eth:0x1", "risk_score": 75, "risk_tier": "HIGH", "risk_factors": "test"})
|
||||
result = _template_fallback(
|
||||
"recommendation",
|
||||
{"subject_id": "eth:0x1", "risk_score": 75, "risk_tier": "HIGH", "risk_factors": "test"},
|
||||
)
|
||||
assert "AVOID" in result
|
||||
|
||||
|
||||
|
|
@ -301,9 +330,9 @@ run_test("test_template_fallback_social_signals", test_template_fallback_social_
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
# SUMMARY
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print(f"TOTAL: {tests_passed} passed, {tests_failed} failed")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
if tests_failed > 0:
|
||||
sys.exit(1)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue