This commit is contained in:
aaron 2026-05-17 19:39:11 +08:00
parent 94b1cffa40
commit 567b5b7268
11 changed files with 959 additions and 137 deletions

View File

@ -79,9 +79,16 @@ def default_onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum"
"dex_volume_spike_pct": _env_float("ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT", 80), "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_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_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_add_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT", 25),
"liquidity_remove_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_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", True),
"etherscan_chains": _env_list("ALPHAX_ETHERSCAN_CHAINS", ("ethereum",)),
"helius_enabled": _env_bool("ALPHAX_HELIUS_ENABLED", True),
"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", "etherscan_api_key_env": "ALPHAX_ETHERSCAN_API_KEY",
"helius_api_key_env": "ALPHAX_HELIUS_API_KEY", "helius_api_key_env": "ALPHAX_HELIUS_API_KEY",
} }

View File

@ -671,15 +671,26 @@ def get_stats():
"sector": derived.get("sector_context") or {}, "sector": derived.get("sector_context") or {},
}) })
def avg_from_context(group_key, field): def values_from_context(group_key, field, include_zero=True):
values = [] values = []
for ctx in actionable_contexts: for ctx in actionable_contexts:
value = (ctx.get(group_key) or {}).get(field) group = ctx.get(group_key) or {}
if field not in group or group.get(field) in ("", None):
continue
value = group.get(field)
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
values.append(float(value)) numeric = float(value)
if include_zero or numeric != 0:
values.append(numeric)
return values
def avg_from_context(group_key, field, include_zero=True):
values = values_from_context(group_key, field, include_zero=include_zero)
if not values: if not values:
return 0 return 0
avg = sum(values) / len(values) avg = sum(values) / len(values)
if field == "funding_rate":
return round(avg, 6)
if abs(avg) < 0.01: if abs(avg) < 0.01:
return round(avg, 3) return round(avg, 3)
return round(avg, 1) return round(avg, 1)
@ -696,7 +707,9 @@ def get_stats():
"avg_turnover_acceleration_4h": avg_from_context("market", "turnover_acceleration_4h"), "avg_turnover_acceleration_4h": avg_from_context("market", "turnover_acceleration_4h"),
"avg_volume_24h": avg_from_context("market", "volume_24h"), "avg_volume_24h": avg_from_context("market", "volume_24h"),
"avg_funding_rate": avg_from_context("derivatives", "funding_rate"), "avg_funding_rate": avg_from_context("derivatives", "funding_rate"),
"funding_rate_sample_count": len(values_from_context("derivatives", "funding_rate")),
"avg_top_trader_long_pct": avg_from_context("derivatives", "top_trader_long_pct"), "avg_top_trader_long_pct": avg_from_context("derivatives", "top_trader_long_pct"),
"top_trader_sample_count": len(values_from_context("derivatives", "top_trader_long_pct")),
"avg_top_trader_long_short_ratio": avg_from_context("derivatives", "top_trader_long_short_ratio"), "avg_top_trader_long_short_ratio": avg_from_context("derivatives", "top_trader_long_short_ratio"),
"hot_sector_count": len(hot_sector_counter), "hot_sector_count": len(hot_sector_counter),
"top_hot_sectors": [ "top_hot_sectors": [

View File

@ -6,16 +6,19 @@ mutate trading recommendations directly.
""" """
import json import json
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.db.altcoin_db import get_conn from app.db.altcoin_db import get_conn
from app.db.postgres_connection import ensure_migrations_once from app.db.postgres_connection import ensure_migrations_once
from app.config.system_config import onchain_config
MIN_MAPPING_CONFIDENCE = 70 MIN_MAPPING_CONFIDENCE = 70
SIGNAL_LABELS = { SIGNAL_LABELS = {
"large_token_transfer": "链上大额转账",
"dex_volume_spike": "DEX 放量", "dex_volume_spike": "DEX 放量",
"liquidity_add": "流动性增加", "liquidity_add": "流动性增加",
"liquidity_remove_risk": "流动性撤出风险", "liquidity_remove_risk": "流动性撤出风险",
@ -27,9 +30,27 @@ SIGNAL_LABELS = {
} }
RAW_EVENT_TYPE_LABELS = { RAW_EVENT_TYPE_LABELS = {
"token_profile_latest": "Token 资料更新", "token_profile_latest": "DEX 新币资料变更",
"token_boost_latest": "DEX 热度 Boost", "token_boost_latest": "DEX 付费曝光新增",
"token_boost_top": "DEX Boost 榜", "token_boost_top": "DEX 付费曝光榜",
}
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",
},
} }
POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "smart_money_buying"} POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "smart_money_buying"}
@ -81,6 +102,17 @@ def raw_event_type_label(event_type):
return RAW_EVENT_TYPE_LABELS.get(str(event_type or ""), str(event_type or "链上原始事件")) return RAW_EVENT_TYPE_LABELS.get(str(event_type or ""), str(event_type or "链上原始事件"))
def raw_event_explainer(event_type):
return RAW_EVENT_EXPLAINERS.get(
str(event_type or ""),
{
"plain": "链上或链上相关数据源捕捉到一条原始动态。",
"meaning": "需要完成币种映射和质量验证后,才可能进入技术检查。",
"priority": "medium",
},
)
def init_onchain_tables(): def init_onchain_tables():
ensure_migrations_once() ensure_migrations_once()
@ -390,6 +422,7 @@ def get_onchain_overview(hours=24):
""" """
SELECT * FROM onchain_raw_events SELECT * FROM onchain_raw_events
WHERE detected_at >= %s WHERE detected_at >= %s
AND event_type NOT IN ('token_profile_latest', 'token_boost_latest', 'token_boost_top')
ORDER BY detected_at::timestamp DESC, importance DESC, id DESC ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
LIMIT 12 LIMIT 12
""", """,
@ -426,9 +459,163 @@ def get_onchain_overview(hours=24):
"risk_tokens": [_format_metric_item(row, active) for row in risks], "risk_tokens": [_format_metric_item(row, active) for row in risks],
"raw_events": [_format_raw_event(row) for row in raw_latest], "raw_events": [_format_raw_event(row) for row in raw_latest],
"signals": _signal_counts(events), "signals": _signal_counts(events),
"provider_status": get_onchain_provider_status(hours=hours),
} }
def get_onchain_provider_status(hours=24):
init_onchain_tables()
cfg = onchain_config()
hours = int(hours or 24)
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
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()
try:
raw_total = conn.execute("SELECT COUNT(*) FROM onchain_raw_events WHERE detected_at >= %s", (cutoff,)).fetchone()[0]
metric_total = conn.execute("SELECT COUNT(*) FROM onchain_token_metrics WHERE metric_time >= %s", (cutoff,)).fetchone()[0]
signal_total = conn.execute("SELECT COUNT(*) FROM onchain_events WHERE detected_at >= %s", (cutoff,)).fetchone()[0]
candidate_total = conn.execute(
"""
SELECT COUNT(*)
FROM event_news
WHERE source='onchain' AND detected_at >= %s
""",
(cutoff,),
).fetchone()[0]
mapping_total = conn.execute("SELECT COUNT(*) FROM onchain_token_map WHERE is_active=1").fetchone()[0]
mapping_usable = conn.execute(
"SELECT COUNT(*) FROM onchain_token_map WHERE is_active=1 AND confidence >= %s",
(MIN_MAPPING_CONFIDENCE,),
).fetchone()[0]
raw_by_type = conn.execute(
"""
SELECT event_type, COUNT(*) AS count
FROM onchain_raw_events
WHERE detected_at >= %s
GROUP BY event_type
ORDER BY count DESC, event_type
""",
(cutoff,),
).fetchall()
metric_sources = conn.execute(
"""
SELECT source, COUNT(*) AS count
FROM onchain_token_metrics
WHERE metric_time >= %s
GROUP BY source
ORDER BY count DESC, source
""",
(cutoff,),
).fetchall()
signal_sources = conn.execute(
"""
SELECT source, COUNT(*) AS count
FROM onchain_events
WHERE detected_at >= %s
GROUP BY source
ORDER BY count DESC, source
""",
(cutoff,),
).fetchall()
last_onchain = conn.execute(
"""
SELECT *
FROM cron_run_log
WHERE script_name='onchain_monitor.py' OR job_name IN ('链上','onchain')
ORDER BY started_at DESC, id DESC
LIMIT 1
"""
).fetchone()
finally:
conn.close()
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 ""
providers = [
{
"provider": "dexscreener",
"label": "DEX Screener",
"enabled": bool(cfg.get("dexscreener_enabled", True)),
"api_key_present": True,
"implemented": True,
"role": "低优先级曝光源Token 资料、付费推广、已映射合约的 DEX 成交量与流动性",
"raw_events": int(raw_total or 0),
"metrics": int(metric_total or 0),
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "dexscreener")),
"status": _provider_status_label(bool(cfg.get("dexscreener_enabled", True)), True, raw_total + metric_total, last_error),
},
{
"provider": "etherscan",
"label": "Etherscan",
"enabled": bool(cfg.get("etherscan_enabled", True)),
"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(
bool(cfg.get("etherscan_enabled", True)),
True,
int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
last_error if "etherscan" in str(last_error).lower() else "",
),
},
{
"provider": "helius",
"label": "Helius",
"enabled": bool(cfg.get("helius_enabled", True)),
"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(
bool(cfg.get("helius_enabled", True)),
True,
int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
last_error if "helius" in str(last_error).lower() else "",
),
},
]
return {
"hours": hours,
"enabled": bool(cfg.get("enabled", False)),
"last_run": dict(last_onchain) if last_onchain else None,
"last_summary": summary,
"last_error": last_error or "",
"coverage": {
"active_mappings": int(mapping_total or 0),
"usable_mappings": int(mapping_usable or 0),
"raw_events": int(raw_total or 0),
"metrics": int(metric_total or 0),
"signals": int(signal_total or 0),
"queued_candidates": int(candidate_total or 0),
},
"raw_event_types": [dict(row) for row in raw_by_type],
"metric_sources": [dict(row) for row in metric_sources],
"signal_sources": [dict(row) for row in signal_sources],
"providers": providers,
}
def _provider_status_label(enabled, implemented, count, last_error=""):
if not enabled:
return "已关闭"
if not implemented:
return "未接入采集"
if count:
return "正常采集中"
if last_error:
return "最近采集失败"
return "暂无数据"
def _signal_counts(events): def _signal_counts(events):
counts = {} counts = {}
for e in events: for e in events:
@ -596,7 +783,7 @@ def list_onchain_events(limit=50, offset=0, chain="", signal="", status="", hour
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)} 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): def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type="", mapping_status="", priority="", hours=24):
init_onchain_tables() init_onchain_tables()
limit = max(1, min(int(limit or 50), 200)) limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0)) offset = max(0, int(offset or 0))
@ -615,6 +802,11 @@ def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type=
if mapping_status: if mapping_status:
clauses.append("mapping_status=%s") clauses.append("mapping_status=%s")
params.append(mapping_status) params.append(mapping_status)
if priority:
if priority == "important":
clauses.append("event_type NOT IN ('token_profile_latest', 'token_boost_latest', 'token_boost_top')")
elif priority == "low":
clauses.append("event_type IN ('token_profile_latest', 'token_boost_latest', 'token_boost_top')")
where = " AND ".join(clauses) where = " AND ".join(clauses)
conn = get_conn() conn = get_conn()
total = conn.execute(f"SELECT COUNT(*) FROM onchain_raw_events WHERE {where}", tuple(params)).fetchone()[0] total = conn.execute(f"SELECT COUNT(*) FROM onchain_raw_events WHERE {where}", tuple(params)).fetchone()[0]
@ -661,6 +853,15 @@ def _with_raw(row):
def _format_raw_event(row): def _format_raw_event(row):
item = _with_raw(row) item = _with_raw(row)
item["event_label"] = raw_event_type_label(item.get("event_type")) item["event_label"] = raw_event_type_label(item.get("event_type"))
explainer = raw_event_explainer(item.get("event_type"))
item["plain_summary"] = explainer.get("plain") or ""
item["why_matters"] = explainer.get("meaning") or ""
item["priority"] = explainer.get("priority") or "medium"
item["pipeline_note"] = (
"已映射,可进入后续链上信号分析。"
if item.get("mapping_status") == "mapped"
else "未完成币种映射,仅作为原始观察,不进入推荐。"
)
item["token_short"] = _short_address(item.get("token_address")) item["token_short"] = _short_address(item.get("token_address"))
return item return item

