1
This commit is contained in:
parent
fa7b82b982
commit
2f682cfd0d
@ -47,8 +47,14 @@ ALPHAX_ONCHAIN_TIMEOUT=15
|
||||
ALPHAX_NODEREAL_ENABLED=1
|
||||
ALPHAX_NODEREAL_CHAINS=ethereum,bsc
|
||||
ALPHAX_NODEREAL_API_KEY=
|
||||
# 可选:生产若 onchain_token_map 为空,可用 JSON 数组自举 NodeReal 合约映射。
|
||||
# 示例:[{"symbol":"STORJ/USDT","chain":"ethereum","contract_address":"0x...","confidence":95}]
|
||||
ALPHAX_ONCHAIN_TOKEN_MAPPINGS=
|
||||
ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK=120
|
||||
ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN=25
|
||||
ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED=1
|
||||
ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK=1
|
||||
ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN=30
|
||||
ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
|
||||
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
|
||||
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
- 运行时数据库是 PostgreSQL,compose 内置 `postgres:16` 服务。
|
||||
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。
|
||||
- 链上主数据源是 NodeReal;`.env` 中配置 `ALPHAX_NODEREAL_API_KEY` 后,`python -m app.cli onchain` 才会产出 NodeReal 链上事件。
|
||||
- 生产若出现 `nodereal_no_mappings`,说明 `onchain_token_map` 没有可用合约映射;可在配置中心 `system/onchain.token_mappings` 或 `.env` 的 `ALPHAX_ONCHAIN_TOKEN_MAPPINGS` 写入 `symbol/chain/contract_address` 种子。
|
||||
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
|
||||
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
|
||||
|
||||
|
||||
@ -77,8 +77,13 @@ def default_onchain_config(default_chains=("ethereum", "bsc")):
|
||||
"nodereal_enabled": _env_bool("ALPHAX_NODEREAL_ENABLED", True),
|
||||
"nodereal_chains": _env_list("ALPHAX_NODEREAL_CHAINS", ("ethereum", "bsc")),
|
||||
"nodereal_api_key_env": "ALPHAX_NODEREAL_API_KEY",
|
||||
"token_mappings_env": "ALPHAX_ONCHAIN_TOKEN_MAPPINGS",
|
||||
"token_mappings": [],
|
||||
"nodereal_log_block_lookback": _env_int("ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK", 120),
|
||||
"nodereal_max_logs_per_token": _env_int("ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN", 25),
|
||||
"nodereal_raw_transfer_enabled": _env_bool("ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED", True),
|
||||
"nodereal_raw_block_lookback": _env_int("ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK", 1),
|
||||
"nodereal_raw_max_logs_per_chain": _env_int("ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN", 30),
|
||||
"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),
|
||||
|
||||
@ -34,6 +34,7 @@ RAW_EVENT_TYPE_LABELS = {
|
||||
"token_profile_latest": "DEX 新币资料变更",
|
||||
"token_boost_latest": "DEX 付费曝光新增",
|
||||
"token_boost_top": "DEX 付费曝光榜",
|
||||
"evm_transfer": "EVM 原始转账",
|
||||
}
|
||||
|
||||
RAW_EVENT_EXPLAINERS = {
|
||||
@ -52,6 +53,11 @@ RAW_EVENT_EXPLAINERS = {
|
||||
"meaning": "代表平台内关注度较高,需要再看成交量、流动性和是否能映射交易对。",
|
||||
"priority": "medium",
|
||||
},
|
||||
"evm_transfer": {
|
||||
"plain": "NodeReal 捕捉到 EVM 链上的 ERC-20 Transfer 原始日志。",
|
||||
"meaning": "这代表链上确实有资金转移,但没有完成币种映射前,不能直接进入策略候选。",
|
||||
"priority": "medium",
|
||||
},
|
||||
}
|
||||
|
||||
POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "holder_growth", "smart_money_buying"}
|
||||
|
||||
@ -107,6 +107,7 @@ def get_onchain_params():
|
||||
etherscan_env = str(cfg.get("etherscan_api_key_env") or "ALPHAX_ETHERSCAN_API_KEY")
|
||||
helius_env = str(cfg.get("helius_api_key_env") or "ALPHAX_HELIUS_API_KEY")
|
||||
nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
|
||||
token_mappings_env = str(cfg.get("token_mappings_env") or "ALPHAX_ONCHAIN_TOKEN_MAPPINGS")
|
||||
etherscan_chains_raw = cfg.get("etherscan_chains") or ["ethereum"]
|
||||
if isinstance(etherscan_chains_raw, str):
|
||||
etherscan_chains = [x.strip().lower() for x in etherscan_chains_raw.split(",") if x.strip()]
|
||||
@ -121,8 +122,13 @@ def get_onchain_params():
|
||||
"nodereal_chains": _normalize_chain_list(cfg.get("nodereal_chains") or ("ethereum", "bsc")),
|
||||
"nodereal_api_key": os.getenv(nodereal_env, "").strip(),
|
||||
"nodereal_api_key_env": nodereal_env,
|
||||
"token_mappings": _load_token_mappings(cfg.get("token_mappings"), os.getenv(token_mappings_env, "")),
|
||||
"token_mappings_env": token_mappings_env,
|
||||
"nodereal_log_block_lookback": int(cfg.get("nodereal_log_block_lookback") or 120),
|
||||
"nodereal_max_logs_per_token": int(cfg.get("nodereal_max_logs_per_token") or 25),
|
||||
"nodereal_raw_transfer_enabled": bool(cfg.get("nodereal_raw_transfer_enabled", True)),
|
||||
"nodereal_raw_block_lookback": int(cfg.get("nodereal_raw_block_lookback") or 1),
|
||||
"nodereal_raw_max_logs_per_chain": int(cfg.get("nodereal_raw_max_logs_per_chain") or 30),
|
||||
"candidate_enabled": bool(cfg.get("candidate_enabled", True)),
|
||||
"candidate_min_score": float(cfg.get("candidate_min_score") or 70),
|
||||
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
|
||||
@ -152,6 +158,67 @@ def _normalize_chain_list(value):
|
||||
return [str(x).strip().lower() for x in (value or []) if str(x).strip()]
|
||||
|
||||
|
||||
def _load_token_mappings(config_value=None, env_value=""):
|
||||
items = []
|
||||
if isinstance(config_value, list):
|
||||
items.extend(config_value)
|
||||
if env_value:
|
||||
try:
|
||||
parsed = json.loads(env_value)
|
||||
if isinstance(parsed, list):
|
||||
items.extend(parsed)
|
||||
except Exception:
|
||||
for part in str(env_value or "").split(","):
|
||||
bits = [x.strip() for x in part.split(":")]
|
||||
if len(bits) >= 3:
|
||||
items.append({"symbol": bits[0], "chain": bits[1], "contract_address": bits[2]})
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
symbol = normalize_symbol(item.get("symbol"))
|
||||
chain = str(item.get("chain") or "").lower().strip()
|
||||
contract = str(item.get("contract_address") or item.get("address") or "").strip()
|
||||
if not symbol or not chain or not contract:
|
||||
continue
|
||||
key = (symbol, chain, contract.lower())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized.append({
|
||||
"symbol": symbol,
|
||||
"chain": chain,
|
||||
"contract_address": contract,
|
||||
"source": item.get("source") or "nodereal_seed",
|
||||
"confidence": int(item.get("confidence") or 95),
|
||||
"raw": item.get("raw") or {},
|
||||
})
|
||||
return normalized
|
||||
|
||||
|
||||
def seed_configured_token_mappings(cfg=None):
|
||||
cfg = cfg or get_onchain_params()
|
||||
seeded = []
|
||||
errors = []
|
||||
for item in cfg.get("token_mappings") or []:
|
||||
try:
|
||||
mapping_id = onchain_db.upsert_token_mapping(
|
||||
item["symbol"],
|
||||
item["chain"],
|
||||
item["contract_address"],
|
||||
source=item.get("source") or "nodereal_seed",
|
||||
confidence=item.get("confidence") or 95,
|
||||
raw=item.get("raw") or {},
|
||||
is_active=True,
|
||||
)
|
||||
if mapping_id:
|
||||
seeded.append(item)
|
||||
except Exception as exc:
|
||||
errors.append(f"{item.get('symbol')}:seed_mapping:{str(exc)[:160]}")
|
||||
return {"seeded": len(seeded), "items": seeded, "errors": errors}
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now()
|
||||
|
||||
@ -782,6 +849,37 @@ def _event_from_nodereal_transfer(log, mapping, cfg=None):
|
||||
}
|
||||
|
||||
|
||||
def _raw_event_from_nodereal_transfer(log, chain):
|
||||
topics = log.get("topics") or []
|
||||
if len(topics) < 3:
|
||||
return None
|
||||
contract = str(log.get("address") or "").strip()
|
||||
tx_hash = str(log.get("transactionHash") or "").strip()
|
||||
amount_raw = _hex_to_int(log.get("data"))
|
||||
if not contract or amount_raw <= 0:
|
||||
return None
|
||||
from_addr = _topic_to_address(topics[1])
|
||||
to_addr = _topic_to_address(topics[2])
|
||||
return {
|
||||
"source": "nodereal",
|
||||
"chain": str(chain or "").lower(),
|
||||
"event_type": "evm_transfer",
|
||||
"token_address": contract,
|
||||
"symbol_guess": "",
|
||||
"name": "",
|
||||
"title": "NodeReal ERC-20 原始转账",
|
||||
"description": f"合约 {_short_addr(contract)} · {_short_addr(from_addr)} -> {_short_addr(to_addr)}",
|
||||
"url": _chain_explorer_tx_url(chain, tx_hash),
|
||||
"amount": amount_raw,
|
||||
"total_amount": 0,
|
||||
"importance": min(100, max(1, len(str(amount_raw)) * 4)),
|
||||
"mapped_symbol": "",
|
||||
"mapping_status": "unmapped",
|
||||
"detected_at": _now().isoformat(timespec="seconds"),
|
||||
"raw": log,
|
||||
}
|
||||
|
||||
|
||||
def _metric_from_nodereal_holder_count(holder_count, mapping):
|
||||
symbol = normalize_symbol(mapping.get("symbol"))
|
||||
chain = str(mapping.get("chain") or "").lower()
|
||||
@ -831,7 +929,9 @@ def fetch_nodereal_events(limit=60):
|
||||
return {"metrics": [], "events": [], "errors": ["nodereal_disabled"]}
|
||||
if not cfg.get("nodereal_api_key"):
|
||||
return {"metrics": [], "events": [], "errors": ["nodereal_api_key_missing"]}
|
||||
seed_result = seed_configured_token_mappings(cfg)
|
||||
client = _nodereal_client(cfg)
|
||||
raw_result = fetch_nodereal_raw_events(client=client, cfg=cfg, limit=limit)
|
||||
enabled_chains = set(cfg.get("nodereal_chains") or DEFAULT_CHAINS)
|
||||
all_mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
|
||||
chain_mappings = [m for m in all_mappings if str(m.get("chain") or "").lower() in enabled_chains]
|
||||
@ -845,8 +945,9 @@ def fetch_nodereal_events(limit=60):
|
||||
unsupported_chains.add(chain)
|
||||
metrics = []
|
||||
events = []
|
||||
errors = []
|
||||
errors = list(seed_result.get("errors") or []) + list(raw_result.get("errors") or [])
|
||||
diagnostics = {
|
||||
"seeded_mappings": seed_result.get("seeded", 0),
|
||||
"mapping_total": len(all_mappings),
|
||||
"chain_mapping_total": len(chain_mappings),
|
||||
"supported_mapping_total": len(mappings),
|
||||
@ -895,12 +996,57 @@ def fetch_nodereal_events(limit=60):
|
||||
except Exception as exc:
|
||||
errors.append(f"{mapping.get('symbol')}:nodereal_logs:{str(exc)[:160]}")
|
||||
if not all_mappings:
|
||||
errors.append("nodereal_no_mappings")
|
||||
diagnostics["mapping_note"] = "no_strategy_mappings_raw_events_only"
|
||||
elif not chain_mappings:
|
||||
errors.append("nodereal_no_enabled_chain_mappings:" + json.dumps(diagnostics, ensure_ascii=False, sort_keys=True))
|
||||
diagnostics["mapping_note"] = "no_enabled_chain_mappings_raw_events_only"
|
||||
elif not mappings:
|
||||
errors.append("nodereal_no_supported_mappings:" + json.dumps(diagnostics, ensure_ascii=False, sort_keys=True))
|
||||
return {"metrics": metrics, "events": events, "errors": errors, "diagnostics": diagnostics}
|
||||
diagnostics["mapping_note"] = "no_supported_mappings_raw_events_only"
|
||||
return {
|
||||
"metrics": metrics,
|
||||
"events": events,
|
||||
"raw_events": raw_result.get("raw_events") or [],
|
||||
"errors": errors,
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
|
||||
def fetch_nodereal_raw_events(client=None, cfg=None, limit=60):
|
||||
cfg = cfg or get_onchain_params()
|
||||
if not cfg.get("nodereal_raw_transfer_enabled", True):
|
||||
return {"raw_events": [], "errors": []}
|
||||
client = client or _nodereal_client(cfg)
|
||||
chains = [c for c in (cfg.get("nodereal_chains") or DEFAULT_CHAINS) if client.supports_chain(c)]
|
||||
lookback = max(0, min(12, int(cfg.get("nodereal_raw_block_lookback") or 1)))
|
||||
per_chain = max(1, min(int(cfg.get("nodereal_raw_max_logs_per_chain") or 30), int(limit or 60)))
|
||||
inserted = []
|
||||
errors = []
|
||||
for chain in chains:
|
||||
try:
|
||||
latest = client.block_number(chain)
|
||||
if latest <= 0:
|
||||
continue
|
||||
logs = client.get_logs(
|
||||
chain,
|
||||
{
|
||||
"fromBlock": hex(max(0, latest - lookback)),
|
||||
"toBlock": hex(latest),
|
||||
"topics": [TRANSFER_TOPIC],
|
||||
},
|
||||
)
|
||||
raw_items = []
|
||||
for log in logs:
|
||||
if not isinstance(log, dict):
|
||||
continue
|
||||
item = _raw_event_from_nodereal_transfer(log, chain)
|
||||
if item:
|
||||
raw_items.append(item)
|
||||
raw_items.sort(key=lambda item: item.get("amount") or 0, reverse=True)
|
||||
for item in raw_items[:per_chain]:
|
||||
if insert_onchain_raw_event(item):
|
||||
inserted.append(item)
|
||||
except Exception as exc:
|
||||
errors.append(f"{chain}:nodereal_raw_logs:{str(exc)[:160]}")
|
||||
return {"raw_events": inserted, "errors": errors}
|
||||
|
||||
|
||||
def fetch_etherscan_events(limit=60):
|
||||
@ -1188,6 +1334,7 @@ def run_once(limit=60):
|
||||
node = fetch_nodereal_events(limit=limit)
|
||||
output["metrics_count"] += len(node.get("metrics") or [])
|
||||
output["events_count"] += len(node.get("events") or [])
|
||||
output["raw_events_count"] += len(node.get("raw_events") or [])
|
||||
output["errors"].extend(node.get("errors") or [])
|
||||
output["discovered_mappings"] = 0
|
||||
if output.get("discovered_mappings"):
|
||||
@ -1236,4 +1383,5 @@ __all__ = [
|
||||
"ingest_normalized_events",
|
||||
"normalize_dexscreener_pair",
|
||||
"run_once",
|
||||
"seed_configured_token_mappings",
|
||||
]
|
||||
|
||||
@ -320,9 +320,12 @@ def test_nodereal_events_generate_metrics_and_normalized_event(monkeypatch, tmp_
|
||||
return 1000
|
||||
|
||||
def get_logs(self, chain, log_filter):
|
||||
if "address" not in log_filter:
|
||||
return []
|
||||
assert log_filter["address"] == "0xabc"
|
||||
return [
|
||||
{
|
||||
"address": "0xabc",
|
||||
"transactionHash": "0xtx",
|
||||
"data": hex(200000 * 10**18),
|
||||
"topics": [
|
||||
@ -351,13 +354,102 @@ def test_nodereal_no_supported_mapping_error_has_diagnostics(monkeypatch, tmp_pa
|
||||
monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum,bsc")
|
||||
onchain_db.upsert_token_mapping("SOLX", "solana", "Mint111", source="manual", confidence=95)
|
||||
|
||||
class EmptyNodeRealClient:
|
||||
def supports_chain(self, chain):
|
||||
return chain in {"ethereum", "bsc"}
|
||||
|
||||
def block_number(self, chain):
|
||||
return 100
|
||||
|
||||
def get_logs(self, chain, log_filter):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: EmptyNodeRealClient())
|
||||
|
||||
result = onchain_monitor.fetch_nodereal_events(limit=10)
|
||||
|
||||
assert result["metrics"] == []
|
||||
assert result["events"] == []
|
||||
assert result["diagnostics"]["mapping_total"] == 1
|
||||
assert result["diagnostics"]["chain_mapping_total"] == 0
|
||||
assert result["errors"][0].startswith("nodereal_no_enabled_chain_mappings:")
|
||||
assert result["diagnostics"]["mapping_note"] == "no_enabled_chain_mappings_raw_events_only"
|
||||
assert result["errors"] == []
|
||||
|
||||
|
||||
def test_nodereal_records_raw_events_without_strategy_mappings(monkeypatch, tmp_path):
|
||||
_temp_db(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
|
||||
monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum")
|
||||
|
||||
class RawNodeRealClient:
|
||||
def supports_chain(self, chain):
|
||||
return chain == "ethereum"
|
||||
|
||||
def block_number(self, chain):
|
||||
return 1000
|
||||
|
||||
def get_logs(self, chain, log_filter):
|
||||
assert "address" not in log_filter
|
||||
return [
|
||||
{
|
||||
"address": "0xabc",
|
||||
"transactionHash": "0xrawtx",
|
||||
"data": hex(987654321),
|
||||
"topics": [
|
||||
onchain_monitor.TRANSFER_TOPIC,
|
||||
"0x0000000000000000000000001111111111111111111111111111111111111111",
|
||||
"0x0000000000000000000000002222222222222222222222222222222222222222",
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: RawNodeRealClient())
|
||||
|
||||
result = onchain_monitor.fetch_nodereal_events(limit=10)
|
||||
|
||||
assert result["errors"] == []
|
||||
assert result["events"] == []
|
||||
assert len(result["raw_events"]) == 1
|
||||
assert result["diagnostics"]["mapping_note"] == "no_strategy_mappings_raw_events_only"
|
||||
raw = onchain_db.list_onchain_raw_events(hours=50000)
|
||||
assert raw["total"] == 1
|
||||
assert raw["items"][0]["source"] == "nodereal"
|
||||
assert raw["items"][0]["mapping_status"] == "unmapped"
|
||||
assert raw["items"][0]["event_type"] == "evm_transfer"
|
||||
|
||||
|
||||
def test_nodereal_seeds_configured_token_mappings_from_env(monkeypatch, tmp_path):
|
||||
_temp_db(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
|
||||
monkeypatch.setenv(
|
||||
"ALPHAX_ONCHAIN_TOKEN_MAPPINGS",
|
||||
'[{"symbol":"ENVX/USDT","chain":"ethereum","contract_address":"0xabc","confidence":96}]',
|
||||
)
|
||||
|
||||
class EmptyNodeRealClient:
|
||||
def supports_chain(self, chain):
|
||||
return chain == "ethereum"
|
||||
|
||||
def token_holder_count(self, chain, contract):
|
||||
return 0
|
||||
|
||||
def block_number(self, chain):
|
||||
return 100
|
||||
|
||||
def get_logs(self, chain, log_filter):
|
||||
if "address" not in log_filter:
|
||||
return []
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: EmptyNodeRealClient())
|
||||
|
||||
result = onchain_monitor.fetch_nodereal_events(limit=10)
|
||||
|
||||
assert result["errors"] == []
|
||||
assert result["diagnostics"]["seeded_mappings"] == 1
|
||||
mappings = onchain_db.get_token_mappings("ENVX/USDT")
|
||||
assert len(mappings) == 1
|
||||
assert mappings[0]["contract_address"] == "0xabc"
|
||||
|
||||
|
||||
def test_legacy_helius_is_disabled_by_default(monkeypatch, tmp_path):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user