btc-intel / features /engineer.py
Anish-g's picture
Deploy btc-intel on free ZeroGPU Gradio Space
824aa03 verified
Raw History Blame Contribute Delete
10.7 kB
"""Per-entity and per-transaction features.
python -m features.engineer --input data/processed/transactions.parquet \
--output data/processed/features.parquet
Two grains, two tables: entities (what the rules, anomaly and fusion stages
score) and transactions (what the CoinJoin/peel-shaped signals live on).
Thresholds come from config.yaml — none of them are baked in here.
"""
from __future__ import annotations
import argparse
import json
from collections.abc import Iterable
from pathlib import Path
import networkx as nx
import pandas as pd
import config
from graph.builder import IP, Tx, from_parquet, graph_transactions
from graph.clustering import Clustering, cluster_wallets, equal_value_group
DAY = 86400.0
ENTITY_COLUMNS = [
"cluster_id", "wallets", "txs", "txs_in", "txs_out",
"counterparties_in", "counterparties_out", "fan_in_ratio", "fan_out_ratio",
"value_in", "value_out", "round_amount_ratio", "velocity", "lifetime_days",
"dormant_then_active", "max_dormant_gap_days", "max_burst_transactions",
"unique_broadcast_ips", "unique_asns", "avg_hop_distance_from_known_bad",
"suspicious_merge",
]
TX_COLUMNS = ["txid", "timestamp", "input_count", "output_count", "equal_output_count",
"equal_output_value", "peel_ratio", "value_in", "value_out", "fee",
"time_since_prev_tx_same_wallet"]
def _epoch(ts) -> float | None:
if ts is None or (isinstance(ts, float) and pd.isna(ts)):
return None
t = pd.Timestamp(ts)
return None if pd.isna(t) else t.timestamp()
def is_round(amount: float, cfg: dict) -> bool:
"""A multiple of one of the configured units — the value a human typed,
not one a coin-selection algorithm produced."""
f = cfg["features"]
tol = f["round_tolerance"]
for unit in f["round_units"]:
if amount >= unit and abs(amount / unit - round(amount / unit)) * unit <= tol:
return True
return False
def dormancy(times: list[float], cfg: dict) -> tuple[bool, float, int]:
"""Long quiet, then a burst. Returns (flag, largest gap in days, burst size)."""
d = cfg["features"]["dormancy"]
gap_s, window_s = d["gap_days"] * DAY, d["burst_window_days"] * DAY
times = sorted(times)
flagged, max_gap, max_burst = False, 0.0, 0
for i in range(1, len(times)):
gap = times[i] - times[i - 1]
max_gap = max(max_gap, gap)
if gap < gap_s:
continue
burst = sum(1 for t in times[i:] if t - times[i] <= window_s)
max_burst = max(max_burst, burst)
if burst >= d["burst_transactions"]:
flagged = True
return flagged, max_gap / DAY, max_burst
def _as_transactions(source) -> list[Tx]:
if isinstance(source, nx.MultiDiGraph):
return list(graph_transactions(source))
return list(source)
def compute_features(graph, clustering: Clustering | None = None,
cfg: dict | None = None) -> pd.DataFrame:
"""Per-entity features, one row per cluster."""
cfg = cfg or config.load()
txs = _as_transactions(graph)
clustering = clustering or cluster_wallets(txs, cfg)
asn_of = _asn_lookup(graph)
def entity(addr: str) -> str:
return clustering.cluster_of(addr) or addr
rows: dict[str, dict] = {}
def row(eid: str) -> dict:
if eid not in rows:
rows[eid] = {"cluster_id": eid,
"wallets": len(clustering.clusters.get(eid, {eid})),
"txs_in": 0, "txs_out": 0, "value_in": 0.0, "value_out": 0.0,
"counterparties_in": set(), "counterparties_out": set(),
"amounts": [], "times": [], "ips": set(),
"suspicious_merge": bool(clustering.flags.get(eid))}
return rows[eid]
for tx in txs:
ts = _epoch(tx.timestamp)
senders: dict[str, float] = {}
receivers: dict[str, float] = {}
for addr, value in tx.inputs:
senders[entity(addr)] = senders.get(entity(addr), 0.0) + value
for addr, value in tx.outputs:
receivers[entity(addr)] = receivers.get(entity(addr), 0.0) + value
for eid, value in senders.items():
r = row(eid)
r["txs_out"] += 1
r["value_out"] += value
r["counterparties_out"].update(set(receivers) - {eid})
r["amounts"].append(value)
r["ips"].update(tx.ips) # the spender is who broadcast it
if ts is not None:
r["times"].append(ts)
for eid, value in receivers.items():
r = row(eid)
r["txs_in"] += 1
r["value_in"] += value
r["counterparties_in"].update(set(senders) - {eid})
r["amounts"].append(value)
if ts is not None and eid not in senders:
r["times"].append(ts)
out = [_finish_entity(r, asn_of, cfg) for r in rows.values()]
df = pd.DataFrame(out, columns=ENTITY_COLUMNS)
return df.sort_values("cluster_id", ignore_index=True)
def _finish_entity(r: dict, asn_of: dict, cfg: dict) -> dict:
times = r["times"]
lifetime_days = (max(times) - min(times)) / DAY if len(times) > 1 else 0.0
window = max(lifetime_days, cfg["features"]["velocity_min_window_days"])
txs = r["txs_in"] + r["txs_out"]
amounts = r["amounts"]
flagged, max_gap_days, max_burst = dormancy(times, cfg)
asns = {asn_of[ip] for ip in r["ips"] if asn_of.get(ip) is not None}
sides = len(r["counterparties_in"]) + len(r["counterparties_out"])
return {
"cluster_id": r["cluster_id"], "wallets": r["wallets"], "txs": txs,
"txs_in": r["txs_in"], "txs_out": r["txs_out"],
"counterparties_in": len(r["counterparties_in"]),
"counterparties_out": len(r["counterparties_out"]),
# how one-sided the entity is: 1.0 = pure collector, 0.0 = pure distributor.
# Raw counterparty and tx counts are kept above, so concentration
# (counterparties per transaction) is still derivable downstream.
"fan_in_ratio": len(r["counterparties_in"]) / sides if sides else 0.0,
"fan_out_ratio": len(r["counterparties_out"]) / sides if sides else 0.0,
"value_in": r["value_in"], "value_out": r["value_out"],
"round_amount_ratio": (sum(is_round(a, cfg) for a in amounts) / len(amounts)
if amounts else 0.0),
"velocity": txs / window,
"lifetime_days": lifetime_days,
"dormant_then_active": flagged,
"max_dormant_gap_days": max_gap_days,
"max_burst_transactions": max_burst,
"unique_broadcast_ips": len(r["ips"]),
"unique_asns": len(asns),
# filled in later by fusion/taint.py
"avg_hop_distance_from_known_bad": 0.0,
"suspicious_merge": r["suspicious_merge"],
}
def _asn_lookup(graph) -> dict[str, int]:
if not isinstance(graph, nx.MultiDiGraph):
return {}
return {n: d.get("asn") for n, d in graph.nodes(data=True) if d.get("node_type") == IP}
def transaction_features(graph, cfg: dict | None = None) -> pd.DataFrame:
"""Per-transaction features, one row per txid."""
cfg = cfg or config.load()
tolerance = cfg["graph"]["coinjoin"]["equal_value_tolerance"]
txs = _as_transactions(graph)
# Every appearance of a wallet, in time order, for the "since last seen" gap.
seen: dict[str, list[float]] = {}
for tx in txs:
ts = _epoch(tx.timestamp)
if ts is None:
continue
for addr in tx.input_addresses + tx.output_addresses:
seen.setdefault(addr, []).append(ts)
for values in seen.values():
values.sort()
rows = []
for tx in txs:
ts = _epoch(tx.timestamp)
outs = tx.output_values
value, group = equal_value_group(outs, tolerance) if outs else (0.0, [])
rows.append({
"txid": tx.txid, "timestamp": tx.timestamp,
"input_count": len(tx.inputs), "output_count": len(tx.outputs),
"equal_output_count": len(group), "equal_output_value": value,
"peel_ratio": min(outs) / max(outs) if len(outs) == 2 and max(outs) else float("nan"),
"value_in": sum(tx.input_values), "value_out": sum(outs), "fee": tx.fee,
"time_since_prev_tx_same_wallet": _previous_gap(tx, seen, ts),
})
return pd.DataFrame(rows, columns=TX_COLUMNS).sort_values("txid", ignore_index=True)
def _previous_gap(tx: Tx, seen: dict[str, list[float]], ts: float | None) -> float:
"""Seconds since any input wallet was last involved in a transaction."""
if ts is None:
return float("nan")
gaps = []
for addr in tx.input_addresses:
earlier = [t for t in seen.get(addr, []) if t < ts]
if earlier:
gaps.append(ts - earlier[-1])
return min(gaps) if gaps else float("nan")
def compute_all(graph, clustering: Clustering | None = None,
cfg: dict | None = None) -> tuple[pd.DataFrame, pd.DataFrame]:
cfg = cfg or config.load()
txs = _as_transactions(graph)
clustering = clustering or cluster_wallets(txs, cfg)
return (compute_features(graph, clustering, cfg), transaction_features(graph, cfg))
def run(input_path=None, output=None, tx_output=None, cfg: dict | None = None) -> dict:
cfg = cfg or config.load()
graph = from_parquet(input_path, cfg)
entities, transactions = compute_all(graph, cfg=cfg)
output = Path(output or cfg["features"]["entity_path"])
tx_output = Path(tx_output or cfg["features"]["transaction_path"])
for path, frame in ((output, entities), (tx_output, transactions)):
path.parent.mkdir(parents=True, exist_ok=True)
frame.to_parquet(path, index=False)
return {"entities": len(entities), "transactions": len(transactions),
"flagged_entities": int(entities["suspicious_merge"].sum()),
"dormant_then_active": int(entities["dormant_then_active"].sum()),
"output": str(output), "transaction_output": str(tx_output)}
def main(argv=None) -> None:
cfg = config.load()
ap = argparse.ArgumentParser(prog="features.engineer", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--input", default=cfg["ingest"]["output_path"])
ap.add_argument("--output", default=cfg["features"]["entity_path"])
ap.add_argument("--tx-output", default=cfg["features"]["transaction_path"])
args = ap.parse_args(argv)
print(json.dumps(run(args.input, args.output, args.tx_output), indent=2))
if __name__ == "__main__":
main()