View File

@ -0,0 +1,196 @@
"""Crypto market-wide overview providers.
This module is intentionally separate from recommendation analytics. The market
overview page should describe the broad crypto environment, not the current
recommendation sample.
"""
from __future__ import annotations
from datetime import datetime
import requests
from app.services import altcoin_screener
def _safe_float(value, default=0.0):
try:
return float(value or 0)
except Exception:
return default
def _percentile(values, pct):
values = sorted([float(v) for v in values if isinstance(v, (int, float))])
if not values:
return 0.0
if len(values) == 1:
return round(values[0], 2)
k = (len(values) - 1) * float(pct)
lower = int(k)
upper = min(lower + 1, len(values) - 1)
weight = k - lower
return round(values[lower] * (1 - weight) + values[upper] * weight, 2)
def _market_state(avg_change, advance_decline_ratio, hot_count, crash_count, benchmarks=None):
benchmarks = benchmarks or {}
btc_change = _safe_float((benchmarks.get("BTC/USDT") or {}).get("change_24h"))
eth_change = _safe_float((benchmarks.get("ETH/USDT") or {}).get("change_24h"))
majors_weak = btc_change <= -2 or eth_change <= -2.5
majors_strong = btc_change >= 1 and eth_change >= 1
if crash_count >= 20 or advance_decline_ratio < 0.65 or (majors_weak and avg_change < 0):
return {
"label": "全市场偏弱",
"tone": "risk_off",
"summary": "主流币或山寨广度走弱,山寨机会更容易变成反弹噪音,优先控制仓位和追高风险。",
}
if avg_change >= 1.0 and advance_decline_ratio >= 1.2 and hot_count >= 20 and not majors_weak:
return {
"label": "全市场偏强",
"tone": "risk_on",
"summary": "上涨覆盖面和强势币数量都不错,可以更积极寻找放量后的确认机会。",
}
if majors_strong and hot_count >= 10 and advance_decline_ratio >= 0.9:
return {
"label": "主流带动轮动",
"tone": "selective",
"summary": "BTC/ETH 提供方向支撑,但山寨仍是结构性轮动,适合等待量价和入场窗口共振。",
}
return {
"label": "结构性行情",
"tone": "neutral",
"summary": "市场不是单边环境,机会更依赖板块轮动和单币确认,适合精选而不是广撒网。",
}
def _benchmark_overview():
try:
tickers = altcoin_screener.exchange.fetch_tickers(["BTC/USDT", "ETH/USDT"])
except Exception:
try:
tickers = altcoin_screener.exchange.fetch_tickers()
except Exception:
tickers = {}
result = {}
for symbol in ("BTC/USDT", "ETH/USDT"):
info = tickers.get(symbol) or {}
result[symbol] = {
"symbol": symbol,
"price": _safe_float(info.get("last")),
"change_24h": _safe_float(info.get("percentage")),
"volume_24h": _safe_float(info.get("quoteVolume")),
}
return result
def _funding_snapshot():
try:
data = altcoin_screener.exchange.fapiPublicGetPremiumIndex()
except Exception:
try:
resp = requests.get("https://fapi.binance.com/fapi/v1/premiumIndex", timeout=8)
data = resp.json() if resp.status_code == 200 else []
except Exception:
data = []
if isinstance(data, dict):
data = [data]
result = {}
for item in data or []:
raw_symbol = str(item.get("symbol") or "")
if not raw_symbol.endswith("USDT"):
continue
rate = item.get("lastFundingRate")
try:
rate = float(rate)
except Exception:
continue
result[raw_symbol.replace("USDT", "/USDT")] = rate
return result
def _funding_overview(universe_symbols=None):
rates = _funding_snapshot()
if not rates:
rates = altcoin_screener.fetch_funding_rates()
universe = set(universe_symbols or [])
values = [
float(v)
for symbol, v in (rates or {}).items()
if isinstance(v, (int, float)) and (not universe or symbol in universe)
]
if not values:
return {
"sample_count": 0,
"avg_funding_rate": 0,
"positive_count": 0,
"negative_count": 0,
"extreme_positive_count": 0,
"extreme_negative_count": 0,
}
return {
"sample_count": len(values),
"avg_funding_rate": round(sum(values) / len(values), 6),
"positive_count": sum(1 for v in values if v > 0),
"negative_count": sum(1 for v in values if v < 0),
"extreme_positive_count": sum(1 for v in values if v >= 0.001),
"extreme_negative_count": sum(1 for v in values if v <= -0.001),
}
def get_crypto_market_overview():
pairs = altcoin_screener.fetch_all_tickers()
benchmarks = _benchmark_overview()
items = []
for symbol, info in (pairs or {}).items():
volume = _safe_float(info.get("volume_24h"))
change = _safe_float(info.get("change_24h"))
price = _safe_float(info.get("price"))
if volume <= 0 or price <= 0:
continue
items.append({
"symbol": symbol,
"price": price,
"change_24h": change,
"volume_24h": volume,
})
changes = [x["change_24h"] for x in items]
volumes = [x["volume_24h"] for x in items]
up = sum(1 for x in changes if x > 0)
down = sum(1 for x in changes if x < 0)
hot = [x for x in items if x["change_24h"] >= 5]
crash = [x for x in items if x["change_24h"] <= -5]
adv_dec = round(up / down, 2) if down else float(up)
avg_change = round(sum(changes) / len(changes), 2) if changes else 0
state = _market_state(avg_change, adv_dec, len(hot), len(crash), benchmarks=benchmarks)
top_gainers = sorted(items, key=lambda x: x["change_24h"], reverse=True)[:10]
top_losers = sorted(items, key=lambda x: x["change_24h"])[:10]
top_volume = sorted(items, key=lambda x: x["volume_24h"], reverse=True)[:10]
overview = {
"updated_at": datetime.now().isoformat(timespec="seconds"),
"source": "binance_spot_usdt_market",
"universe": "Binance spot USDT crypto market, with BTC/ETH as benchmarks and altcoin breadth excluding stables/wrapped/metals/BNB",
"benchmarks": benchmarks,
"sample_count": len(items),
"up_count": up,
"down_count": down,
"flat_count": max(0, len(items) - up - down),
"advance_decline_ratio": adv_dec,
"avg_change_24h": avg_change,
"median_change_24h": _percentile(changes, 0.5),
"p75_change_24h": _percentile(changes, 0.75),
"p25_change_24h": _percentile(changes, 0.25),
"hot_count_5pct": len(hot),
"crash_count_5pct": len(crash),
"total_quote_volume_24h": round(sum(volumes), 2),
"state": state,
"top_gainers": top_gainers,
"top_losers": top_losers,
"top_volume": top_volume,
"funding": _funding_overview({x["symbol"] for x in items}),
}
return overview
__all__ = ["get_crypto_market_overview"]

