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

745 lines
29 KiB
Python

"""On-chain signal collector and candidate bridge.
V1 deliberately treats on-chain data as a discovery/risk layer. It writes
normalized events/metrics and may request a technical check through event_news,
but it never creates recommendations or changes recommendation state directly.
"""
import json
import os
from datetime import datetime, timedelta
import requests
from app.db import onchain_db
from app.db.altcoin_db import get_conn, init_db, log_cron_run
from app.db.onchain_db import (
MIN_MAPPING_CONFIDENCE,
POSITIVE_SIGNALS,
RISK_SIGNALS,
find_mapping_by_contract,
get_token_mappings,
init_onchain_tables,
insert_onchain_event,
insert_onchain_raw_event,
insert_token_metric,
normalize_symbol,
signal_direction,
signal_label,
)
from app.services.event_driven_screener import _event_hash as event_hash
from app.services.event_driven_screener import _tradable_symbol, init_event_tables
DEFAULT_CHAINS = ("ethereum", "bsc", "base", "arbitrum", "solana")
SOLANA_AUTO_ALLOWLIST = {
"WIF", "BONK", "JUP", "RAY", "PYTH", "PENGU", "JTO", "MEW", "POPCAT", "PNUT",
"FARTCOIN", "RENDER", "HNT", "MOBILE", "ORCA", "KMNO", "DRIFT", "TNSR", "IO",
}
NON_TARGET_NATIVE_BASES = {
"AVAX", "FIL", "SUI", "APT", "DOT", "ADA", "XRP", "LTC", "BCH", "ATOM", "NEAR",
"SEI", "INJ", "TON", "ETC", "ICP", "HBAR", "ALGO", "VET", "TRX", "XLM", "KAS",
"TIA", "EGLD", "FLOW", "KAVA", "MINA", "IOTA", "XMR", "DASH", "ZEC",
}
BRIDGED_TOKEN_MARKERS = (
"wrapped", "wormhole", "portal", "bridged", "bridge", "axelar", "allbridge",
"binance-peg", "multichain", "layerzero", "lz", "wavax", "wfil",
)
DEX_CHAIN_ALIASES = {
"ethereum": "ethereum",
"eth": "ethereum",
"bsc": "bsc",
"bnb": "bsc",
"base": "base",
"arbitrum": "arbitrum",
"arb": "arbitrum",
"solana": "solana",
"sol": "solana",
}
DEXSCREENER_RAW_ENDPOINTS = (
("token_profile_latest", "https://api.dexscreener.com/token-profiles/latest/v1"),
("token_boost_latest", "https://api.dexscreener.com/token-boosts/latest/v1"),
("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"),
)
def _env_bool(name, default=False):
value = os.getenv(name)
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _env_int(name, default):
try:
return int(os.getenv(name, str(default)) or default)
except Exception:
return default
def _env_float(name, default):
try:
return float(os.getenv(name, str(default)) or default)
except Exception:
return default
def get_onchain_params():
"""Runtime provider config. Keep this out of rules.yaml."""
chains = [x.strip().lower() for x in os.getenv("ALPHAX_ONCHAIN_CHAINS", ",".join(DEFAULT_CHAINS)).split(",") if x.strip()]
return {
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
"chains": chains or list(DEFAULT_CHAINS),
"timeout": _env_int("ALPHAX_ONCHAIN_TIMEOUT", 15),
"candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
"candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
"candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),
"candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6),
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", True),
"dex_volume_spike_pct": _env_float("ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT", 80),
"dex_min_liquidity_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD", 100000),
"dex_min_volume_24h_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD", 100000),
"liquidity_add_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT", 25),
"liquidity_remove_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT", -25),
"whale_tx_usd": _env_float("ALPHAX_ONCHAIN_WHALE_TX_USD", 250000),
"etherscan_api_key": os.getenv("ALPHAX_ETHERSCAN_API_KEY", "").strip(),
"helius_api_key": os.getenv("ALPHAX_HELIUS_API_KEY", "").strip(),
}
def _now():
return datetime.now()
def _request_json(url, params=None, timeout=15):
resp = requests.get(url, params=params or {}, timeout=timeout, headers={"User-Agent": "AlphaX-Agent-Crypto/1.0"})
if resp.status_code >= 400:
raise RuntimeError(f"http_{resp.status_code}:{resp.text[:200]}")
return resp.json()
def _safe_float(value, default=0.0):
try:
return float(value or 0)
except Exception:
return default
def _safe_pct_change(new_value, old_value):
new_value = _safe_float(new_value)
old_value = _safe_float(old_value)
if old_value <= 0:
return 0.0
return (new_value - old_value) / old_value * 100
def _chain_alias(value):
key = str(value or "").lower()
return DEX_CHAIN_ALIASES.get(key, key)
def _latest_metric(symbol, chain, contract_address):
conn = get_conn()
row = conn.execute(
"""
SELECT * FROM onchain_token_metrics
WHERE symbol=%s AND chain=%s AND contract_address=%s AND "window"='1h'
ORDER BY metric_time DESC, id DESC LIMIT 1
""",
(symbol, chain, contract_address or ""),
).fetchone()
conn.close()
return dict(row) if row else None
def _event_amount(item):
return _safe_float(item.get("amount"))
def _event_total_amount(item):
return _safe_float(item.get("totalAmount") or item.get("total_amount"))
def _raw_importance(event_type, item):
amount = _event_amount(item)
total = _event_total_amount(item)
if event_type == "token_boost_top":
return max(total, amount, 1)
if event_type == "token_boost_latest":
return max(amount, total * 0.5, 1)
return 1
def normalize_dexscreener_raw_event(item, event_type, cfg=None):
cfg = cfg or get_onchain_params()
chain = _chain_alias(item.get("chainId"))
if chain not in set(cfg.get("chains") or DEFAULT_CHAINS):
return None
token_address = str(item.get("tokenAddress") or "").strip()
if not token_address:
return None
mapping = find_mapping_by_contract(chain, token_address)
links = item.get("links") or []
symbol_guess = ""
name = ""
if isinstance(links, list):
for link in links:
if not isinstance(link, dict):
continue
if not name and link.get("label"):
name = str(link.get("label") or "")
raw = {
"chainId": item.get("chainId"),
"tokenAddress": token_address,
"url": item.get("url") or "",
"description": item.get("description") or "",
"icon": item.get("icon") or "",
"header": item.get("header") or "",
"links": links,
"amount": item.get("amount"),
"totalAmount": item.get("totalAmount"),
}
title = "DEX Screener"
if event_type == "token_profile_latest":
title = "Token 资料更新"
elif event_type == "token_boost_latest":
title = "DEX Boost 新增"
elif event_type == "token_boost_top":
title = "DEX Boost 榜单"
return {
"source": "dexscreener",
"chain": chain,
"event_type": event_type,
"token_address": token_address,
"symbol_guess": symbol_guess,
"name": name,
"title": title,
"description": item.get("description") or "",
"url": item.get("url") or "",
"icon": item.get("icon") or "",
"amount": _event_amount(item),
"total_amount": _event_total_amount(item),
"importance": _raw_importance(event_type, item),
"mapped_symbol": mapping.get("symbol") if mapping else "",
"mapping_status": "mapped" if mapping else "unmapped",
"detected_at": _now().isoformat(timespec="seconds"),
"raw": raw,
}
def fetch_dexscreener_raw_events(limit=80):
cfg = get_onchain_params()
if not cfg.get("dexscreener_enabled", True):
return {"raw_events": [], "errors": ["dexscreener_disabled"]}
inserted = []
errors = []
per_source_limit = max(1, int(limit or 80))
for event_type, url in DEXSCREENER_RAW_ENDPOINTS:
try:
data = _request_json(url, timeout=cfg.get("timeout", 15))
items = data if isinstance(data, list) else data.get("items") or data.get("data") or []
for item in items[:per_source_limit]:
if not isinstance(item, dict):
continue
event = normalize_dexscreener_raw_event(item, event_type, cfg=cfg)
if not event:
continue
if insert_onchain_raw_event(event):
inserted.append(event)
except Exception as exc:
errors.append(f"{event_type}:{str(exc)[:160]}")
return {"raw_events": inserted, "errors": errors}
def _discover_seed_symbols(limit=120):
conn = get_conn()
symbols = []
try:
rows = conn.execute(
"""
SELECT DISTINCT symbol
FROM recommendation
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
ORDER BY rec_time DESC
LIMIT %s
""",
(int(limit or 120),),
).fetchall()
symbols.extend([row["symbol"] for row in rows if row["symbol"]])
except Exception:
pass
try:
rows = conn.execute(
"""
SELECT DISTINCT symbol
FROM coin_state
WHERE state != '过期'
ORDER BY detected_at DESC
LIMIT %s
""",
(int(limit or 120),),
).fetchall()
symbols.extend([row["symbol"] for row in rows if row["symbol"]])
except Exception:
pass
conn.close()
seen = set()
ordered = []
for symbol in symbols:
norm = normalize_symbol(symbol)
if not norm or norm in seen or not _tradable_symbol(norm):
continue
seen.add(norm)
ordered.append(norm)
return ordered[: int(limit or 120)]
def _score_pair_candidate(pair, requested_symbol, chains):
base = (pair.get("baseToken") or {})
quote = (pair.get("quoteToken") or {})
base_symbol = str(base.get("symbol") or "").upper()
req_base = str(requested_symbol or "").split("/")[0].upper()
liquidity = _safe_float((pair.get("liquidity") or {}).get("usd"))
volume = _safe_float((pair.get("volume") or {}).get("h24"))
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or "").lower(), str(pair.get("chainId") or "").lower())
score = 0
if base_symbol == req_base:
score += 50
if chain in set(chains or []):
score += 15
if quote.get("symbol") in ("USDT", "USDC", "USD", "FDUSD", "USDE", "DAI", "USDS"):
score += 10
if liquidity >= 100000:
score += 10
if volume >= 100000:
score += 10
if liquidity >= 500000:
score += 5
return score
def _pair_rejection_reason(pair, requested_symbol, chains):
base = pair.get("baseToken") or {}
quote = pair.get("quoteToken") or {}
req_base = str(requested_symbol or "").split("/")[0].upper()
base_symbol = str(base.get("symbol") or "").upper()
base_name = str(base.get("name") or "").lower()
pair_url = str(pair.get("url") or "").lower()
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or "").lower(), str(pair.get("chainId") or "").lower())
if base_symbol != req_base:
return "symbol_mismatch"
if chain not in set(chains or []):
return "chain_not_supported"
if req_base in NON_TARGET_NATIVE_BASES:
return "native_chain_not_in_scope"
if chain == "solana" and req_base not in SOLANA_AUTO_ALLOWLIST:
return "solana_not_allowlisted"
text = " ".join([base_name, base_symbol.lower(), str(quote.get("symbol") or "").lower(), pair_url])
if any(marker in text for marker in BRIDGED_TOKEN_MARKERS):
return "bridged_or_wrapped_token"
return ""
def discover_token_mappings(limit=60):
cfg = get_onchain_params()
chains = set(cfg.get("chains") or DEFAULT_CHAINS)
seeds = _discover_seed_symbols(limit=limit)
if not seeds:
return {"inserted": 0, "candidates": [], "errors": ["no_seed_symbols"]}
inserted = []
errors = []
for symbol in seeds:
existing = get_token_mappings(symbol, min_confidence=1, active_only=False)
if existing:
continue
base = symbol.split("/")[0]
try:
data = _request_json("https://api.dexscreener.com/latest/dex/search", params={"q": base}, timeout=cfg.get("timeout", 15))
pairs = data.get("pairs") or []
pair_candidates = []
for pair in pairs:
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or "").lower(), str(pair.get("chainId") or "").lower())
if chain not in chains:
continue
if _pair_rejection_reason(pair, symbol, chains):
continue
pair_candidates.append((pair, _score_pair_candidate(pair, symbol, chains)))
if not pair_candidates:
continue
pair_candidates.sort(key=lambda x: (x[1], _safe_float((x[0].get("liquidity") or {}).get("usd")), _safe_float((x[0].get("volume") or {}).get("h24"))), reverse=True)
best, score = pair_candidates[0]
if score < 55:
continue
base_token = best.get("baseToken") or {}
chain = DEX_CHAIN_ALIASES.get(str(best.get("chainId") or "").lower(), str(best.get("chainId") or "").lower())
contract = str(base_token.get("address") or "").strip()
if not contract:
continue
confidence = min(95, 60 + score)
mapping_id = onchain_db.upsert_token_mapping(
symbol=symbol,
chain=chain,
contract_address=contract,
source="dexscreener_search",
confidence=confidence,
raw={
"search_query": base,
"matched_pair": {
"pairAddress": best.get("pairAddress") or "",
"dexId": best.get("dexId") or "",
"url": best.get("url") or "",
"liquidity": best.get("liquidity") or {},
"volume": best.get("volume") or {},
"priceChange": best.get("priceChange") or {},
"baseToken": base_token,
"quoteToken": best.get("quoteToken") or {},
},
},
is_active=True,
)
if mapping_id:
inserted.append({"symbol": symbol, "chain": chain, "contract_address": contract, "confidence": confidence})
except Exception as exc:
errors.append(f"{symbol}:{str(exc)[:160]}")
return {"inserted": len(inserted), "candidates": inserted, "errors": errors}
def _score_metric(metric):
score = 0.0
risk = 0.0
vol_change = _safe_float(metric.get("dex_volume_change_pct"))
liq_change = _safe_float(metric.get("liquidity_change_pct"))
netflow = _safe_float(metric.get("exchange_netflow_usd"))
whale = _safe_float(metric.get("whale_accumulation_usd"))
smart = _safe_float(metric.get("smart_money_score"))
if vol_change > 0:
score += min(35, vol_change / 4)
if liq_change > 0:
score += min(20, liq_change / 3)
if netflow < 0:
score += min(20, abs(netflow) / 100000)
if whale > 0:
score += min(20, whale / 100000)
score += min(20, smart)
if liq_change < 0:
risk += min(40, abs(liq_change))
if netflow > 0:
risk += min(35, netflow / 100000)
metric["onchain_score"] = round(min(score, 100), 2)
metric["risk_score"] = round(min(risk, 100), 2)
return metric
def derive_dex_signals(metric, cfg=None):
cfg = cfg or get_onchain_params()
signals = []
vol_change = _safe_float(metric.get("dex_volume_change_pct"))
liq_change = _safe_float(metric.get("liquidity_change_pct"))
if vol_change >= cfg.get("dex_volume_spike_pct", 80):
signals.append("dex_volume_spike")
if liq_change >= cfg.get("liquidity_add_pct", 25):
signals.append("liquidity_add")
if liq_change <= cfg.get("liquidity_remove_pct", -25):
signals.append("liquidity_remove_risk")
return signals
def _event_from_metric(metric, signal_code, source="dexscreener"):
direction = signal_direction(signal_code)
severity = "RISK" if direction == "risk" else "A" if _safe_float(metric.get("onchain_score")) >= 75 else "B"
return {
"chain": metric.get("chain"),
"symbol": metric.get("symbol"),
"contract_address": metric.get("contract_address") or "",
"event_type": "onchain_signal",
"signal_code": signal_code,
"signal_label": signal_label(signal_code),
"direction": direction,
"value_usd": metric.get("dex_volume_usd") or metric.get("whale_accumulation_usd") or abs(metric.get("exchange_netflow_usd") or 0),
"confidence": 75 if direction != "risk" else 80,
"severity": severity,
"detected_at": metric.get("metric_time") or _now().isoformat(),
"source": source,
"url": metric.get("url") or "",
"raw": metric,
}
def normalize_dexscreener_pair(pair, mapping, cfg=None):
cfg = cfg or get_onchain_params()
symbol = normalize_symbol(mapping.get("symbol"))
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or mapping.get("chain") or "").lower(), str(mapping.get("chain") or "").lower())
contract = mapping.get("contract_address") or (pair.get("baseToken") or {}).get("address") or ""
liquidity = _safe_float((pair.get("liquidity") or {}).get("usd"))
volume = _safe_float((pair.get("volume") or {}).get("h24"))
prev = _latest_metric(symbol, chain, contract)
prev_volume = _safe_float(prev.get("dex_volume_usd") if prev else 0)
prev_liquidity = _safe_float(prev.get("liquidity_usd") if prev else 0)
metric = {
"symbol": symbol,
"chain": chain,
"contract_address": contract,
"window": "1h",
"metric_time": _now().isoformat(timespec="seconds"),
"dex_volume_usd": volume,
"dex_volume_change_pct": _safe_pct_change(volume, prev_volume),
"liquidity_usd": liquidity,
"liquidity_change_pct": _safe_pct_change(liquidity, prev_liquidity),
"exchange_netflow_usd": 0,
"whale_accumulation_usd": 0,
"holder_delta": 0,
"smart_money_score": 0,
"source": "dexscreener",
"url": pair.get("url") or "",
"raw": {
"pair_address": pair.get("pairAddress") or "",
"dex_id": pair.get("dexId") or "",
"price_usd": pair.get("priceUsd") or "",
"fdv": pair.get("fdv") or 0,
"txns": pair.get("txns") or {},
"price_change": pair.get("priceChange") or {},
"volume": pair.get("volume") or {},
"liquidity": pair.get("liquidity") or {},
},
}
return _score_metric(metric)
def fetch_dexscreener_metrics(limit=60):
cfg = get_onchain_params()
if not cfg.get("dexscreener_enabled", True):
return {"metrics": [], "events": [], "errors": ["dexscreener_disabled"]}
mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
bootstrap = None
if not mappings:
bootstrap = discover_token_mappings(limit=limit)
mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
metrics = []
events = []
errors = []
if bootstrap:
errors.extend(bootstrap.get("errors") or [])
for mapping in mappings[: int(limit or 60)]:
symbol = normalize_symbol(mapping.get("symbol"))
if not symbol or not _tradable_symbol(symbol):
continue
try:
url = "https://api.dexscreener.com/latest/dex/tokens/" + str(mapping.get("contract_address") or "").strip()
data = _request_json(url, timeout=cfg.get("timeout", 15))
pairs = data.get("pairs") or []
wanted_chain = DEX_CHAIN_ALIASES.get(str(mapping.get("chain") or "").lower(), str(mapping.get("chain") or "").lower())
pairs = [p for p in pairs if DEX_CHAIN_ALIASES.get(str(p.get("chainId") or "").lower(), str(p.get("chainId") or "").lower()) == wanted_chain]
if not pairs:
continue
best = max(pairs, key=lambda p: _safe_float((p.get("liquidity") or {}).get("usd")))
metric = normalize_dexscreener_pair(best, mapping, cfg=cfg)
if metric.get("liquidity_usd", 0) < cfg.get("dex_min_liquidity_usd", 100000) and metric.get("dex_volume_usd", 0) < cfg.get("dex_min_volume_24h_usd", 100000):
insert_token_metric(metric)
metrics.append(metric)
continue
insert_token_metric(metric)
metrics.append(metric)
for code in derive_dex_signals(metric, cfg):
event = _event_from_metric(metric, code, source="dexscreener")
if insert_onchain_event(event):
events.append(event)
except Exception as exc:
errors.append(f"{symbol}:{str(exc)[:160]}")
return {"metrics": metrics, "events": events, "errors": errors}
def ingest_normalized_events(events):
"""Test/integration helper for provider adapters."""
init_db()
init_onchain_tables()
inserted = []
for event in events or []:
eid = insert_onchain_event(event)
if eid:
item = dict(event)
item["id"] = eid
inserted.append(item)
queued = enqueue_onchain_candidates()
return {"inserted": len(inserted), "queued": queued.get("queued", 0), "events": inserted, "candidate_result": queued}
def _candidate_title(event):
label = event.get("signal_label") or signal_label(event.get("signal_code"))
value = _safe_float(event.get("value_usd"))
value_txt = f" · ${value:,.0f}" if value > 0 else ""
return f"链上异动 {event.get('symbol')}: {label}{value_txt}"
def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hours=None, limit=20):
cfg = get_onchain_params()
if not cfg.get("candidate_enabled", True):
return {"queued": 0, "skipped": 0, "symbols": [], "reason": "candidate_disabled"}
min_score = cfg.get("candidate_min_score", 70) if min_score is None else min_score
min_confidence = cfg.get("candidate_min_confidence", 70) if min_confidence is None else min_confidence
cooldown_hours = cfg.get("candidate_cooldown_hours", 6) if cooldown_hours is None else cooldown_hours
init_onchain_tables()
init_event_tables()
cutoff = (_now() - timedelta(hours=24)).isoformat()
conn = get_conn()
rows = conn.execute(
"""
SELECT e.*,
COALESCE((
SELECT m.onchain_score FROM onchain_token_metrics m
WHERE m.symbol=e.symbol AND m.chain=e.chain
ORDER BY m.metric_time::timestamp DESC, m.id DESC LIMIT 1
), 0) AS latest_onchain_score,
COALESCE((
SELECT m.risk_score FROM onchain_token_metrics m
WHERE m.symbol=e.symbol AND m.chain=e.chain
ORDER BY m.metric_time::timestamp DESC, m.id DESC LIMIT 1
), 0) AS latest_risk_score
FROM onchain_events e
WHERE e.status IN ('new', 'candidate_failed')
AND e.detected_at >= %s
AND e.direction='positive'
ORDER BY e.confidence DESC, e.value_usd DESC, e.detected_at::timestamp DESC
LIMIT %s
""",
(cutoff, int(limit or 20)),
).fetchall()
queued = []
skipped_ids = []
now = _now().isoformat(timespec="seconds")
cooldown_cutoff = (_now() - timedelta(hours=float(cooldown_hours or 6))).isoformat()
for row in rows:
event = dict(row)
symbol = normalize_symbol(event.get("symbol"))
if not symbol or not _tradable_symbol(symbol):
skipped_ids.append(event["id"])
continue
score = max(_safe_float(event.get("latest_onchain_score")), _safe_float(event.get("confidence")))
if score < float(min_score or 0) or int(event.get("confidence") or 0) < int(min_confidence or 0):
continue
recent = conn.execute(
"""
SELECT id FROM event_news
WHERE source='onchain' AND symbol=%s AND detected_at >= %s
LIMIT 1
""",
(symbol, cooldown_cutoff),
).fetchone()
if recent:
skipped_ids.append(event["id"])
continue
title = _candidate_title(event)
h = event_hash("onchain", title, symbol)
try:
conn.execute(
"""
INSERT INTO event_news
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
VALUES (%s, 'onchain', %s, %s, %s, %s, %s, %s, 'onchain_candidate', %s, 0)
""",
(
h,
symbol,
title,
event.get("url") or "",
event.get("detected_at") or now,
now,
event.get("severity") or "A",
json.dumps(
{
"onchain_event_id": event.get("id"),
"chain": event.get("chain"),
"signal_code": event.get("signal_code"),
"signal_label": event.get("signal_label"),
"confidence": event.get("confidence"),
"value_usd": event.get("value_usd"),
"onchain_score": event.get("latest_onchain_score"),
"risk_score": event.get("latest_risk_score"),
},
ensure_ascii=False,
),
),
)
conn.execute("UPDATE onchain_events SET status='candidate_queued' WHERE id=%s", (event.get("id"),))
queued.append(symbol)
except Exception:
skipped_ids.append(event["id"])
if skipped_ids:
conn.execute(
"UPDATE onchain_events SET status='candidate_skipped' WHERE id IN (" + ",".join(["%s"] * len(skipped_ids)) + ")",
tuple(skipped_ids),
)
conn.commit()
conn.close()
return {"queued": len(queued), "skipped": len(skipped_ids), "symbols": queued}
def run_once(limit=60):
started = _now()
init_db()
init_onchain_tables()
cfg = get_onchain_params()
output = {
"status": "disabled" if not cfg.get("enabled") else "processed",
"metrics_count": 0,
"events_count": 0,
"raw_events_count": 0,
"candidate_queued": 0,
"errors": [],
"check_time": _now().isoformat(),
}
if cfg.get("enabled"):
raw = fetch_dexscreener_raw_events(limit=limit)
output["raw_events_count"] = len(raw.get("raw_events") or [])
output["errors"].extend(raw.get("errors") or [])
dex = fetch_dexscreener_metrics(limit=limit)
output["metrics_count"] += len(dex.get("metrics") or [])
output["events_count"] += len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or [])
output["discovered_mappings"] = discover_token_mappings(limit=limit).get("inserted", 0) if not get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE) else 0
if output.get("discovered_mappings"):
output["status"] = "bootstrapped"
dex = fetch_dexscreener_metrics(limit=limit)
output["metrics_count"] = len(dex.get("metrics") or [])
output["events_count"] = len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or [])
queued = enqueue_onchain_candidates()
output["candidate_queued"] = queued.get("queued", 0)
output["candidate_symbols"] = queued.get("symbols", [])
if not output["metrics_count"] and not output["events_count"] and not output["raw_events_count"]:
output["status"] = "no_onchain_data"
log_cron_run(
job_name="链上",
script_name="onchain_monitor.py",
run_status="success" if not output["errors"] else "error",
result_status=output["status"],
started_at=started.isoformat(),
finished_at=_now().isoformat(),
duration_ms=int((_now() - started).total_seconds() * 1000),
summary={
"metrics_count": output["metrics_count"],
"events_count": output["events_count"],
"raw_events_count": output["raw_events_count"],
"candidate_queued": output["candidate_queued"],
"enabled": cfg.get("enabled"),
},
error_message="; ".join(output["errors"][:5]),
)
print(json.dumps(output, ensure_ascii=False, indent=2, default=str))
return output
__all__ = [
"POSITIVE_SIGNALS",
"RISK_SIGNALS",
"derive_dex_signals",
"enqueue_onchain_candidates",
"fetch_dexscreener_metrics",
"fetch_dexscreener_raw_events",
"get_onchain_params",
"ingest_normalized_events",
"normalize_dexscreener_pair",
"run_once",
]