update
This commit is contained in:
parent
94b1cffa40
commit
567b5b7268
@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": [
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
196
app/services/market_overview.py
Normal file
196
app/services/market_overview.py
Normal 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"]
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
70
tests/test_market_overview_api.py
Normal file
70
tests/test_market_overview_api.py
Normal 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"]
|
||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user