alphax/app/db/onchain_db.py
2026-05-16 14:52:10 +08:00

673 lines
24 KiB
Python

"""On-chain discovery storage and read models.
The on-chain layer is a research/discovery input. It stores normalized external
facts and can enqueue technical-check candidates, but it must not create or
mutate trading recommendations directly.
"""
import json
from datetime import datetime, timedelta
from app.db.altcoin_db import get_conn
from app.db.postgres_connection import ensure_migrations_once
MIN_MAPPING_CONFIDENCE = 70
SIGNAL_LABELS = {
"dex_volume_spike": "DEX 放量",
"liquidity_add": "流动性增加",
"liquidity_remove_risk": "流动性撤出风险",
"exchange_outflow": "交易所流出",
"exchange_inflow_risk": "交易所流入风险",
"whale_accumulation": "鲸鱼增持",
"holder_concentration_risk": "持仓集中风险",
"smart_money_buying": "聪明钱买入",
}
RAW_EVENT_TYPE_LABELS = {
"token_profile_latest": "Token 资料更新",
"token_boost_latest": "DEX 热度 Boost",
"token_boost_top": "DEX Boost 榜",
}
POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "smart_money_buying"}
RISK_SIGNALS = {"liquidity_remove_risk", "exchange_inflow_risk", "holder_concentration_risk"}
def _now():
return datetime.now().isoformat()
def _dump(value):
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True, default=str)
def _load(value, fallback=None):
try:
if isinstance(value, str) and value.strip():
return json.loads(value)
if value is not None:
return value
except Exception:
pass
return fallback
def _symbol_base(symbol):
return str(symbol or "").upper().replace("/USDT", "").replace("USDT", "").strip()
def normalize_symbol(symbol):
base = _symbol_base(symbol)
return f"{base}/USDT" if base else ""
def signal_label(code):
return SIGNAL_LABELS.get(str(code or ""), str(code or "链上信号"))
def signal_direction(code):
code = str(code or "")
if code in RISK_SIGNALS:
return "risk"
if code in POSITIVE_SIGNALS:
return "positive"
return "neutral"
def raw_event_type_label(event_type):
return RAW_EVENT_TYPE_LABELS.get(str(event_type or ""), str(event_type or "链上原始事件"))
def init_onchain_tables():
ensure_migrations_once()
def upsert_token_mapping(symbol, chain, contract_address, source="", confidence=0, raw=None, is_active=True):
init_onchain_tables()
now = _now()
symbol = normalize_symbol(symbol)
chain = str(chain or "").lower().strip()
contract_address = str(contract_address or "").strip()
if not symbol or not chain or not contract_address:
return 0
conn = get_conn()
cur = conn.execute(
"""
INSERT INTO onchain_token_map
(symbol, chain, contract_address, source, confidence, is_active, raw_json, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT(symbol, chain, contract_address) DO UPDATE SET
source=excluded.source,
confidence=GREATEST(onchain_token_map.confidence, excluded.confidence),
is_active=excluded.is_active,
raw_json=excluded.raw_json,
updated_at=excluded.updated_at
""",
(symbol, chain, contract_address, source or "", int(confidence or 0), 1 if is_active else 0, _dump(raw), now, now),
)
conn.commit()
row = conn.execute(
"SELECT id FROM onchain_token_map WHERE symbol=%s AND chain=%s AND contract_address=%s",
(symbol, chain, contract_address),
).fetchone()
conn.close()
return int(row["id"] if row else 0)
def get_token_mappings(symbol="", min_confidence=MIN_MAPPING_CONFIDENCE, active_only=True):
init_onchain_tables()
clauses = ["confidence >= %s"]
params = [int(min_confidence or 0)]
if symbol:
clauses.append("symbol=%s")
params.append(normalize_symbol(symbol))
if active_only:
clauses.append("is_active=1")
conn = get_conn()
rows = conn.execute(
f"""
SELECT * FROM onchain_token_map
WHERE {' AND '.join(clauses)}
ORDER BY confidence DESC, updated_at DESC
""",
tuple(params),
).fetchall()
conn.close()
return [dict(row) for row in rows]
def _event_hash(event):
raw = "|".join(
[
str(event.get("source") or ""),
str(event.get("chain") or ""),
str(event.get("symbol") or ""),
str(event.get("signal_code") or event.get("event_type") or ""),
str(event.get("tx_hash") or event.get("contract_address") or ""),
str(event.get("detected_at") or ""),
str(round(float(event.get("value_usd") or 0), 2)),
]
).lower()
import hashlib
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def _raw_event_hash(event):
raw = "|".join(
[
str(event.get("source") or ""),
str(event.get("chain") or ""),
str(event.get("event_type") or ""),
str(event.get("token_address") or ""),
str(event.get("url") or ""),
str(round(float(event.get("amount") or 0), 4)),
str(round(float(event.get("total_amount") or 0), 4)),
]
).lower()
import hashlib
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def insert_onchain_event(event):
init_onchain_tables()
item = dict(event or {})
item["symbol"] = normalize_symbol(item.get("symbol"))
item["chain"] = str(item.get("chain") or "").lower().strip()
item["signal_code"] = str(item.get("signal_code") or "").strip()
item["event_type"] = str(item.get("event_type") or item["signal_code"] or "onchain_event")
if not item["symbol"] or not item["chain"] or not item["signal_code"]:
return 0
item["signal_label"] = item.get("signal_label") or signal_label(item["signal_code"])
item["direction"] = item.get("direction") or signal_direction(item["signal_code"])
item["detected_at"] = str(item.get("detected_at") or _now())
item["event_hash"] = item.get("event_hash") or _event_hash(item)
conn = get_conn()
try:
cur = conn.execute(
"""
INSERT INTO onchain_events (
event_hash, chain, symbol, contract_address, event_type, signal_code, signal_label,
direction, value_usd, amount, tx_hash, wallet_address, wallet_label,
counterparty_label, confidence, severity, status, detected_at, source, url, raw_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT(event_hash) DO NOTHING
RETURNING id
""",
(
item["event_hash"],
item["chain"],
item["symbol"],
item.get("contract_address") or "",
item["event_type"],
item["signal_code"],
item["signal_label"],
item["direction"],
float(item.get("value_usd") or 0),
float(item.get("amount") or 0),
item.get("tx_hash") or "",
item.get("wallet_address") or "",
item.get("wallet_label") or "",
item.get("counterparty_label") or "",
int(item.get("confidence") or 0),
item.get("severity") or "B",
item.get("status") or "new",
item["detected_at"],
item.get("source") or "",
item.get("url") or "",
_dump(item.get("raw") or item.get("raw_json") or {}),
),
)
row = cur.fetchone()
event_id = int(row["id"] if row else 0)
conn.commit()
finally:
conn.close()
return event_id
def find_mapping_by_contract(chain, contract_address):
init_onchain_tables()
chain = str(chain or "").lower().strip()
contract_address = str(contract_address or "").strip()
if not chain or not contract_address:
return None
conn = get_conn()
row = conn.execute(
"""
SELECT *
FROM onchain_token_map
WHERE chain=%s AND lower(contract_address)=lower(%s) AND is_active=1
ORDER BY confidence DESC, updated_at DESC
LIMIT 1
""",
(chain, contract_address),
).fetchone()
conn.close()
return dict(row) if row else None
def insert_onchain_raw_event(event):
init_onchain_tables()
item = dict(event or {})
item["source"] = str(item.get("source") or "").strip()
item["chain"] = str(item.get("chain") or "").lower().strip()
item["event_type"] = str(item.get("event_type") or "onchain_raw_event").strip()
item["token_address"] = str(item.get("token_address") or "").strip()
if not item["source"] or not item["chain"] or not item["event_type"] or not item["token_address"]:
return 0
item["detected_at"] = str(item.get("detected_at") or _now())
item["event_hash"] = item.get("event_hash") or _raw_event_hash(item)
item["mapped_symbol"] = normalize_symbol(item.get("mapped_symbol")) if item.get("mapped_symbol") else ""
item["mapping_status"] = str(item.get("mapping_status") or ("mapped" if item["mapped_symbol"] else "unmapped"))
conn = get_conn()
try:
cur = conn.execute(
"""
INSERT INTO onchain_raw_events (
event_hash, source, chain, event_type, token_address, symbol_guess, name,
title, description, url, icon, amount, total_amount, importance,
mapped_symbol, mapping_status, detected_at, raw_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT(event_hash) DO NOTHING
RETURNING id
""",
(
item["event_hash"],
item["source"],
item["chain"],
item["event_type"],
item["token_address"],
item.get("symbol_guess") or "",
item.get("name") or "",
item.get("title") or raw_event_type_label(item["event_type"]),
item.get("description") or "",
item.get("url") or "",
item.get("icon") or "",
float(item.get("amount") or 0),
float(item.get("total_amount") or 0),
float(item.get("importance") or 0),
item["mapped_symbol"],
item["mapping_status"],
item["detected_at"],
_dump(item.get("raw") or item.get("raw_json") or {}),
),
)
row = cur.fetchone()
event_id = int(row["id"] if row else 0)
conn.commit()
finally:
conn.close()
return event_id
def insert_token_metric(metric):
init_onchain_tables()
item = dict(metric or {})
item["symbol"] = normalize_symbol(item.get("symbol"))
item["chain"] = str(item.get("chain") or "").lower().strip()
item["window"] = str(item.get("window") or "1h").strip()
item["metric_time"] = str(item.get("metric_time") or _now())
if not item["symbol"] or not item["chain"] or not item["window"]:
return 0
conn = get_conn()
cur = conn.execute(
"""
INSERT INTO onchain_token_metrics (
symbol, chain, contract_address, "window", metric_time,
dex_volume_usd, dex_volume_change_pct, liquidity_usd, liquidity_change_pct,
exchange_netflow_usd, whale_accumulation_usd, holder_delta, smart_money_score,
onchain_score, risk_score, source, raw_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT(symbol, chain, contract_address, "window", metric_time) DO UPDATE SET
dex_volume_usd=excluded.dex_volume_usd,
dex_volume_change_pct=excluded.dex_volume_change_pct,
liquidity_usd=excluded.liquidity_usd,
liquidity_change_pct=excluded.liquidity_change_pct,
exchange_netflow_usd=excluded.exchange_netflow_usd,
whale_accumulation_usd=excluded.whale_accumulation_usd,
holder_delta=excluded.holder_delta,
smart_money_score=excluded.smart_money_score,
onchain_score=excluded.onchain_score,
risk_score=excluded.risk_score,
source=excluded.source,
raw_json=excluded.raw_json
RETURNING id
""",
(
item["symbol"],
item["chain"],
item.get("contract_address") or "",
item["window"],
item["metric_time"],
float(item.get("dex_volume_usd") or 0),
float(item.get("dex_volume_change_pct") or 0),
float(item.get("liquidity_usd") or 0),
float(item.get("liquidity_change_pct") or 0),
float(item.get("exchange_netflow_usd") or 0),
float(item.get("whale_accumulation_usd") or 0),
float(item.get("holder_delta") or 0),
float(item.get("smart_money_score") or 0),
float(item.get("onchain_score") or 0),
float(item.get("risk_score") or 0),
item.get("source") or "",
_dump(item.get("raw") or item.get("raw_json") or {}),
),
)
conn.commit()
metric_id = int(cur.fetchone()["id"] or 0)
conn.close()
return metric_id
def _latest_metrics_subquery(hours=24):
return """
SELECT m.*
FROM onchain_token_metrics m
JOIN (
SELECT symbol, chain, contract_address, MAX(metric_time) AS max_time
FROM onchain_token_metrics
WHERE metric_time >= %s
GROUP BY symbol, chain, contract_address
) latest ON latest.symbol=m.symbol
AND latest.chain=m.chain
AND latest.contract_address=m.contract_address
AND latest.max_time=m.metric_time
"""
def get_onchain_overview(hours=24):
init_onchain_tables()
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
conn = get_conn()
event_rows = conn.execute("SELECT * FROM onchain_events WHERE detected_at >= %s", (cutoff,)).fetchall()
raw_rows = conn.execute("SELECT * FROM onchain_raw_events WHERE detected_at >= %s", (cutoff,)).fetchall()
raw_latest = conn.execute(
"""
SELECT * FROM onchain_raw_events
WHERE detected_at >= %s
ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
LIMIT 12
""",
(cutoff,),
).fetchall()
metric_rows = conn.execute(_latest_metrics_subquery(hours), (cutoff,)).fetchall()
rec_rows = conn.execute(
"SELECT symbol, id, execution_status, action_status, display_bucket FROM recommendation WHERE status='active'"
).fetchall()
conn.close()
active = {row["symbol"]: dict(row) for row in rec_rows}
events = [dict(row) for row in event_rows]
raw_events = [dict(row) for row in raw_rows]
metrics = [dict(row) for row in metric_rows]
hot = sorted(metrics, key=lambda x: float(x.get("onchain_score") or 0), reverse=True)[:8]
risks = sorted(metrics, key=lambda x: float(x.get("risk_score") or 0), reverse=True)[:8]
total_netflow = sum(float(x.get("exchange_netflow_usd") or 0) for x in metrics)
dex_volume = sum(float(x.get("dex_volume_usd") or 0) for x in metrics)
return {
"hours": int(hours or 24),
"updated_at": _now(),
"kpi": {
"event_count": len(events),
"raw_event_count": len(raw_events),
"raw_unmapped_count": sum(1 for e in raw_events if e.get("mapping_status") == "unmapped"),
"raw_mapped_count": sum(1 for e in raw_events if e.get("mapping_status") == "mapped"),
"token_count": len({(m["symbol"], m["chain"], m.get("contract_address") or "") for m in metrics}),
"positive_events": sum(1 for e in events if e.get("direction") == "positive"),
"risk_events": sum(1 for e in events if e.get("direction") == "risk"),
"exchange_netflow_usd": round(total_netflow, 2),
"dex_volume_usd": round(dex_volume, 2),
},
"hot_tokens": [_format_metric_item(row, active) for row in hot],
"risk_tokens": [_format_metric_item(row, active) for row in risks],
"raw_events": [_format_raw_event(row) for row in raw_latest],
"signals": _signal_counts(events),
}
def _signal_counts(events):
counts = {}
for e in events:
code = e.get("signal_code") or ""
if not code:
continue
counts.setdefault(code, {"signal_code": code, "signal_label": signal_label(code), "count": 0})
counts[code]["count"] += 1
return sorted(counts.values(), key=lambda x: x["count"], reverse=True)
def _format_metric_item(row, active=None):
active = active or {}
item = dict(row)
item["raw"] = _load(item.pop("raw_json", "{}"), {})
rec = active.get(item.get("symbol")) or {}
item["recommendation"] = {
"rec_id": rec.get("id") or 0,
"execution_status": rec.get("execution_status") or "",
"action_status": rec.get("action_status") or "",
"display_bucket": rec.get("display_bucket") or "",
"has_active": bool(rec),
}
return item
def list_onchain_tokens(limit=30, offset=0, chain="", signal="", hours=24):
init_onchain_tables()
limit = max(1, min(int(limit or 30), 100))
offset = max(0, int(offset or 0))
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
clauses = []
params = []
if chain:
clauses.append("m.chain=%s")
params.append(str(chain).lower())
if signal:
clauses.append(
"""
EXISTS (
SELECT 1 FROM onchain_events e
WHERE e.symbol=m.symbol AND e.chain=m.chain
AND e.detected_at >= %s AND e.signal_code=%s
)
"""
)
params.extend([cutoff, signal])
where = " AND ".join(clauses) if clauses else "1=1"
conn = get_conn()
total = conn.execute(
f"""
SELECT COUNT(*) FROM (
SELECT m.symbol, m.chain, m.contract_address
FROM onchain_token_metrics m
WHERE {where}
GROUP BY m.symbol, m.chain, m.contract_address
)
""",
tuple(params),
).fetchone()[0]
rows = conn.execute(
f"""
SELECT m.*,
(SELECT COUNT(*) FROM onchain_events e
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.detected_at >= %s) AS event_count,
(SELECT COUNT(*) FROM onchain_events e
WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.direction='risk' AND e.detected_at >= %s) AS risk_event_count
FROM ({_latest_metrics_subquery(hours)}) m
WHERE {where}
ORDER BY m.onchain_score DESC, m.risk_score DESC, m.metric_time DESC
LIMIT %s OFFSET %s
""",
(cutoff, cutoff, cutoff, *params, limit, offset),
).fetchall()
rec_rows = conn.execute(
"SELECT symbol, id, execution_status, action_status, display_bucket FROM recommendation WHERE status='active'"
).fetchall()
conn.close()
active = {row["symbol"]: dict(row) for row in rec_rows}
return {
"items": [_format_metric_item(row, active) for row in rows],
"total": int(total or 0),
"limit": limit,
"offset": offset,
"has_more": offset + len(rows) < int(total or 0),
}
def get_onchain_token_detail(symbol, hours=72):
init_onchain_tables()
symbol = normalize_symbol(symbol)
cutoff = (datetime.now() - timedelta(hours=int(hours or 72))).isoformat()
conn = get_conn()
mappings = conn.execute(
"SELECT * FROM onchain_token_map WHERE symbol=%s ORDER BY confidence DESC, updated_at DESC",
(symbol,),
).fetchall()
events = conn.execute(
"""
SELECT * FROM onchain_events
WHERE symbol=%s AND detected_at >= %s
ORDER BY detected_at DESC, id DESC
LIMIT 100
""",
(symbol, cutoff),
).fetchall()
metrics = conn.execute(
"""
SELECT * FROM onchain_token_metrics
WHERE symbol=%s AND metric_time >= %s
ORDER BY metric_time DESC, id DESC
LIMIT 100
""",
(symbol, cutoff),
).fetchall()
rec = conn.execute(
"""
SELECT id, rec_time, action_status, execution_status, display_bucket, entry_price, current_price
FROM recommendation
WHERE symbol=%s AND status='active'
ORDER BY id DESC LIMIT 1
""",
(symbol,),
).fetchone()
conn.close()
return {
"symbol": symbol,
"hours": int(hours or 72),
"mappings": [_with_raw(row) for row in mappings],
"events": [_with_raw(row) for row in events],
"metrics": [_with_raw(row) for row in metrics],
"recommendation": dict(rec) if rec else None,
}
def list_onchain_events(limit=50, offset=0, chain="", signal="", status="", hours=24):
init_onchain_tables()
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
clauses = ["detected_at >= %s"]
params = [cutoff]
if chain:
clauses.append("chain=%s")
params.append(str(chain).lower())
if signal:
clauses.append("signal_code=%s")
params.append(signal)
if status:
clauses.append("status=%s")
params.append(status)
where = " AND ".join(clauses)
conn = get_conn()
total = conn.execute(f"SELECT COUNT(*) FROM onchain_events WHERE {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM onchain_events
WHERE {where}
ORDER BY detected_at::timestamp DESC, id DESC
LIMIT %s OFFSET %s
""",
(*params, limit, offset),
).fetchall()
conn.close()
return {"items": [_with_raw(row) for row in rows], "total": int(total or 0), "limit": limit, "offset": offset, "has_more": offset + len(rows) < int(total or 0)}
def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type="", mapping_status="", hours=24):
init_onchain_tables()
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
clauses = ["detected_at >= %s"]
params = [cutoff]
if chain:
clauses.append("chain=%s")
params.append(str(chain).lower())
if source:
clauses.append("source=%s")
params.append(source)
if event_type:
clauses.append("event_type=%s")
params.append(event_type)
if mapping_status:
clauses.append("mapping_status=%s")
params.append(mapping_status)
where = " AND ".join(clauses)
conn = get_conn()
total = conn.execute(f"SELECT COUNT(*) FROM onchain_raw_events WHERE {where}", tuple(params)).fetchone()[0]
rows = conn.execute(
f"""
SELECT * FROM onchain_raw_events
WHERE {where}
ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
LIMIT %s OFFSET %s
""",
(*params, limit, offset),
).fetchall()
conn.close()
return {
"items": [_format_raw_event(row) for row in rows],
"total": int(total or 0),
"limit": limit,
"offset": offset,
"has_more": offset + len(rows) < int(total or 0),
}
def update_event_status(event_ids, status):
if not event_ids:
return 0
init_onchain_tables()
conn = get_conn()
cur = conn.execute(
"UPDATE onchain_events SET status=%s WHERE id IN (" + ",".join(["%s"] * len(event_ids)) + ")",
(status, *[int(x) for x in event_ids]),
)
conn.commit()
conn.close()
return int(cur.rowcount or 0)
def _with_raw(row):
item = dict(row)
if "raw_json" in item:
item["raw"] = _load(item.pop("raw_json"), {})
return item
def _format_raw_event(row):
item = _with_raw(row)
item["event_label"] = raw_event_type_label(item.get("event_type"))
item["token_short"] = _short_address(item.get("token_address"))
return item
def _short_address(value):
value = str(value or "")
if len(value) <= 14:
return value
return value[:6] + "..." + value[-4:]