This commit is contained in:
aaron 2026-05-21 20:06:48 +08:00
parent a22fb1e775
commit cabd96a875
8 changed files with 32 additions and 884 deletions

View File

@ -61,17 +61,7 @@ ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70 ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70 ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6 ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
ALPHAX_ONCHAIN_DEXSCREENER_ENABLED=0
ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT=80
ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD=100000
ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD=100000
ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT=25
ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT=-25
ALPHAX_ONCHAIN_WHALE_TX_USD=250000 ALPHAX_ONCHAIN_WHALE_TX_USD=250000
ALPHAX_ETHERSCAN_ENABLED=0
ALPHAX_ETHERSCAN_API_KEY=
ALPHAX_HELIUS_ENABLED=0
ALPHAX_HELIUS_API_KEY=
ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0 ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0
ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK= ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK=

View File

@ -102,7 +102,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- 当前链上主数据源是 NodeReal入口在 `app/services/nodereal_client.py``app/services/onchain_monitor.py` - 当前链上主数据源是 NodeReal入口在 `app/services/nodereal_client.py``app/services/onchain_monitor.py`
- 默认只跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`,并通过 `ALPHAX_NODEREAL_API_KEY` 访问 EVM JSON-RPC / Enhanced API。 - 默认只跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`,并通过 `ALPHAX_NODEREAL_API_KEY` 访问 EVM JSON-RPC / Enhanced API。
- DEX Screener、Etherscan、Helius 已从默认链路关闭,只保留历史兼容函数和旧数据展示,不应再作为新增链上逻辑的主入口。 - DEX Screener、Etherscan、Helius 已从运行时代码链路移除;当前只做 Ethereum / BSC 的 NodeReal 采集。
- NodeReal 原始 Transfer 会先记录到 `onchain_raw_events`,再通过 ERC-20 `symbol/name/decimals` 自动尝试映射到交易所 `XXX/USDT`,人工 `ALPHAX_ONCHAIN_TOKEN_MAPPINGS` 只作为兜底。
- 新增链上信号优先落到 `onchain_token_metrics` / `onchain_events`,不要直接创建推荐;高质量事件仍通过 `event_news` 进入技术检查。 - 新增链上信号优先落到 `onchain_token_metrics` / `onchain_events`,不要直接创建推荐;高质量事件仍通过 `event_news` 进入技术检查。
### 4.2 Web/API ### 4.2 Web/API

View File

@ -7,8 +7,8 @@
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190` - Web 默认暴露到宿主机 `8191`,容器内端口 `8190`
- 运行时数据库是 PostgreSQLcompose 内置 `postgres:16` 服务。 - 运行时数据库是 PostgreSQLcompose 内置 `postgres:16` 服务。
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。 - `DATABASE_URL` 是应用唯一运行时数据库连接入口。
- 链上主数据源是 NodeReal`.env` 中配置 `ALPHAX_NODEREAL_API_KEY` 后,`python -m app.cli onchain` 才会产出 NodeReal 链上事件。 - 链上主数据源是 NodeReal,只采集 Ethereum / BSC`.env` 中配置 `ALPHAX_NODEREAL_API_KEY` 后,`python -m app.cli onchain` 才会产出链上事件。
- 生产若出现 `nodereal_no_mappings`,说明 `onchain_token_map` 没有可用合约映射;可在配置中心 `system/onchain.token_mappings``.env``ALPHAX_ONCHAIN_TOKEN_MAPPINGS` 写入 `symbol/chain/contract_address` 种子 - NodeReal 原始 Transfer 会先进入 `onchain_raw_events` 展示,再自动读取 ERC-20 metadata 尝试映射交易所 `XXX/USDT``ALPHAX_ONCHAIN_TOKEN_MAPPINGS` 只作为人工兜底
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。 - 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。 - `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。

View File

@ -90,22 +90,7 @@ def default_onchain_config(default_chains=("ethereum", "bsc")):
"candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70), "candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
"candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70), "candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),
"candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6), "candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6),
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", False),
"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),
"dex_min_hour_volume_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_HOUR_VOLUME_USD", 50000),
"dex_hour_volume_share_pct": _env_float("ALPHAX_ONCHAIN_DEX_HOUR_VOLUME_SHARE_PCT", 8),
"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), "whale_tx_usd": _env_float("ALPHAX_ONCHAIN_WHALE_TX_USD", 250000),
"etherscan_enabled": _env_bool("ALPHAX_ETHERSCAN_ENABLED", False),
"etherscan_chains": _env_list("ALPHAX_ETHERSCAN_CHAINS", ("ethereum",)),
"helius_enabled": _env_bool("ALPHAX_HELIUS_ENABLED", False),
"etherscan_base_url": _env_str("ALPHAX_ETHERSCAN_BASE_URL", "https://api.etherscan.io/v2/api"),
"helius_base_url": _env_str("ALPHAX_HELIUS_BASE_URL", "https://api.helius.xyz"),
"etherscan_api_key_env": "ALPHAX_ETHERSCAN_API_KEY",
"helius_api_key_env": "ALPHAX_HELIUS_API_KEY",
} }