View File

@ -8,6 +8,7 @@ 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 import requests
@ -33,6 +34,12 @@ from app.services.event_driven_screener import _tradable_symbol, init_event_tabl
DEFAULT_CHAINS = ("ethereum", "bsc", "base", "arbitrum", "solana") DEFAULT_CHAINS = ("ethereum", "bsc", "base", "arbitrum", "solana")
ETHERSCAN_CHAIN_IDS = {
"ethereum": "1",
"bsc": "56",
"base": "8453",
"arbitrum": "42161",
}
SOLANA_AUTO_ALLOWLIST = { SOLANA_AUTO_ALLOWLIST = {
"WIF", "BONK", "JUP", "RAY", "PYTH", "PENGU", "JTO", "MEW", "POPCAT", "PNUT", "WIF", "BONK", "JUP", "RAY", "PYTH", "PENGU", "JTO", "MEW", "POPCAT", "PNUT",
"FARTCOIN", "RENDER", "HNT", "MOBILE", "ORCA", "KMNO", "DRIFT", "TNSR", "IO", "FARTCOIN", "RENDER", "HNT", "MOBILE", "ORCA", "KMNO", "DRIFT", "TNSR", "IO",
@ -96,6 +103,11 @@ def get_onchain_params():
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") 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") helius_env = str(cfg.get("helius_api_key_env") or "ALPHAX_HELIUS_API_KEY")
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)),
"chains": chains or list(DEFAULT_CHAINS), "chains": chains or list(DEFAULT_CHAINS),
@ -105,12 +117,19 @@ def get_onchain_params():
"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)), "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_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_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), "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_add_pct": float(cfg.get("liquidity_add_pct") or 25),
"liquidity_remove_pct": float(cfg.get("liquidity_remove_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(), "etherscan_api_key": os.getenv(etherscan_env, "").strip(),
"helius_api_key": os.getenv(helius_env, "").strip(), "helius_api_key": os.getenv(helius_env, "").strip(),
} }
@ -142,11 +161,35 @@ def _safe_pct_change(new_value, old_value):
return (new_value - old_value) / old_value * 100 return (new_value - old_value) / old_value * 100
def _safe_int(value, default=0):
try:
return int(float(value or 0))
except Exception:
return default
def _chain_alias(value): def _chain_alias(value):
key = str(value or "").lower() key = str(value or "").lower()
return DEX_CHAIN_ALIASES.get(key, key) return DEX_CHAIN_ALIASES.get(key, key)
def _chain_explorer_tx_url(chain, tx_hash):
tx_hash = str(tx_hash or "").strip()
if not tx_hash:
return ""
if chain == "ethereum":
return f"https://etherscan.io/tx/{tx_hash}"
if chain == "bsc":
return f"https://bscscan.com/tx/{tx_hash}"
if chain == "base":
return f"https://basescan.org/tx/{tx_hash}"
if chain == "arbitrum":
return f"https://arbiscan.io/tx/{tx_hash}"
if chain == "solana":
return f"https://solscan.io/tx/{tx_hash}"
return ""
def _latest_metric(symbol, chain, contract_address): def _latest_metric(symbol, chain, contract_address):
conn = get_conn() conn = get_conn()
row = conn.execute( row = conn.execute(
@ -210,11 +253,11 @@ def normalize_dexscreener_raw_event(item, event_type, cfg=None):
} }
title = "DEX Screener" title = "DEX Screener"
if event_type == "token_profile_latest": if event_type == "token_profile_latest":
title = "Token 资料更新" title = "DEX 新币资料变更"
elif event_type == "token_boost_latest": elif event_type == "token_boost_latest":
title = "DEX Boost 新增" title = "DEX 付费曝光新增"
elif event_type == "token_boost_top": elif event_type == "token_boost_top":
title = "DEX Boost 榜单" title = "DEX 付费曝光榜"
return { return {
"source": "dexscreener", "source": "dexscreener",
"chain": chain, "chain": chain,
@ -445,8 +488,16 @@ def derive_dex_signals(metric, cfg=None):
signals = [] signals = []
vol_change = _safe_float(metric.get("dex_volume_change_pct")) vol_change = _safe_float(metric.get("dex_volume_change_pct"))
liq_change = _safe_float(metric.get("liquidity_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): if vol_change >= cfg.get("dex_volume_spike_pct", 80):
signals.append("dex_volume_spike") 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): if liq_change >= cfg.get("liquidity_add_pct", 25):
signals.append("liquidity_add") signals.append("liquidity_add")
if liq_change <= cfg.get("liquidity_remove_pct", -25): if liq_change <= cfg.get("liquidity_remove_pct", -25):
@ -481,7 +532,13 @@ def normalize_dexscreener_pair(pair, mapping, cfg=None):
chain = DEX_CHAIN_ALIASES.get(str(pair.get("chainId") or mapping.get("chain") or "").lower(), str(mapping.get("chain") or "").lower()) 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 "" contract = mapping.get("contract_address") or (pair.get("baseToken") or {}).get("address") or ""
liquidity = _safe_float((pair.get("liquidity") or {}).get("usd")) liquidity = _safe_float((pair.get("liquidity") or {}).get("usd"))
volume = _safe_float((pair.get("volume") or {}).get("h24")) 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 = _latest_metric(symbol, chain, contract)
prev_volume = _safe_float(prev.get("dex_volume_usd") if prev else 0) 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) prev_liquidity = _safe_float(prev.get("liquidity_usd") if prev else 0)
@ -492,6 +549,10 @@ def normalize_dexscreener_pair(pair, mapping, cfg=None):
"window": "1h", "window": "1h",
"metric_time": _now().isoformat(timespec="seconds"), "metric_time": _now().isoformat(timespec="seconds"),
"dex_volume_usd": volume, "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), "dex_volume_change_pct": _safe_pct_change(volume, prev_volume),
"liquidity_usd": liquidity, "liquidity_usd": liquidity,
"liquidity_change_pct": _safe_pct_change(liquidity, prev_liquidity), "liquidity_change_pct": _safe_pct_change(liquidity, prev_liquidity),
@ -510,6 +571,14 @@ def normalize_dexscreener_pair(pair, mapping, cfg=None):
"price_change": pair.get("priceChange") or {}, "price_change": pair.get("priceChange") or {},
"volume": pair.get("volume") or {}, "volume": pair.get("volume") or {},
"liquidity": pair.get("liquidity") 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) return _score_metric(metric)
@ -558,6 +627,198 @@ def fetch_dexscreener_metrics(limit=60):
return {"metrics": metrics, "events": events, "errors": errors} 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):
latest = _latest_metric(
normalize_symbol(mapping.get("symbol")),
str(mapping.get("chain") or "").lower(),
str(mapping.get("contract_address") or ""),
)
raw = {}
try:
raw = json.loads(latest.get("raw_json") or "{}") if latest else {}
except Exception:
raw = {}
return _safe_float(raw.get("price_usd"))
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):
value = str(value or "")
if len(value) <= 12:
return value
return value[:6] + "..." + value[-4:]
def ingest_normalized_events(events): def ingest_normalized_events(events):
"""Test/integration helper for provider adapters.""" """Test/integration helper for provider adapters."""
init_db() init_db()
@ -705,6 +966,12 @@ def run_once(limit=60):
output["metrics_count"] += len(dex.get("metrics") or []) output["metrics_count"] += len(dex.get("metrics") or [])
output["events_count"] += len(dex.get("events") or []) output["events_count"] += len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or []) output["errors"].extend(dex.get("errors") or [])
eth = fetch_etherscan_events(limit=limit)
output["events_count"] += len(eth.get("events") or [])
output["errors"].extend(eth.get("errors") or [])
hel = fetch_helius_events(limit=limit)
output["events_count"] += len(hel.get("events") or [])
output["errors"].extend(hel.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 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"): if output.get("discovered_mappings"):
output["status"] = "bootstrapped" output["status"] = "bootstrapped"
@ -712,6 +979,12 @@ def run_once(limit=60):
output["metrics_count"] = len(dex.get("metrics") or []) output["metrics_count"] = len(dex.get("metrics") or [])
output["events_count"] = len(dex.get("events") or []) output["events_count"] = len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or []) output["errors"].extend(dex.get("errors") or [])
eth = fetch_etherscan_events(limit=limit)
output["events_count"] += len(eth.get("events") or [])
output["errors"].extend(eth.get("errors") or [])
hel = fetch_helius_events(limit=limit)
output["events_count"] += len(hel.get("events") or [])
output["errors"].extend(hel.get("errors") or [])
queued = enqueue_onchain_candidates() queued = enqueue_onchain_candidates()
output["candidate_queued"] = queued.get("queued", 0) output["candidate_queued"] = queued.get("queued", 0)
output["candidate_symbols"] = queued.get("symbols", []) output["candidate_symbols"] = queued.get("symbols", [])
@ -745,6 +1018,8 @@ __all__ = [
"enqueue_onchain_candidates", "enqueue_onchain_candidates",
"fetch_dexscreener_metrics", "fetch_dexscreener_metrics",
"fetch_dexscreener_raw_events", "fetch_dexscreener_raw_events",
"fetch_etherscan_events",
"fetch_helius_events",
"get_onchain_params", "get_onchain_params",
"ingest_normalized_events", "ingest_normalized_events",
"normalize_dexscreener_pair", "normalize_dexscreener_pair",

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Cookie from fastapi import APIRouter, Cookie
from app.db.analytics import get_stats
from app.db.onchain_db import get_onchain_overview from app.db.onchain_db import get_onchain_overview
from app.services.market_overview import get_crypto_market_overview
from app.web.shared import require_api_user_with_subscription from app.web.shared import require_api_user_with_subscription
@ -11,7 +11,12 @@ router = APIRouter()
@router.get("/api/market/overview") @router.get("/api/market/overview")
async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(default="")): async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
require_api_user_with_subscription(altcoin_session) require_api_user_with_subscription(altcoin_session)
stats = get_stats() crypto_market = {}
market_error = ""
try:
crypto_market = get_crypto_market_overview()
except Exception as exc:
market_error = str(exc)[:500]
onchain = get_onchain_overview(hours=hours) onchain = get_onchain_overview(hours=hours)
newsfeed = {} newsfeed = {}
ai_analysis = {} ai_analysis = {}
@ -22,9 +27,9 @@ async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(def
except Exception: except Exception:
newsfeed = {} newsfeed = {}
try: try:
from app.db.llm_insights import get_latest_insight_by_type from app.services.llm_insights import get_latest_sentiment_batch_analysis
latest_sentiment = get_latest_insight_by_type("sentiment_batch_analysis") latest_sentiment = get_latest_sentiment_batch_analysis()
if latest_sentiment: if latest_sentiment:
ai_analysis = { ai_analysis = {
"status": latest_sentiment.get("status"), "status": latest_sentiment.get("status"),
@ -38,9 +43,10 @@ async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(def
ai_analysis = {} ai_analysis = {}
return { return {
"hours": int(hours or 24), "hours": int(hours or 24),
"updated_at": stats.get("market_context_overview", {}).get("updated_at") if isinstance(stats, dict) else None, "updated_at": crypto_market.get("updated_at"),
"market": { "market": {
"stats": stats, "crypto_market": crypto_market,
"market_error": market_error,
"newsfeed": newsfeed, "newsfeed": newsfeed,
"onchain": onchain, "onchain": onchain,
"ai_analysis": ai_analysis, "ai_analysis": ai_analysis,

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Cookie
from app.db.onchain_db import ( from app.db.onchain_db import (
get_onchain_overview, get_onchain_overview,
get_onchain_provider_status,
get_onchain_token_detail, get_onchain_token_detail,
list_onchain_events, list_onchain_events,
list_onchain_raw_events, list_onchain_raw_events,
@ -19,6 +20,12 @@ async def api_onchain_overview(hours: int = 24, altcoin_session: str = Cookie(de
return get_onchain_overview(hours=hours) return get_onchain_overview(hours=hours)
@router.get("/api/onchain/provider-status")
async def api_onchain_provider_status(hours: int = 24, altcoin_session: str = Cookie(default="")):
require_api_user_with_subscription(altcoin_session)
return get_onchain_provider_status(hours=hours)
@router.get("/api/onchain/tokens") @router.get("/api/onchain/tokens")
async def api_onchain_tokens( async def api_onchain_tokens(
limit: int = 30, limit: int = 30,
@ -60,6 +67,7 @@ async def api_onchain_raw_events(
source: str = "", source: str = "",
event_type: str = "", event_type: str = "",
mapping_status: str = "", mapping_status: str = "",
priority: str = "",
hours: int = 24, hours: int = 24,
altcoin_session: str = Cookie(default=""), altcoin_session: str = Cookie(default=""),
): ):
@ -71,5 +79,6 @@ async def api_onchain_raw_events(
source=source, source=source,
event_type=event_type, event_type=event_type,
mapping_status=mapping_status, mapping_status=mapping_status,
priority=priority,
hours=hours, hours=hours,
) )

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,70 @@
from fastapi.testclient import TestClient
from app.db import auth_db
from app.services import market_overview
from app.web import routes_market, web_server
def _login_subscribed_user(email="market-user@example.com"):
reg = auth_db.register_user(email, "StrongPass123")
auth_db.verify_email(email, reg["verification_code"])
user = auth_db.get_user_by_email(email)
auth_db.claim_free_trial(user["id"])
return auth_db.login_user(email, "StrongPass123")["token"]
def test_crypto_market_overview_uses_full_market_inputs(monkeypatch):
monkeypatch.setattr(market_overview.altcoin_screener, "fetch_all_tickers", lambda: {
"AAA/USDT": {"price": 1, "change_24h": 7, "volume_24h": 100_000_000},
"BBB/USDT": {"price": 2, "change_24h": 4, "volume_24h": 60_000_000},
"CCC/USDT": {"price": 3, "change_24h": -1, "volume_24h": 30_000_000},
"DDD/USDT": {"price": 4, "change_24h": -6, "volume_24h": 20_000_000},
})
monkeypatch.setattr(market_overview, "_funding_snapshot", lambda: {
"AAA/USDT": 0.0002,
"BBB/USDT": -0.0001,
"CCC/USDT": 0.0012,
})
monkeypatch.setattr(market_overview.altcoin_screener.exchange, "fetch_tickers", lambda *args, **kwargs: {
"BTC/USDT": {"last": 100_000, "percentage": 1.5, "quoteVolume": 1_000_000_000},
"ETH/USDT": {"last": 5_000, "percentage": 2.1, "quoteVolume": 800_000_000},
})
overview = market_overview.get_crypto_market_overview()
assert overview["source"] == "binance_spot_usdt_market"
assert overview["sample_count"] == 4
assert overview["up_count"] == 2
assert overview["down_count"] == 2
assert overview["hot_count_5pct"] == 1
assert overview["crash_count_5pct"] == 1
assert overview["benchmarks"]["BTC/USDT"]["change_24h"] == 1.5
assert overview["top_gainers"][0]["symbol"] == "AAA/USDT"
assert overview["top_volume"][0]["symbol"] == "AAA/USDT"
assert overview["funding"]["sample_count"] == 3
assert overview["funding"]["extreme_positive_count"] == 1
def test_market_overview_api_returns_crypto_market_not_candidate_stats(monkeypatch):
monkeypatch.setattr(routes_market, "get_crypto_market_overview", lambda: {
"updated_at": "2026-05-17T12:00:00",
"source": "binance_spot_usdt_market",
"sample_count": 2,
"state": {"label": "结构性行情", "tone": "neutral", "summary": "精选机会"},
"benchmarks": {},
"top_gainers": [],
"top_volume": [],
"funding": {"sample_count": 0},
})
monkeypatch.setattr(routes_market, "get_onchain_overview", lambda hours=24: {"kpi": {}, "raw_events": []})
token = _login_subscribed_user()
client = TestClient(web_server.app)
client.cookies.set("altcoin_session", token)
resp = client.get("/api/market/overview")
assert resp.status_code == 200
data = resp.json()
assert data["market"]["crypto_market"]["source"] == "binance_spot_usdt_market"
assert "stats" not in data["market"]
assert "market_context_overview" not in data["market"]

View File

@ -40,6 +40,8 @@ def test_dex_signal_codes_from_metric(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path) _temp_db(monkeypatch, tmp_path)
cfg = { cfg = {
"dex_volume_spike_pct": 80, "dex_volume_spike_pct": 80,
"dex_min_hour_volume_usd": 50000,
"dex_hour_volume_share_pct": 8,
"liquidity_add_pct": 25, "liquidity_add_pct": 25,
"liquidity_remove_pct": -25, "liquidity_remove_pct": -25,
} }
@ -51,6 +53,10 @@ def test_dex_signal_codes_from_metric(monkeypatch, tmp_path):
assert onchain_monitor.derive_dex_signals({"dex_volume_change_pct": 10, "liquidity_change_pct": -35}, cfg) == [ assert onchain_monitor.derive_dex_signals({"dex_volume_change_pct": 10, "liquidity_change_pct": -35}, cfg) == [
"liquidity_remove_risk" "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):
@ -192,11 +198,16 @@ def test_onchain_api_and_page(monkeypatch, tmp_path):
overview = client.get("/api/onchain/overview") overview = client.get("/api/onchain/overview")
assert overview.status_code == 200 assert overview.status_code == 200
assert overview.json()["kpi"]["token_count"] == 1 assert overview.json()["kpi"]["token_count"] == 1
assert overview.json()["provider_status"]["providers"][0]["provider"] == "dexscreener"
tokens = client.get("/api/onchain/tokens") tokens = client.get("/api/onchain/tokens")
assert tokens.status_code == 200 assert tokens.status_code == 200
assert tokens.json()["items"][0]["symbol"] == "ABC/USDT" assert tokens.json()["items"][0]["symbol"] == "ABC/USDT"
provider_status = client.get("/api/onchain/provider-status")
assert provider_status.status_code == 200
assert provider_status.json()["coverage"]["metrics"] == 1
def test_raw_dexscreener_events_store_without_mapping(monkeypatch, tmp_path): def test_raw_dexscreener_events_store_without_mapping(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path) _temp_db(monkeypatch, tmp_path)
@ -261,12 +272,112 @@ def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path):
overview = client.get("/api/onchain/overview") overview = client.get("/api/onchain/overview")
events = client.get("/api/onchain/raw-events") events = client.get("/api/onchain/raw-events")
important = client.get("/api/onchain/raw-events?priority=important")
low = client.get("/api/onchain/raw-events?priority=low")
assert overview.status_code == 200 assert overview.status_code == 200
assert overview.json()["kpi"]["raw_event_count"] == 1 assert overview.json()["kpi"]["raw_event_count"] == 1
assert overview.json()["kpi"]["raw_mapped_count"] == 1 assert overview.json()["kpi"]["raw_mapped_count"] == 1
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.json()["total"] == 0
assert low.status_code == 200
assert low.json()["total"] == 1
def test_etherscan_events_generate_normalized_onchain_event(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ETHERSCAN_API_KEY", "test-key")
onchain_db.upsert_token_mapping("ABC", "ethereum", "0xabc", source="manual", confidence=95)
onchain_db.insert_token_metric(
{
"symbol": "ABC/USDT",
"chain": "ethereum",
"contract_address": "0xabc",
"window": "1h",
"metric_time": datetime.now().isoformat(),
"dex_volume_usd": 100000,
"liquidity_usd": 100000,
"source": "test",
"raw": {"price_usd": "2"},
}
)
def fake_request(url, params=None, timeout=15):
assert params["chainid"] == "1"
assert params["contractaddress"] == "0xabc"
return {
"status": "1",
"message": "OK",
"result": [
{
"hash": "0xtx",
"timeStamp": "1700000000",
"from": "0xfrom",
"to": "0xto",
"value": "200000000000000000000000",
"tokenDecimal": "18",
}
],
}
monkeypatch.setattr(onchain_monitor, "_request_json", fake_request)
result = onchain_monitor.fetch_etherscan_events(limit=10)
assert result["errors"] == []
assert len(result["events"]) == 1
events = onchain_db.list_onchain_events(hours=50000)
assert events["total"] == 1
assert events["items"][0]["source"] == "etherscan"
assert events["items"][0]["signal_code"] == "whale_accumulation"
def test_helius_events_generate_normalized_onchain_event(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)
onchain_db.insert_token_metric(
{
"symbol": "SOLX/USDT",
"chain": "solana",
"contract_address": "Mint111",
"window": "1h",
"metric_time": datetime.now().isoformat(),
"dex_volume_usd": 100000,
"liquidity_usd": 100000,
"source": "test",
"raw": {"price_usd": "5"},
}
)
def fake_request(url, params=None, timeout=15):
assert "api-key=test-key" in url
return [
{
"signature": "sig111",
"timestamp": 1700000000,
"tokenTransfers": [
{
"mint": "Mint111",
"tokenAmount": 60000,
"fromUserAccount": "fromSol",
"toUserAccount": "toSol",
}
],
"nativeTransfers": [],
}
]
monkeypatch.setattr(onchain_monitor, "_request_json", fake_request)
result = onchain_monitor.fetch_helius_events(limit=10)
assert result["errors"] == []
assert len(result["events"]) == 1
events = onchain_db.list_onchain_events(hours=50000)
assert events["total"] == 1
assert events["items"][0]["source"] == "helius"
assert events["items"][0]["signal_code"] == "whale_accumulation"
def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path): def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path):