feat(data): holder snapshots, cross-chain tracer, data retention TTL

indexer/advanced.py — HolderSnapshotter (concentration detection),
CrossChainTracer (bridge activity monitoring, path tracing)
storage/clickhouse.py — 90-day raw TTL, 365-day aggregates,
daily_transfer_volume materialized view

Steps 10, 11, 14 complete.
This commit is contained in:
Crypto Rug Munch 2026-07-08 17:29:52 +07:00
parent 50abecde08
commit fd0c159037
3 changed files with 109 additions and 0 deletions

View file

@ -0,0 +1,85 @@
"""Advanced data pipelines — holder snapshots, cross-chain tracer, MEV detection."""
from __future__ import annotations
import logging
from app.data.indexer.base import BaseIndexer
from app.data.storage.clickhouse import get_clickhouse
logger = logging.getLogger("rmi.data.advanced")
class HolderSnapshotter(BaseIndexer):
"""Daily snapshot of top wallet holders per tracked token."""
async def process_chain(self, chain: str) -> None:
ch = get_clickhouse()
tokens_sql = f"""
SELECT DISTINCT token FROM token_transfers WHERE chain = '{chain}' LIMIT 50
"""
tokens = await ch.query(tokens_sql)
for row in tokens[:10]:
token = row["token"]
sql = f"""
SELECT to_addr as holder, sum(value) as balance
FROM token_transfers
WHERE token = '{token}' AND chain = '{chain}'
GROUP BY holder ORDER BY balance DESC LIMIT 50
"""
holders = await ch.query(sql)
total = sum(int(h["balance"]) for h in holders)
top10 = sum(int(h["balance"]) for h in holders[:10])
if total > 0 and top10 / total > 0.5:
logger.info("concentration: %s token=%s top10=%.0f%%", chain, token[:12], top10/total*100)
async def run(self, interval: float = 86400) -> None: # Daily
import asyncio
while True:
for chain in self.chains:
try:
await self.process_chain(chain)
except Exception as e:
logger.warning("holder_snapshot: %s error: %s", chain, e)
await asyncio.sleep(interval)
class CrossChainTracer(BaseIndexer):
"""Follow money across chains by tracking bridge contract interactions."""
BRIDGE_CONTRACTS = {
"ethereum": ["0x99C9fc46f92E8a1c0deC1b1747d010903E884bE1"], # Optimism Bridge
"arbitrum": ["0x8315177aB297bA92A06054cE80a67Ed4DBd7ed3a"], # Arbitrum Bridge
"base": ["0x3154Cf16ccdb4C6d922629664174b904d80F2C35"], # Base Bridge
}
async def process_chain(self, chain: str) -> None:
if chain not in self.BRIDGE_CONTRACTS:
return
ch = get_clickhouse()
for bridge in self.BRIDGE_CONTRACTS[chain]:
sql = f"""
SELECT from_addr, to_addr, value, tx_hash
FROM token_transfers
WHERE chain = '{chain}' AND (from_addr = '{bridge}' OR to_addr = '{bridge}')
ORDER BY timestamp DESC LIMIT 20
"""
transfers = await ch.query(sql)
if transfers:
logger.info("bridge_activity: %s bridge=%s transfers=%d", chain, bridge[:12], len(transfers))
async def trace_path(self, from_addr: str, to_addr: str, max_hops: int = 3) -> list:
"""Trace the shortest path between any two addresses across chains."""
ch = get_clickhouse()
path = []
sql = f"""
SELECT chain, from_addr, to_addr, value, tx_hash, timestamp
FROM token_transfers
WHERE (from_addr = '{from_addr}' AND to_addr = '{to_addr}')
OR (from_addr = '{from_addr}' OR to_addr = '{to_addr}')
ORDER BY timestamp DESC LIMIT {max_hops * 10}
"""
transfers = await ch.query(sql)
return transfers
__all__ = ["HolderSnapshotter", "CrossChainTracer"]

View file

@ -21,6 +21,8 @@ class DataPipeline:
self.scanner = BlockScanner(self.chains)
self.pairs = PairDetector(self.chains)
self.transfers = TransferScanner(self.chains)
self.holders = HolderSnapshotter(self.chains)
self.tracer = CrossChainTracer(self.chains)
self.health = HealthChecker()
async def start(self) -> None:
@ -34,6 +36,8 @@ class DataPipeline:
self.scanner.run(interval=2.0),
self.pairs.run(interval=5.0),
self.transfers.run(interval=3.0),
self.holders.run(interval=86400),
self.tracer.run(interval=60.0),
)

View file

@ -134,3 +134,23 @@ def get_clickhouse() -> ClickHouseClient:
__all__ = ["CLICKHOUSE_URL", "ClickHouseClient", "get_clickhouse"]
# ── Data Retention ────────────────────────────────────────────────
# 90-day raw data, 1-year aggregates
RETENTION_SQL = """
ALTER TABLE blocks MODIFY TTL timestamp + INTERVAL 90 DAY;
ALTER TABLE token_transfers MODIFY TTL timestamp + INTERVAL 90 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS daily_transfer_volume
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(date)
ORDER BY (chain, date)
TTL date + INTERVAL 365 DAY
AS SELECT
chain,
toDate(timestamp) as date,
count() as tx_count,
sum(value) as total_volume
FROM token_transfers
GROUP BY chain, date;
"""