View File

@ -31,28 +31,10 @@ SIGNAL_LABELS = {
} }
RAW_EVENT_TYPE_LABELS = { RAW_EVENT_TYPE_LABELS = {
"token_profile_latest": "DEX 新币资料变更",
"token_boost_latest": "DEX 付费曝光新增",
"token_boost_top": "DEX 付费曝光榜",
"evm_transfer": "EVM 原始转账", "evm_transfer": "EVM 原始转账",
} }
RAW_EVENT_EXPLAINERS = { RAW_EVENT_EXPLAINERS = {
"token_profile_latest": {
"plain": "项目方或社区刚在 DEX Screener 更新了代币资料、图标或链接。",
"meaning": "只代表曝光资料发生变化,信号较弱,通常不能单独说明有资金买入。",
"priority": "low",
},
"token_boost_latest": {
"plain": "有人为这个代币购买了 DEX Screener 曝光位。",
"meaning": "代表短期推广热度上升,可能吸引散户注意,但也可能只是营销。",
"priority": "medium",
},
"token_boost_top": {
"plain": "这个代币出现在 DEX Screener 付费曝光榜前列。",
"meaning": "代表平台内关注度较高,需要再看成交量、流动性和是否能映射交易对。",
"priority": "medium",
},
"evm_transfer": { "evm_transfer": {
"plain": "NodeReal 捕捉到 EVM 链上的 ERC-20 Transfer 原始日志。", "plain": "NodeReal 捕捉到 EVM 链上的 ERC-20 Transfer 原始日志。",
"meaning": "这代表链上确实有资金转移,但没有完成币种映射前,不能直接进入策略候选。", "meaning": "这代表链上确实有资金转移,但没有完成币种映射前,不能直接进入策略候选。",
@ -476,11 +458,6 @@ def get_onchain_provider_status(hours=24):
hours = int(hours or 24) hours = int(hours or 24)
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat() cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY") nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
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")
etherscan_chains = cfg.get("etherscan_chains") or ["ethereum"]
if isinstance(etherscan_chains, str):
etherscan_chains = [x.strip().lower() for x in etherscan_chains.split(",") if x.strip()]
conn = get_conn() conn = get_conn()
try: try:
raw_total = conn.execute("SELECT COUNT(*) FROM onchain_raw_events WHERE detected_at >= %s", (cutoff,)).fetchone()[0] raw_total = conn.execute("SELECT COUNT(*) FROM onchain_raw_events WHERE detected_at >= %s", (cutoff,)).fetchone()[0]
@ -544,72 +521,25 @@ def get_onchain_provider_status(hours=24):
summary = _load(last_onchain.get("summary_json") if last_onchain else "{}", {}) if last_onchain else {} summary = _load(last_onchain.get("summary_json") if last_onchain else "{}", {}) if last_onchain else {}
last_error = last_onchain.get("error_message") if last_onchain else "" last_error = last_onchain.get("error_message") if last_onchain else ""
provider = str(cfg.get("provider") or "nodereal").strip().lower() provider = str(cfg.get("provider") or "nodereal").strip().lower()
dexscreener_enabled = bool(cfg.get("dexscreener_enabled", False)) and provider != "nodereal" nodereal_enabled = bool(cfg.get("nodereal_enabled", True)) and provider == "nodereal"
etherscan_enabled = bool(cfg.get("etherscan_enabled", False)) and provider != "nodereal" nodereal_metrics = int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal"))
helius_enabled = bool(cfg.get("helius_enabled", False)) and provider != "nodereal" nodereal_signals = int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal"))
providers = [ providers = [
{ {
"provider": "nodereal", "provider": "nodereal",
"label": "NodeReal", "label": "NodeReal",
"enabled": bool(cfg.get("nodereal_enabled", True)) and provider == "nodereal", "enabled": nodereal_enabled,
"api_key_present": bool(os.getenv(nodereal_env, "").strip()), "api_key_present": bool(os.getenv(nodereal_env, "").strip()),
"implemented": True, "implemented": True,
"role": "EVM 主链上数据源Transfer 日志、大额转账、holder 变化", "role": "EVM 主链上数据源Transfer 日志、大额转账、holder 变化",
"raw_events": 0,
"metrics": int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal")),
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal")),
"status": _provider_status_label(
bool(cfg.get("nodereal_enabled", True)),
True,
int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal"))
+ int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal")),
last_error if "nodereal" in str(last_error).lower() else "",
),
},
{
"provider": "dexscreener",
"label": "DEX Screener",
"enabled": dexscreener_enabled,
"api_key_present": True,
"implemented": True,
"role": "低优先级曝光源Token 资料、付费推广、已映射合约的 DEX 成交量与流动性",
"raw_events": int(raw_total or 0), "raw_events": int(raw_total or 0),
"metrics": int(metric_total or 0), "metrics": nodereal_metrics,
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "dexscreener")), "signals": nodereal_signals,
"status": _provider_status_label(dexscreener_enabled, True, raw_total + metric_total, last_error),
},
{
"provider": "etherscan",
"label": "Etherscan",
"enabled": etherscan_enabled,
"api_key_present": bool(os.getenv(etherscan_env, "").strip()),
"implemented": True,
"role": "EVM 已映射合约的 ERC20 大额转账,当前链: " + ", ".join(etherscan_chains or ["ethereum"]),
"raw_events": 0,
"metrics": 0,
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
"status": _provider_status_label( "status": _provider_status_label(
etherscan_enabled, nodereal_enabled,
True, True,
int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")), int(raw_total or 0) + nodereal_metrics + nodereal_signals,
last_error if "etherscan" in str(last_error).lower() else "", last_error if "nodereal" in str(last_error).lower() else "",
),
},
{
"provider": "helius",
"label": "Helius",
"enabled": helius_enabled,
"api_key_present": bool(os.getenv(helius_env, "").strip()),
"implemented": True,
"role": "Solana 已映射 mint 的解析交易与大额 token 活动",
"raw_events": 0,
"metrics": 0,
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
"status": _provider_status_label(
helius_enabled,
True,
int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
last_error if "helius" in str(last_error).lower() else "",
), ),
}, },
] ]

View File

@ -8,9 +8,6 @@ but it never creates recommendations or changes recommendation state directly.
import json import json
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import urlencode
import requests
from app.config.system_config import onchain_config from app.config.system_config import onchain_config
from app.db import onchain_db from app.db import onchain_db
@ -36,16 +33,6 @@ from app.services.nodereal_client import DEFAULT_CHAIN_ENDPOINTS, NodeRealClient
DEFAULT_CHAINS = ("ethereum", "bsc") DEFAULT_CHAINS = ("ethereum", "bsc")
ETHERSCAN_CHAIN_IDS = {
"ethereum": "1",
"bsc": "56",
"base": "8453",
"arbitrum": "42161",
}
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 = { NON_TARGET_NATIVE_BASES = {
"AVAX", "FIL", "SUI", "APT", "DOT", "ADA", "XRP", "LTC", "BCH", "ATOM", "NEAR", "AVAX", "FIL", "SUI", "APT", "DOT", "ADA", "XRP", "LTC", "BCH", "ATOM", "NEAR",
"SEI", "INJ", "TON", "ETC", "ICP", "HBAR", "ALGO", "VET", "TRX", "XLM", "KAS", "SEI", "INJ", "TON", "ETC", "ICP", "HBAR", "ALGO", "VET", "TRX", "XLM", "KAS",
@ -55,23 +42,6 @@ BRIDGED_TOKEN_MARKERS = (
"wrapped", "wormhole", "portal", "bridged", "bridge", "axelar", "allbridge", "wrapped", "wormhole", "portal", "bridged", "bridge", "axelar", "allbridge",
"binance-peg", "multichain", "layerzero", "lz", "wavax", "wfil", "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"),
)
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
ERC20_SYMBOL_SELECTOR = "0x95d89b41" ERC20_SYMBOL_SELECTOR = "0x95d89b41"
ERC20_NAME_SELECTOR = "0x06fdde03" ERC20_NAME_SELECTOR = "0x06fdde03"
@ -107,15 +77,8 @@ def get_onchain_params():
chains = [x.strip().lower() for x in chains_raw.split(",") if x.strip()] chains = [x.strip().lower() for x in chains_raw.split(",") if x.strip()]
else: else:
chains = [str(x).strip().lower() for x in chains_raw if str(x).strip()] chains = [str(x).strip().lower() for x in chains_raw if str(x).strip()]
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") 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") 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()]
else:
etherscan_chains = [str(x).strip().lower() for x in etherscan_chains_raw if str(x).strip()]
return { return {
"enabled": bool(cfg.get("enabled", False)), "enabled": bool(cfg.get("enabled", False)),
"provider": str(cfg.get("provider") or "nodereal").strip().lower(), "provider": str(cfg.get("provider") or "nodereal").strip().lower(),
@ -138,22 +101,7 @@ def get_onchain_params():
"candidate_min_score": float(cfg.get("candidate_min_score") or 70), "candidate_min_score": float(cfg.get("candidate_min_score") or 70),
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70), "candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
"candidate_cooldown_hours": float(cfg.get("candidate_cooldown_hours") or 6), "candidate_cooldown_hours": float(cfg.get("candidate_cooldown_hours") or 6),
"dexscreener_enabled": bool(cfg.get("dexscreener_enabled", True)),
"etherscan_enabled": bool(cfg.get("etherscan_enabled", True)),
"etherscan_chains": etherscan_chains or ["ethereum"],
"helius_enabled": bool(cfg.get("helius_enabled", True)),
"dex_volume_spike_pct": float(cfg.get("dex_volume_spike_pct") or 80),
"dex_min_liquidity_usd": float(cfg.get("dex_min_liquidity_usd") or 100000),
"dex_min_volume_24h_usd": float(cfg.get("dex_min_volume_24h_usd") or 100000),
"liquidity_add_pct": float(cfg.get("liquidity_add_pct") or 25),
"liquidity_remove_pct": float(cfg.get("liquidity_remove_pct") or -25),
"dex_hour_volume_share_pct": float(cfg.get("dex_hour_volume_share_pct") or 8),
"dex_min_hour_volume_usd": float(cfg.get("dex_min_hour_volume_usd") or 50000),
"whale_tx_usd": float(cfg.get("whale_tx_usd") or 250000), "whale_tx_usd": float(cfg.get("whale_tx_usd") or 250000),
"etherscan_base_url": str(cfg.get("etherscan_base_url") or "https://api.etherscan.io/v2/api").strip(),
"helius_base_url": str(cfg.get("helius_base_url") or "https://api.helius.xyz").strip().rstrip("/"),
"etherscan_api_key": os.getenv(etherscan_env, "").strip(),
"helius_api_key": os.getenv(helius_env, "").strip(),
} }
@ -228,13 +176,6 @@ def _now():
return datetime.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): def _safe_float(value, default=0.0):
try: try:
return float(value or 0) return float(value or 0)
@ -242,14 +183,6 @@ def _safe_float(value, default=0.0):
return default 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 _safe_int(value, default=0): def _safe_int(value, default=0):
try: try:
return int(float(value or 0)) return int(float(value or 0))
@ -257,11 +190,6 @@ def _safe_int(value, default=0):
return default return default
def _chain_alias(value):
key = str(value or "").lower()
return DEX_CHAIN_ALIASES.get(key, key)
def _chain_explorer_tx_url(chain, tx_hash): def _chain_explorer_tx_url(chain, tx_hash):
tx_hash = str(tx_hash or "").strip() tx_hash = str(tx_hash or "").strip()
if not tx_hash: if not tx_hash:
@ -293,308 +221,7 @@ def _latest_metric(symbol, chain, contract_address):
return dict(row) if row else None return dict(row) if row else None
def _event_amount(item): def _event_from_metric(metric, signal_code, source="nodereal"):
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 = "DEX 新币资料变更"
elif event_type == "token_boost_latest":
title = "DEX 付费曝光新增"
elif event_type == "token_boost_top":
title = "DEX 付费曝光榜"
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"))
volume_1h = _safe_float(metric.get("dex_volume_1h_usd"))
volume_24h = _safe_float(metric.get("dex_volume_usd"))
hour_share_pct = (volume_1h / volume_24h * 100) if volume_24h > 0 else 0
if vol_change >= cfg.get("dex_volume_spike_pct", 80):
signals.append("dex_volume_spike")
elif (
volume_1h >= cfg.get("dex_min_hour_volume_usd", 50000)
and hour_share_pct >= cfg.get("dex_hour_volume_share_pct", 8)
):
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) direction = signal_direction(signal_code)
severity = "RISK" if direction == "risk" else "A" if _safe_float(metric.get("onchain_score")) >= 75 else "B" severity = "RISK" if direction == "risk" else "A" if _safe_float(metric.get("onchain_score")) >= 75 else "B"
return { return {
@ -615,143 +242,6 @@ def _event_from_metric(metric, signal_code, source="dexscreener"):
} }
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_map = pair.get("volume") or {}
volume = _safe_float(volume_map.get("h24"))
volume_1h = _safe_float(volume_map.get("h1"))
volume_5m = _safe_float(volume_map.get("m5"))
volume_6h = _safe_float(volume_map.get("h6"))
txns_map = pair.get("txns") or {}
txns_h1 = txns_map.get("h1") or {}
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_1h_usd": volume_1h,
"dex_volume_5m_usd": volume_5m,
"dex_volume_6h_usd": volume_6h,
"dex_volume_1h_share_pct": round(volume_1h / volume * 100, 2) if volume > 0 else 0,
"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 {},
"derived": {
"dex_volume_1h_usd": volume_1h,
"dex_volume_5m_usd": volume_5m,
"dex_volume_6h_usd": volume_6h,
"dex_volume_1h_share_pct": round(volume_1h / volume * 100, 2) if volume > 0 else 0,
"h1_buys": int(txns_h1.get("buys") or 0),
"h1_sells": int(txns_h1.get("sells") or 0),
},
},
}
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 _event_from_etherscan_transfer(row, mapping, cfg=None):
cfg = cfg or get_onchain_params()
decimals = _safe_int(row.get("tokenDecimal"), 18)
amount = _safe_float(row.get("value")) / (10 ** decimals if decimals >= 0 else 1)
price_usd = _latest_price_from_metric(mapping)
value_usd = amount * price_usd if price_usd > 0 else 0
threshold = _safe_float(cfg.get("whale_tx_usd"), 250000)
if value_usd > 0 and value_usd < threshold:
return None
if value_usd <= 0 and amount <= 0:
return None
tx_hash = str(row.get("hash") or "").strip()
chain = str(mapping.get("chain") or "").lower()
return {
"chain": chain,
"symbol": mapping.get("symbol"),
"contract_address": mapping.get("contract_address") or "",
"event_type": "token_transfer",
"signal_code": "whale_accumulation" if value_usd >= threshold else "large_token_transfer",
"signal_label": signal_label("whale_accumulation" if value_usd >= threshold else "large_token_transfer"),
"direction": "positive" if value_usd >= threshold else "neutral",
"value_usd": value_usd,
"amount": amount,
"tx_hash": tx_hash,
"wallet_address": row.get("to") or "",
"wallet_label": "EVM 接收地址",
"counterparty_label": "EVM 发送地址 " + _short_addr(row.get("from") or ""),
"confidence": 74 if value_usd >= threshold else 58,
"severity": "A" if value_usd >= threshold else "B",
"detected_at": _ts_to_iso(row.get("timeStamp")),
"source": "etherscan",
"url": _chain_explorer_tx_url(chain, tx_hash),
"raw": row,
}
def _latest_price_from_metric(mapping): def _latest_price_from_metric(mapping):
symbol = normalize_symbol(mapping.get("symbol")) symbol = normalize_symbol(mapping.get("symbol"))
chain = str(mapping.get("chain") or "").lower() chain = str(mapping.get("chain") or "").lower()
@ -1179,141 +669,6 @@ def fetch_nodereal_raw_events(client=None, cfg=None, limit=60):
return {"raw_events": inserted, "errors": errors} return {"raw_events": inserted, "errors": errors}
def fetch_etherscan_events(limit=60):
cfg = get_onchain_params()
if not cfg.get("etherscan_enabled", True):
return {"events": [], "errors": ["etherscan_disabled"]}
api_key = cfg.get("etherscan_api_key") or ""
if not api_key:
return {"events": [], "errors": ["etherscan_api_key_missing"]}
allowed_chains = set(cfg.get("etherscan_chains") or ["ethereum"])
mappings = [
m for m in get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
if m.get("chain") in ETHERSCAN_CHAIN_IDS and m.get("chain") in allowed_chains
]
events = []
errors = []
for mapping in mappings[: int(limit or 60)]:
chain = str(mapping.get("chain") or "").lower()
contract = str(mapping.get("contract_address") or "").strip()
if not contract:
continue
params = {
"chainid": ETHERSCAN_CHAIN_IDS[chain],
"module": "account",
"action": "tokentx",
"contractaddress": contract,
"page": 1,
"offset": 25,
"sort": "desc",
"apikey": api_key,
}
try:
data = _request_json(cfg.get("etherscan_base_url"), params=params, timeout=cfg.get("timeout", 15))
status = str(data.get("status") or "")
message = str(data.get("message") or "")
rows = data.get("result") or []
if status == "0" and not isinstance(rows, list):
if "No transactions found" not in str(rows) and "No records" not in str(rows):
errors.append(f"{mapping.get('symbol')}:etherscan_{message}:{str(rows)[:100]}")
continue
if not isinstance(rows, list):
continue
for row in rows:
if not isinstance(row, dict):
continue
event = _event_from_etherscan_transfer(row, mapping, cfg=cfg)
if not event:
continue
if insert_onchain_event(event):
events.append(event)
except Exception as exc:
errors.append(f"{mapping.get('symbol')}:etherscan:{str(exc)[:160]}")
return {"events": events, "errors": errors}
def _event_from_helius_tx(tx, mapping, cfg=None):
cfg = cfg or get_onchain_params()
mint = str(mapping.get("contract_address") or "")
symbol = normalize_symbol(mapping.get("symbol"))
signature = str(tx.get("signature") or "")
token_transfers = tx.get("tokenTransfers") or []
native_transfers = tx.get("nativeTransfers") or []
matched = [t for t in token_transfers if str(t.get("mint") or "") == mint]
if not matched:
return None
amount = max(_safe_float(t.get("tokenAmount")) for t in matched)
price_usd = _latest_price_from_metric(mapping)
value_usd = amount * price_usd if price_usd > 0 else 0
sol_amount = max([_safe_float(t.get("amount")) / 1_000_000_000 for t in native_transfers] or [0])
threshold = _safe_float(cfg.get("whale_tx_usd"), 250000)
if value_usd > 0 and value_usd < threshold and sol_amount < 100:
return None
signal = "whale_accumulation" if value_usd >= threshold or sol_amount >= 100 else "large_token_transfer"
return {
"chain": "solana",
"symbol": symbol,
"contract_address": mint,
"event_type": "solana_token_activity",
"signal_code": signal,
"signal_label": signal_label(signal),
"direction": "positive" if signal == "whale_accumulation" else "neutral",
"value_usd": value_usd,
"amount": amount,
"tx_hash": signature,
"wallet_address": (matched[0].get("toUserAccount") or matched[0].get("userAccount") or ""),
"wallet_label": "Solana 接收地址",
"counterparty_label": "Solana 发送地址 " + _short_addr(matched[0].get("fromUserAccount") or ""),
"confidence": 74 if signal == "whale_accumulation" else 58,
"severity": "A" if signal == "whale_accumulation" else "B",
"detected_at": _ts_to_iso(tx.get("timestamp")),
"source": "helius",
"url": _chain_explorer_tx_url("solana", signature),
"raw": tx,
}
def fetch_helius_events(limit=60):
cfg = get_onchain_params()
if not cfg.get("helius_enabled", True):
return {"events": [], "errors": ["helius_disabled"]}
api_key = cfg.get("helius_api_key") or ""
if not api_key:
return {"events": [], "errors": ["helius_api_key_missing"]}
mappings = [m for m in get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE) if m.get("chain") == "solana"]
events = []
errors = []
for mapping in mappings[: int(limit or 60)]:
mint = str(mapping.get("contract_address") or "").strip()
if not mint:
continue
query = urlencode({"api-key": api_key, "limit": 25})
url = f"{cfg.get('helius_base_url')}/v0/addresses/{mint}/transactions?{query}"
try:
data = _request_json(url, timeout=cfg.get("timeout", 15))
rows = data if isinstance(data, list) else data.get("transactions") or []
for tx in rows:
if not isinstance(tx, dict):
continue
event = _event_from_helius_tx(tx, mapping, cfg=cfg)
if not event:
continue
if insert_onchain_event(event):
events.append(event)
except Exception as exc:
errors.append(f"{mapping.get('symbol')}:helius:{str(exc)[:160]}")
return {"events": events, "errors": errors}
def _ts_to_iso(value):
try:
if value:
return datetime.fromtimestamp(float(value)).isoformat(timespec="seconds")
except Exception:
pass
return _now().isoformat(timespec="seconds")
def _short_addr(value): def _short_addr(value):
value = str(value or "") value = str(value or "")
if len(value) <= 12: if len(value) <= 12:
@ -1502,16 +857,10 @@ def run_once(limit=60):
__all__ = [ __all__ = [
"POSITIVE_SIGNALS", "POSITIVE_SIGNALS",
"RISK_SIGNALS", "RISK_SIGNALS",
"derive_dex_signals",
"enqueue_onchain_candidates", "enqueue_onchain_candidates",
"fetch_dexscreener_metrics",
"fetch_dexscreener_raw_events",
"fetch_etherscan_events",
"fetch_helius_events",
"fetch_nodereal_events", "fetch_nodereal_events",
"get_onchain_params", "get_onchain_params",
"ingest_normalized_events", "ingest_normalized_events",
"normalize_dexscreener_pair",
"run_once", "run_once",
"seed_configured_token_mappings", "seed_configured_token_mappings",
] ]

View File

@ -36,66 +36,12 @@ def test_mapping_requires_confidence_and_preserves_multi_chain(monkeypatch, tmp_
assert usable[0]["contract_address"] == "0xaaa" assert usable[0]["contract_address"] == "0xaaa"
def test_dex_signal_codes_from_metric(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
cfg = {
"dex_volume_spike_pct": 80,
"dex_min_hour_volume_usd": 50000,
"dex_hour_volume_share_pct": 8,
"liquidity_add_pct": 25,
"liquidity_remove_pct": -25,
}
assert onchain_monitor.derive_dex_signals({"dex_volume_change_pct": 120, "liquidity_change_pct": 40}, cfg) == [
"dex_volume_spike",
"liquidity_add",
]
assert onchain_monitor.derive_dex_signals({"dex_volume_change_pct": 10, "liquidity_change_pct": -35}, cfg) == [
"liquidity_remove_risk"
]
assert onchain_monitor.derive_dex_signals(
{"dex_volume_usd": 600000, "dex_volume_1h_usd": 80000, "dex_volume_change_pct": 0, "liquidity_change_pct": 0},
cfg,
) == ["dex_volume_spike"]
def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path): def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path) _temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana") assert onchain_monitor._is_auto_mapping_symbol_allowed("STORJ", "Storj") is True
cfg = onchain_monitor.get_onchain_params() assert onchain_monitor._is_auto_mapping_symbol_allowed("AVAX", "Avalanche") is False
chains = set(cfg.get("chains") or []) assert onchain_monitor._is_auto_mapping_symbol_allowed("FIL", "Wrapped Filecoin") is False
assert onchain_monitor._is_auto_mapping_symbol_allowed("USDT", "Tether USD") is False
avax_pair = {
"chainId": "solana",
"baseToken": {"symbol": "AVAX", "name": "Avalanche"},
"quoteToken": {"symbol": "USDC"},
"liquidity": {"usd": 500000},
"volume": {"h24": 1000000},
"url": "https://example.com",
}
wrapped_pair = {
"chainId": "ethereum",
"baseToken": {"symbol": "FIL", "name": "Wrapped Filecoin"},
"quoteToken": {"symbol": "USDT"},
"liquidity": {"usd": 500000},
"volume": {"h24": 1000000},
"url": "https://example.com/wrapped-filecoin",
}
assert onchain_monitor._pair_rejection_reason(avax_pair, "AVAX/USDT", chains) == "native_chain_not_in_scope"
assert onchain_monitor._pair_rejection_reason(wrapped_pair, "FIL/USDT", chains) == "native_chain_not_in_scope"
assert onchain_monitor._pair_rejection_reason(
{
"chainId": "solana",
"baseToken": {"symbol": "UNKNOWN", "name": "Unknown"},
"quoteToken": {"symbol": "USDC"},
"liquidity": {"usd": 500000},
"volume": {"h24": 1000000},
"url": "https://example.com",
},
"UNKNOWN/USDT",
chains,
) == "solana_not_allowlisted"
def test_onchain_candidate_enqueues_event_news_not_recommendation(monkeypatch, tmp_path): def test_onchain_candidate_enqueues_event_news_not_recommendation(monkeypatch, tmp_path):
@ -210,58 +156,16 @@ def test_onchain_api_and_page(monkeypatch, tmp_path):
assert provider_status.json()["coverage"]["metrics"] == 1 assert provider_status.json()["coverage"]["metrics"] == 1
def test_raw_dexscreener_events_store_without_mapping(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "1")
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
monkeypatch.setenv("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", "1")
def fake_request(url, params=None, timeout=15):
if "token-profiles" in url:
return [
{
"chainId": "ethereum",
"tokenAddress": "0xraw",
"url": "https://dexscreener.com/ethereum/0xraw",
"description": "Unmapped token started trending",
"icon": "https://example.com/icon.png",
}
]
if "token-boosts/latest" in url:
return [
{
"chainId": "solana",
"tokenAddress": "So111",
"url": "https://dexscreener.com/solana/So111",
"amount": 25,
"totalAmount": 100,
}
]
if "token-boosts/top" in url:
return []
return {"pairs": []}
monkeypatch.setattr(onchain_monitor, "_request_json", fake_request)
result = onchain_monitor.fetch_dexscreener_raw_events(limit=10)
assert len(result["raw_events"]) == 2
raw = onchain_db.list_onchain_raw_events(hours=24)
assert raw["total"] == 2
assert raw["items"][0]["mapping_status"] == "unmapped"
assert raw["items"][0]["event_label"]
def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path): def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path) _temp_db(monkeypatch, tmp_path)
onchain_db.upsert_token_mapping("ABC", "base", "0xabc", source="manual", confidence=95) onchain_db.upsert_token_mapping("ABC", "ethereum", "0xabc", source="manual", confidence=95)
onchain_db.insert_onchain_raw_event( onchain_db.insert_onchain_raw_event(
{ {
"source": "dexscreener", "source": "nodereal",
"chain": "base", "chain": "ethereum",
"event_type": "token_boost_top", "event_type": "evm_transfer",
"token_address": "0xabc", "token_address": "0xabc",
"title": "DEX Boost 榜单", "title": "NodeReal ERC-20 原始转账",
"amount": 10, "amount": 10,
"total_amount": 80, "total_amount": 80,
"importance": 80, "importance": 80,
@ -283,9 +187,9 @@ def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path):
assert events.status_code == 200 assert events.status_code == 200
assert events.json()["items"][0]["mapped_symbol"] == "ABC/USDT" assert events.json()["items"][0]["mapped_symbol"] == "ABC/USDT"
assert important.status_code == 200 assert important.status_code == 200
assert important.json()["total"] == 0 assert important.json()["total"] == 1
assert low.status_code == 200 assert low.status_code == 200
assert low.json()["total"] == 1 assert low.json()["total"] == 0
def test_nodereal_events_generate_metrics_and_normalized_event(monkeypatch, tmp_path): def test_nodereal_events_generate_metrics_and_normalized_event(monkeypatch, tmp_path):
@ -515,16 +419,6 @@ def test_nodereal_seeds_configured_token_mappings_from_env(monkeypatch, tmp_path
assert mappings[0]["contract_address"] == "0xabc" assert mappings[0]["contract_address"] == "0xabc"
def test_legacy_helius_is_disabled_by_default(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_HELIUS_API_KEY", "test-key")
onchain_db.upsert_token_mapping("SOLX", "solana", "Mint111", source="manual", confidence=95)
result = onchain_monitor.fetch_helius_events(limit=10)
assert result["events"] == []
assert result["errors"] == ["helius_disabled"]
def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path): def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path) _temp_db(monkeypatch, tmp_path)
sched_path = tmp_path / "scheduler_state.db" sched_path = tmp_path / "scheduler_state.db"

View File

@ -216,25 +216,24 @@ def test_llm_system_config_overrides_env_defaults(monkeypatch):
def test_onchain_system_config_overrides_env(monkeypatch): def test_onchain_system_config_overrides_env(monkeypatch):
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "0") monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "0")
monkeypatch.setenv("TEST_ETHERSCAN_KEY", "etherscan-secret") monkeypatch.setenv("TEST_NODEREAL_KEY", "nodereal-secret")
set_config("system", "onchain", { set_config("system", "onchain", {
"enabled": True, "enabled": True,
"chains": ["base", "solana"], "chains": ["ethereum", "bsc"],
"timeout": 9, "timeout": 9,
"candidate_min_score": 88, "candidate_min_score": 88,
"dex_min_liquidity_usd": 123456, "nodereal_api_key_env": "TEST_NODEREAL_KEY",
"etherscan_api_key_env": "TEST_ETHERSCAN_KEY", "nodereal_raw_max_logs_per_chain": 12,
"helius_api_key_env": "TEST_HELIUS_KEY",
}) })
params = get_onchain_params() params = get_onchain_params()
assert params["enabled"] is True assert params["enabled"] is True
assert params["chains"] == ["base", "solana"] assert params["chains"] == ["ethereum", "bsc"]
assert params["timeout"] == 9 assert params["timeout"] == 9
assert params["candidate_min_score"] == 88 assert params["candidate_min_score"] == 88
assert params["dex_min_liquidity_usd"] == 123456 assert params["nodereal_api_key"] == "nodereal-secret"
assert params["etherscan_api_key"] == "etherscan-secret" assert params["nodereal_raw_max_logs_per_chain"] == 12
def test_paper_trading_system_config_controls_account_model(monkeypatch): def test_paper_trading_system_config_controls_account_model(monkeypatch):