update
This commit is contained in:
parent
4fa4dcb965
commit
823609db9f
12
.env.example
12
.env.example
@ -41,20 +41,28 @@ ALPHAX_LLM_REVIEW_ENABLED=1
|
|||||||
|
|
||||||
# 链上追踪运行时配置。默认关闭;开启后采集结果只作为发现/风控辅助。
|
# 链上追踪运行时配置。默认关闭;开启后采集结果只作为发现/风控辅助。
|
||||||
ALPHAX_ONCHAIN_ENABLED=0
|
ALPHAX_ONCHAIN_ENABLED=0
|
||||||
ALPHAX_ONCHAIN_CHAINS=ethereum,bsc,base,arbitrum,solana
|
ALPHAX_ONCHAIN_PROVIDER=nodereal
|
||||||
|
ALPHAX_ONCHAIN_CHAINS=ethereum,bsc
|
||||||
ALPHAX_ONCHAIN_TIMEOUT=15
|
ALPHAX_ONCHAIN_TIMEOUT=15
|
||||||
|
ALPHAX_NODEREAL_ENABLED=1
|
||||||
|
ALPHAX_NODEREAL_CHAINS=ethereum,bsc
|
||||||
|
ALPHAX_NODEREAL_API_KEY=
|
||||||
|
ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK=120
|
||||||
|
ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN=25
|
||||||
ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
|
ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
|
||||||
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
|
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
|
||||||
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
|
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
|
||||||
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
|
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
|
||||||
ALPHAX_ONCHAIN_DEXSCREENER_ENABLED=1
|
ALPHAX_ONCHAIN_DEXSCREENER_ENABLED=0
|
||||||
ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT=80
|
ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT=80
|
||||||
ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD=100000
|
ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD=100000
|
||||||
ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD=100000
|
ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD=100000
|
||||||
ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT=25
|
ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT=25
|
||||||
ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT=-25
|
ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT=-25
|
||||||
ALPHAX_ONCHAIN_WHALE_TX_USD=250000
|
ALPHAX_ONCHAIN_WHALE_TX_USD=250000
|
||||||
|
ALPHAX_ETHERSCAN_ENABLED=0
|
||||||
ALPHAX_ETHERSCAN_API_KEY=
|
ALPHAX_ETHERSCAN_API_KEY=
|
||||||
|
ALPHAX_HELIUS_ENABLED=0
|
||||||
ALPHAX_HELIUS_API_KEY=
|
ALPHAX_HELIUS_API_KEY=
|
||||||
|
|
||||||
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
|
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
|
||||||
|
|||||||
@ -98,6 +98,13 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
|||||||
8. `app/services/review_engine.py`
|
8. `app/services/review_engine.py`
|
||||||
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
|
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
|
||||||
|
|
||||||
|
### 4.1.1 链上数据源
|
||||||
|
|
||||||
|
- 当前链上主数据源是 NodeReal,入口在 `app/services/nodereal_client.py` 和 `app/services/onchain_monitor.py`。
|
||||||
|
- 默认只跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`,并通过 `ALPHAX_NODEREAL_API_KEY` 访问 EVM JSON-RPC / Enhanced API。
|
||||||
|
- DEX Screener、Etherscan、Helius 已从默认链路关闭,只保留历史兼容函数和旧数据展示,不应再作为新增链上逻辑的主入口。
|
||||||
|
- 新增链上信号优先落到 `onchain_token_metrics` / `onchain_events`,不要直接创建推荐;高质量事件仍通过 `event_news` 进入技术检查。
|
||||||
|
|
||||||
### 4.2 Web/API
|
### 4.2 Web/API
|
||||||
|
|
||||||
`app/web/web_server.py` 只应负责 FastAPI 应用装配、模板装配、中间件、全局异常处理和 router include。新增业务 API 优先放到对应 route 模块:
|
`app/web/web_server.py` 只应负责 FastAPI 应用装配、模板装配、中间件、全局异常处理和 router include。新增业务 API 优先放到对应 route 模块:
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190`。
|
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190`。
|
||||||
- 运行时数据库是 PostgreSQL,compose 内置 `postgres:16` 服务。
|
- 运行时数据库是 PostgreSQL,compose 内置 `postgres:16` 服务。
|
||||||
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。
|
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。
|
||||||
|
- 链上主数据源是 NodeReal;`.env` 中配置 `ALPHAX_NODEREAL_API_KEY` 后,`python -m app.cli onchain` 才会产出 NodeReal 链上事件。
|
||||||
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
|
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
|
||||||
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
|
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
|
||||||
|
|
||||||
|
|||||||
@ -68,16 +68,22 @@ def default_llm_config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum", "solana")):
|
def default_onchain_config(default_chains=("ethereum", "bsc")):
|
||||||
return {
|
return {
|
||||||
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
|
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
|
||||||
"chains": _env_list("ALPHAX_ONCHAIN_CHAINS", default_chains),
|
"chains": _env_list("ALPHAX_ONCHAIN_CHAINS", default_chains),
|
||||||
"timeout": _env_int("ALPHAX_ONCHAIN_TIMEOUT", 15),
|
"timeout": _env_int("ALPHAX_ONCHAIN_TIMEOUT", 15),
|
||||||
|
"provider": _env_str("ALPHAX_ONCHAIN_PROVIDER", "nodereal"),
|
||||||
|
"nodereal_enabled": _env_bool("ALPHAX_NODEREAL_ENABLED", True),
|
||||||
|
"nodereal_chains": _env_list("ALPHAX_NODEREAL_CHAINS", ("ethereum", "bsc")),
|
||||||
|
"nodereal_api_key_env": "ALPHAX_NODEREAL_API_KEY",
|
||||||
|
"nodereal_log_block_lookback": _env_int("ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK", 120),
|
||||||
|
"nodereal_max_logs_per_token": _env_int("ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN", 25),
|
||||||
"candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
|
"candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
|
||||||
"candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
|
"candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
|
||||||
"candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),
|
"candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),
|
||||||
"candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6),
|
"candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6),
|
||||||
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", True),
|
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", False),
|
||||||
"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),
|
||||||
@ -86,9 +92,9 @@ def default_onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum"
|
|||||||
"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_enabled": _env_bool("ALPHAX_ETHERSCAN_ENABLED", False),
|
||||||
"etherscan_chains": _env_list("ALPHAX_ETHERSCAN_CHAINS", ("ethereum",)),
|
"etherscan_chains": _env_list("ALPHAX_ETHERSCAN_CHAINS", ("ethereum",)),
|
||||||
"helius_enabled": _env_bool("ALPHAX_HELIUS_ENABLED", True),
|
"helius_enabled": _env_bool("ALPHAX_HELIUS_ENABLED", False),
|
||||||
"etherscan_base_url": _env_str("ALPHAX_ETHERSCAN_BASE_URL", "https://api.etherscan.io/v2/api"),
|
"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"),
|
"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",
|
||||||
@ -334,7 +340,7 @@ def llm_config():
|
|||||||
return cfg or default_llm_config()
|
return cfg or default_llm_config()
|
||||||
|
|
||||||
|
|
||||||
def onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum", "solana")):
|
def onchain_config(default_chains=("ethereum", "bsc")):
|
||||||
cfg = get_onchain_config(default=None)
|
cfg = get_onchain_config(default=None)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
_seed_one("onchain", default_onchain_config(default_chains), "On-chain provider and signal thresholds; API keys remain in env")
|
_seed_one("onchain", default_onchain_config(default_chains), "On-chain provider and signal thresholds; API keys remain in env")
|
||||||
|
|||||||
@ -25,6 +25,7 @@ SIGNAL_LABELS = {
|
|||||||
"exchange_outflow": "交易所流出",
|
"exchange_outflow": "交易所流出",
|
||||||
"exchange_inflow_risk": "交易所流入风险",
|
"exchange_inflow_risk": "交易所流入风险",
|
||||||
"whale_accumulation": "鲸鱼增持",
|
"whale_accumulation": "鲸鱼增持",
|
||||||
|
"holder_growth": "持有人增长",
|
||||||
"holder_concentration_risk": "持仓集中风险",
|
"holder_concentration_risk": "持仓集中风险",
|
||||||
"smart_money_buying": "聪明钱买入",
|
"smart_money_buying": "聪明钱买入",
|
||||||
}
|
}
|
||||||
@ -53,7 +54,7 @@ RAW_EVENT_EXPLAINERS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
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", "holder_growth", "smart_money_buying"}
|
||||||
RISK_SIGNALS = {"liquidity_remove_risk", "exchange_inflow_risk", "holder_concentration_risk"}
|
RISK_SIGNALS = {"liquidity_remove_risk", "exchange_inflow_risk", "holder_concentration_risk"}
|
||||||
|
|
||||||
|
|
||||||
@ -468,6 +469,7 @@ def get_onchain_provider_status(hours=24):
|
|||||||
cfg = onchain_config()
|
cfg = onchain_config()
|
||||||
hours = int(hours or 24)
|
hours = int(hours or 24)
|
||||||
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
|
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
|
||||||
|
nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
|
||||||
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 = cfg.get("etherscan_chains") or ["ethereum"]
|
etherscan_chains = cfg.get("etherscan_chains") or ["ethereum"]
|
||||||
@ -535,23 +537,45 @@ def get_onchain_provider_status(hours=24):
|
|||||||
|
|
||||||
summary = _load(last_onchain.get("summary_json") if last_onchain else "{}", {}) if last_onchain else {}
|
summary = _load(last_onchain.get("summary_json") if last_onchain else "{}", {}) if last_onchain else {}
|
||||||
last_error = last_onchain.get("error_message") if last_onchain else ""
|
last_error = last_onchain.get("error_message") if last_onchain else ""
|
||||||
|
provider = str(cfg.get("provider") or "nodereal").strip().lower()
|
||||||
|
dexscreener_enabled = bool(cfg.get("dexscreener_enabled", False)) and provider != "nodereal"
|
||||||
|
etherscan_enabled = bool(cfg.get("etherscan_enabled", False)) and provider != "nodereal"
|
||||||
|
helius_enabled = bool(cfg.get("helius_enabled", False)) and provider != "nodereal"
|
||||||
providers = [
|
providers = [
|
||||||
|
{
|
||||||
|
"provider": "nodereal",
|
||||||
|
"label": "NodeReal",
|
||||||
|
"enabled": bool(cfg.get("nodereal_enabled", True)) and provider == "nodereal",
|
||||||
|
"api_key_present": bool(os.getenv(nodereal_env, "").strip()),
|
||||||
|
"implemented": True,
|
||||||
|
"role": "EVM 主链上数据源:Transfer 日志、大额转账、holder 变化",
|
||||||
|
"raw_events": 0,
|
||||||
|
"metrics": int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal")),
|
||||||
|
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal")),
|
||||||
|
"status": _provider_status_label(
|
||||||
|
bool(cfg.get("nodereal_enabled", True)),
|
||||||
|
True,
|
||||||
|
int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal"))
|
||||||
|
+ int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal")),
|
||||||
|
last_error if "nodereal" in str(last_error).lower() else "",
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"provider": "dexscreener",
|
"provider": "dexscreener",
|
||||||
"label": "DEX Screener",
|
"label": "DEX Screener",
|
||||||
"enabled": bool(cfg.get("dexscreener_enabled", True)),
|
"enabled": dexscreener_enabled,
|
||||||
"api_key_present": True,
|
"api_key_present": True,
|
||||||
"implemented": True,
|
"implemented": True,
|
||||||
"role": "低优先级曝光源:Token 资料、付费推广、已映射合约的 DEX 成交量与流动性",
|
"role": "低优先级曝光源:Token 资料、付费推广、已映射合约的 DEX 成交量与流动性",
|
||||||
"raw_events": int(raw_total or 0),
|
"raw_events": int(raw_total or 0),
|
||||||
"metrics": int(metric_total or 0),
|
"metrics": int(metric_total or 0),
|
||||||
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "dexscreener")),
|
"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),
|
"status": _provider_status_label(dexscreener_enabled, True, raw_total + metric_total, last_error),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "etherscan",
|
"provider": "etherscan",
|
||||||
"label": "Etherscan",
|
"label": "Etherscan",
|
||||||
"enabled": bool(cfg.get("etherscan_enabled", True)),
|
"enabled": etherscan_enabled,
|
||||||
"api_key_present": bool(os.getenv(etherscan_env, "").strip()),
|
"api_key_present": bool(os.getenv(etherscan_env, "").strip()),
|
||||||
"implemented": True,
|
"implemented": True,
|
||||||
"role": "EVM 已映射合约的 ERC20 大额转账,当前链: " + ", ".join(etherscan_chains or ["ethereum"]),
|
"role": "EVM 已映射合约的 ERC20 大额转账,当前链: " + ", ".join(etherscan_chains or ["ethereum"]),
|
||||||
@ -559,7 +583,7 @@ def get_onchain_provider_status(hours=24):
|
|||||||
"metrics": 0,
|
"metrics": 0,
|
||||||
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
|
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
|
||||||
"status": _provider_status_label(
|
"status": _provider_status_label(
|
||||||
bool(cfg.get("etherscan_enabled", True)),
|
etherscan_enabled,
|
||||||
True,
|
True,
|
||||||
int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
|
int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
|
||||||
last_error if "etherscan" in str(last_error).lower() else "",
|
last_error if "etherscan" in str(last_error).lower() else "",
|
||||||
@ -568,7 +592,7 @@ def get_onchain_provider_status(hours=24):
|
|||||||
{
|
{
|
||||||
"provider": "helius",
|
"provider": "helius",
|
||||||
"label": "Helius",
|
"label": "Helius",
|
||||||
"enabled": bool(cfg.get("helius_enabled", True)),
|
"enabled": helius_enabled,
|
||||||
"api_key_present": bool(os.getenv(helius_env, "").strip()),
|
"api_key_present": bool(os.getenv(helius_env, "").strip()),
|
||||||
"implemented": True,
|
"implemented": True,
|
||||||
"role": "Solana 已映射 mint 的解析交易与大额 token 活动",
|
"role": "Solana 已映射 mint 的解析交易与大额 token 活动",
|
||||||
@ -576,7 +600,7 @@ def get_onchain_provider_status(hours=24):
|
|||||||
"metrics": 0,
|
"metrics": 0,
|
||||||
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
|
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
|
||||||
"status": _provider_status_label(
|
"status": _provider_status_label(
|
||||||
bool(cfg.get("helius_enabled", True)),
|
helius_enabled,
|
||||||
True,
|
True,
|
||||||
int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
|
int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
|
||||||
last_error if "helius" in str(last_error).lower() else "",
|
last_error if "helius" in str(last_error).lower() else "",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from app.config.system_config import paper_trading_config
|
from app.config.system_config import paper_trading_config
|
||||||
from app.db.schema import get_conn
|
from app.db.schema import get_conn
|
||||||
|
from app.db.system_logs import record_system_error
|
||||||
from app.integrations.feishu_push import push_card
|
from app.integrations.feishu_push import push_card
|
||||||
|
|
||||||
|
|
||||||
@ -267,7 +268,7 @@ def _push_paper_card(event_type: str, symbol: str, title: str, template: str, fi
|
|||||||
elements.append(_card_note(note))
|
elements.append(_card_note(note))
|
||||||
if event_time:
|
if event_time:
|
||||||
elements.append(_card_note(f"时间: {event_time}"))
|
elements.append(_card_note(f"时间: {event_time}"))
|
||||||
push_card({
|
ok, result = push_card({
|
||||||
"metadata": {"source": "paper_trading", "event_type": event_type, "symbol": symbol},
|
"metadata": {"source": "paper_trading", "event_type": event_type, "symbol": symbol},
|
||||||
"config": {"wide_screen_mode": True},
|
"config": {"wide_screen_mode": True},
|
||||||
"header": {
|
"header": {
|
||||||
@ -276,8 +277,26 @@ def _push_paper_card(event_type: str, symbol: str, title: str, template: str, fi
|
|||||||
},
|
},
|
||||||
"elements": elements,
|
"elements": elements,
|
||||||
})
|
})
|
||||||
except Exception:
|
if not ok:
|
||||||
pass
|
record_system_error(
|
||||||
|
source="paper_trading",
|
||||||
|
level="warning",
|
||||||
|
error_type="FeishuPushFailed",
|
||||||
|
message=f"Feishu push failed for {event_type} {symbol}: {str(result)[:500]}",
|
||||||
|
status_code=0,
|
||||||
|
context={"event_type": event_type, "symbol": symbol, "push_result": result},
|
||||||
|
fingerprint=f"paper_trading_feishu_push_failed:{event_type}:{symbol}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
record_system_error(
|
||||||
|
source="paper_trading",
|
||||||
|
level="warning",
|
||||||
|
error_type=exc.__class__.__name__,
|
||||||
|
message=f"Feishu push exception for {event_type} {symbol}: {str(exc)[:500]}",
|
||||||
|
status_code=0,
|
||||||
|
context={"event_type": event_type, "symbol": symbol},
|
||||||
|
fingerprint=f"paper_trading_feishu_push_exception:{event_type}:{symbol}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _push_custom_paper_card(card: dict) -> tuple[bool, object]:
|
def _push_custom_paper_card(card: dict) -> tuple[bool, object]:
|
||||||
@ -306,16 +325,18 @@ def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
if event_type == "close":
|
if event_type == "close":
|
||||||
|
exit_reason = str(result.get("exit_reason") or "--")
|
||||||
|
title_prefix = "移动止盈成交平仓" if exit_reason == "trailing_stop" else "交易平仓"
|
||||||
_push_paper_card(
|
_push_paper_card(
|
||||||
event_type,
|
event_type,
|
||||||
symbol,
|
symbol,
|
||||||
f"交易平仓 - {short_symbol}",
|
f"{title_prefix} - {short_symbol}",
|
||||||
"red" if _safe_float(result.get("pnl_usdt")) < 0 else "green",
|
"red" if _safe_float(result.get("pnl_usdt")) < 0 else "green",
|
||||||
[
|
[
|
||||||
("退出价", _fmt_price(result.get("exit_price"))),
|
("退出价", _fmt_price(result.get("exit_price"))),
|
||||||
("收益率", _fmt_pct(result.get("pnl_pct"))),
|
("收益率", _fmt_pct(result.get("pnl_pct"))),
|
||||||
("收益额", f"{_safe_float(result.get('pnl_usdt')):.2f} USDT"),
|
("收益额", f"{_safe_float(result.get('pnl_usdt')):.2f} USDT"),
|
||||||
("原因", result.get("exit_reason") or "--"),
|
("原因", exit_reason),
|
||||||
],
|
],
|
||||||
"收益以交易账本记录为准。",
|
"收益以交易账本记录为准。",
|
||||||
event_time,
|
event_time,
|
||||||
|
|||||||
90
app/services/nodereal_client.py
Normal file
90
app/services/nodereal_client.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""Small JSON-RPC client for NodeReal MegaNode.
|
||||||
|
|
||||||
|
The on-chain monitor only needs a narrow subset of NodeReal right now:
|
||||||
|
standard EVM logs plus a few enhanced token APIs. Keeping this adapter small
|
||||||
|
prevents provider-specific details from leaking into strategy code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CHAIN_ENDPOINTS = {
|
||||||
|
"ethereum": "https://eth-mainnet.nodereal.io/v1/{api_key}",
|
||||||
|
"bsc": "https://bsc-mainnet.nodereal.io/v1/{api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NodeRealConfig:
|
||||||
|
api_key: str
|
||||||
|
timeout: int = 15
|
||||||
|
endpoints: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeRealClient:
|
||||||
|
def __init__(self, config: NodeRealConfig):
|
||||||
|
self.config = config
|
||||||
|
self.endpoints = {**DEFAULT_CHAIN_ENDPOINTS, **(config.endpoints or {})}
|
||||||
|
|
||||||
|
def supports_chain(self, chain: str) -> bool:
|
||||||
|
return bool(self._endpoint(chain))
|
||||||
|
|
||||||
|
def call(self, chain: str, method: str, params: list[Any] | None = None) -> Any:
|
||||||
|
endpoint = self._endpoint(chain)
|
||||||
|
if not endpoint:
|
||||||
|
raise ValueError(f"nodereal_chain_not_configured:{chain}")
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": method,
|
||||||
|
"params": params or [],
|
||||||
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
endpoint,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.config.timeout,
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "AlphaX-Agent-Crypto/1.0"},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"nodereal_http_{resp.status_code}:{resp.text[:200]}")
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("error"):
|
||||||
|
raise RuntimeError(f"nodereal_rpc_error:{data['error']}")
|
||||||
|
return data.get("result")
|
||||||
|
|
||||||
|
def block_number(self, chain: str) -> int:
|
||||||
|
return _hex_to_int(self.call(chain, "eth_blockNumber", []))
|
||||||
|
|
||||||
|
def get_logs(self, chain: str, log_filter: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
result = self.call(chain, "eth_getLogs", [log_filter])
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
def token_holder_count(self, chain: str, contract_address: str) -> int:
|
||||||
|
return _hex_to_int(self.call(chain, "nr_getTokenHolderCount", [contract_address]))
|
||||||
|
|
||||||
|
def _endpoint(self, chain: str) -> str:
|
||||||
|
chain_key = str(chain or "").lower().strip()
|
||||||
|
template = self.endpoints.get(chain_key, "")
|
||||||
|
if not template or not self.config.api_key:
|
||||||
|
return ""
|
||||||
|
return template.format(api_key=self.config.api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_int(value: Any) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(text, 16) if text.startswith("0x") else int(text)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ import requests
|
|||||||
from app.config.system_config import onchain_config
|
from app.config.system_config import onchain_config
|
||||||
from app.db import onchain_db
|
from app.db import onchain_db
|
||||||
from app.db.altcoin_db import get_conn, init_db, log_cron_run
|
from app.db.altcoin_db import get_conn, init_db, log_cron_run
|
||||||
|
from app.db.tracking_queries import get_latest_price_cache
|
||||||
from app.db.onchain_db import (
|
from app.db.onchain_db import (
|
||||||
MIN_MAPPING_CONFIDENCE,
|
MIN_MAPPING_CONFIDENCE,
|
||||||
POSITIVE_SIGNALS,
|
POSITIVE_SIGNALS,
|
||||||
@ -31,9 +32,10 @@ from app.db.onchain_db import (
|
|||||||
)
|
)
|
||||||
from app.services.event_driven_screener import _event_hash as event_hash
|
from app.services.event_driven_screener import _event_hash as event_hash
|
||||||
from app.services.event_driven_screener import _tradable_symbol, init_event_tables
|
from app.services.event_driven_screener import _tradable_symbol, init_event_tables
|
||||||
|
from app.services.nodereal_client import DEFAULT_CHAIN_ENDPOINTS, NodeRealClient, NodeRealConfig
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CHAINS = ("ethereum", "bsc", "base", "arbitrum", "solana")
|
DEFAULT_CHAINS = ("ethereum", "bsc")
|
||||||
ETHERSCAN_CHAIN_IDS = {
|
ETHERSCAN_CHAIN_IDS = {
|
||||||
"ethereum": "1",
|
"ethereum": "1",
|
||||||
"bsc": "56",
|
"bsc": "56",
|
||||||
@ -70,6 +72,7 @@ DEXSCREENER_RAW_ENDPOINTS = (
|
|||||||
("token_boost_latest", "https://api.dexscreener.com/token-boosts/latest/v1"),
|
("token_boost_latest", "https://api.dexscreener.com/token-boosts/latest/v1"),
|
||||||
("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"),
|
("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"),
|
||||||
)
|
)
|
||||||
|
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name, default=False):
|
def _env_bool(name, default=False):
|
||||||
@ -103,6 +106,7 @@ 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")
|
||||||
|
nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
|
||||||
etherscan_chains_raw = cfg.get("etherscan_chains") or ["ethereum"]
|
etherscan_chains_raw = cfg.get("etherscan_chains") or ["ethereum"]
|
||||||
if isinstance(etherscan_chains_raw, str):
|
if isinstance(etherscan_chains_raw, str):
|
||||||
etherscan_chains = [x.strip().lower() for x in etherscan_chains_raw.split(",") if x.strip()]
|
etherscan_chains = [x.strip().lower() for x in etherscan_chains_raw.split(",") if x.strip()]
|
||||||
@ -110,8 +114,15 @@ def get_onchain_params():
|
|||||||
etherscan_chains = [str(x).strip().lower() for x in etherscan_chains_raw if str(x).strip()]
|
etherscan_chains = [str(x).strip().lower() for x in etherscan_chains_raw if str(x).strip()]
|
||||||
return {
|
return {
|
||||||
"enabled": bool(cfg.get("enabled", False)),
|
"enabled": bool(cfg.get("enabled", False)),
|
||||||
|
"provider": str(cfg.get("provider") or "nodereal").strip().lower(),
|
||||||
"chains": chains or list(DEFAULT_CHAINS),
|
"chains": chains or list(DEFAULT_CHAINS),
|
||||||
"timeout": int(cfg.get("timeout") or 15),
|
"timeout": int(cfg.get("timeout") or 15),
|
||||||
|
"nodereal_enabled": bool(cfg.get("nodereal_enabled", True)),
|
||||||
|
"nodereal_chains": _normalize_chain_list(cfg.get("nodereal_chains") or ("ethereum", "bsc")),
|
||||||
|
"nodereal_api_key": os.getenv(nodereal_env, "").strip(),
|
||||||
|
"nodereal_api_key_env": nodereal_env,
|
||||||
|
"nodereal_log_block_lookback": int(cfg.get("nodereal_log_block_lookback") or 120),
|
||||||
|
"nodereal_max_logs_per_token": int(cfg.get("nodereal_max_logs_per_token") or 25),
|
||||||
"candidate_enabled": bool(cfg.get("candidate_enabled", True)),
|
"candidate_enabled": bool(cfg.get("candidate_enabled", True)),
|
||||||
"candidate_min_score": float(cfg.get("candidate_min_score") or 70),
|
"candidate_min_score": float(cfg.get("candidate_min_score") or 70),
|
||||||
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
|
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
|
||||||
@ -135,6 +146,12 @@ def get_onchain_params():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chain_list(value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [x.strip().lower() for x in value.split(",") if x.strip()]
|
||||||
|
return [str(x).strip().lower() for x in (value or []) if str(x).strip()]
|
||||||
|
|
||||||
|
|
||||||
def _now():
|
def _now():
|
||||||
return datetime.now()
|
return datetime.now()
|
||||||
|
|
||||||
@ -664,17 +681,209 @@ def _event_from_etherscan_transfer(row, mapping, cfg=None):
|
|||||||
|
|
||||||
|
|
||||||
def _latest_price_from_metric(mapping):
|
def _latest_price_from_metric(mapping):
|
||||||
latest = _latest_metric(
|
symbol = normalize_symbol(mapping.get("symbol"))
|
||||||
normalize_symbol(mapping.get("symbol")),
|
chain = str(mapping.get("chain") or "").lower()
|
||||||
str(mapping.get("chain") or "").lower(),
|
contract = str(mapping.get("contract_address") or "")
|
||||||
str(mapping.get("contract_address") or ""),
|
conn = get_conn()
|
||||||
)
|
|
||||||
raw = {}
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(latest.get("raw_json") or "{}") if latest else {}
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT raw_json
|
||||||
|
FROM onchain_token_metrics
|
||||||
|
WHERE symbol=%s AND chain=%s AND contract_address=%s
|
||||||
|
ORDER BY metric_time DESC, id DESC
|
||||||
|
LIMIT 8
|
||||||
|
""",
|
||||||
|
(symbol, chain, contract),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
raw = json.loads(row.get("raw_json") or "{}")
|
||||||
|
except Exception:
|
||||||
|
raw = {}
|
||||||
|
price = _safe_float(raw.get("price_usd"))
|
||||||
|
if price > 0:
|
||||||
|
return price
|
||||||
|
cache = get_latest_price_cache([symbol])
|
||||||
|
item = cache.get(symbol) or {}
|
||||||
|
return _safe_float(item.get("price"))
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_int(value):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(text, 16) if text.startswith("0x") else int(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
raw = {}
|
return 0
|
||||||
return _safe_float(raw.get("price_usd"))
|
|
||||||
|
|
||||||
|
def _topic_to_address(topic):
|
||||||
|
topic = str(topic or "").lower()
|
||||||
|
if topic.startswith("0x") and len(topic) >= 42:
|
||||||
|
return "0x" + topic[-40:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _nodereal_client(cfg=None):
|
||||||
|
cfg = cfg or get_onchain_params()
|
||||||
|
return NodeRealClient(
|
||||||
|
NodeRealConfig(
|
||||||
|
api_key=cfg.get("nodereal_api_key") or "",
|
||||||
|
timeout=int(cfg.get("timeout") or 15),
|
||||||
|
endpoints=dict(DEFAULT_CHAIN_ENDPOINTS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_from_nodereal_transfer(log, mapping, cfg=None):
|
||||||
|
cfg = cfg or get_onchain_params()
|
||||||
|
topics = log.get("topics") or []
|
||||||
|
if len(topics) < 3:
|
||||||
|
return None
|
||||||
|
amount_raw = _hex_to_int(log.get("data"))
|
||||||
|
mapping_raw = {}
|
||||||
|
try:
|
||||||
|
mapping_raw = json.loads(mapping.get("raw_json") or "{}")
|
||||||
|
except Exception:
|
||||||
|
mapping_raw = {}
|
||||||
|
decimals = _safe_int(mapping_raw.get("decimals") or mapping_raw.get("tokenDecimal") or 18, 18)
|
||||||
|
amount = amount_raw / (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 or value_usd < threshold:
|
||||||
|
return None
|
||||||
|
chain = str(mapping.get("chain") or "").lower()
|
||||||
|
tx_hash = str(log.get("transactionHash") or "").strip()
|
||||||
|
return {
|
||||||
|
"chain": chain,
|
||||||
|
"symbol": mapping.get("symbol"),
|
||||||
|
"contract_address": mapping.get("contract_address") or "",
|
||||||
|
"event_type": "token_transfer",
|
||||||
|
"signal_code": "whale_accumulation",
|
||||||
|
"signal_label": signal_label("whale_accumulation"),
|
||||||
|
"direction": "positive",
|
||||||
|
"value_usd": value_usd,
|
||||||
|
"amount": amount,
|
||||||
|
"tx_hash": tx_hash,
|
||||||
|
"wallet_address": _topic_to_address(topics[2]),
|
||||||
|
"wallet_label": "EVM 接收地址",
|
||||||
|
"counterparty_label": "EVM 发送地址 " + _short_addr(_topic_to_address(topics[1])),
|
||||||
|
"confidence": 76,
|
||||||
|
"severity": "A",
|
||||||
|
"detected_at": _now().isoformat(timespec="seconds"),
|
||||||
|
"source": "nodereal",
|
||||||
|
"url": _chain_explorer_tx_url(chain, tx_hash),
|
||||||
|
"raw": log,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_from_nodereal_holder_count(holder_count, mapping):
|
||||||
|
symbol = normalize_symbol(mapping.get("symbol"))
|
||||||
|
chain = str(mapping.get("chain") or "").lower()
|
||||||
|
contract = str(mapping.get("contract_address") or "")
|
||||||
|
prev = _latest_metric(symbol, chain, contract)
|
||||||
|
prev_count = 0
|
||||||
|
if prev:
|
||||||
|
try:
|
||||||
|
prev_raw = json.loads(prev.get("raw_json") or "{}")
|
||||||
|
prev_count = _safe_int(prev_raw.get("holder_count"))
|
||||||
|
except Exception:
|
||||||
|
prev_count = 0
|
||||||
|
holder_delta = holder_count - prev_count if prev_count > 0 else 0
|
||||||
|
metric = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"chain": chain,
|
||||||
|
"contract_address": contract,
|
||||||
|
"window": "1h",
|
||||||
|
"metric_time": _now().isoformat(timespec="seconds"),
|
||||||
|
"holder_delta": holder_delta,
|
||||||
|
"smart_money_score": 0,
|
||||||
|
"source": "nodereal",
|
||||||
|
"raw": {
|
||||||
|
"holder_count": holder_count,
|
||||||
|
"previous_holder_count": prev_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if holder_delta > 0:
|
||||||
|
metric["onchain_score"] = min(30, holder_delta)
|
||||||
|
elif holder_delta < 0:
|
||||||
|
metric["risk_score"] = min(30, abs(holder_delta))
|
||||||
|
return metric
|
||||||
|
|
||||||
|
|
||||||
|
def _event_from_holder_metric(metric):
|
||||||
|
holder_delta = _safe_float(metric.get("holder_delta"))
|
||||||
|
if holder_delta <= 0:
|
||||||
|
return None
|
||||||
|
if holder_delta < 20:
|
||||||
|
return None
|
||||||
|
return _event_from_metric(metric, "holder_growth", source="nodereal")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_nodereal_events(limit=60):
|
||||||
|
cfg = get_onchain_params()
|
||||||
|
if not cfg.get("nodereal_enabled", True):
|
||||||
|
return {"metrics": [], "events": [], "errors": ["nodereal_disabled"]}
|
||||||
|
if not cfg.get("nodereal_api_key"):
|
||||||
|
return {"metrics": [], "events": [], "errors": ["nodereal_api_key_missing"]}
|
||||||
|
client = _nodereal_client(cfg)
|
||||||
|
enabled_chains = set(cfg.get("nodereal_chains") or DEFAULT_CHAINS)
|
||||||
|
mappings = [
|
||||||
|
m for m in get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
|
||||||
|
if str(m.get("chain") or "").lower() in enabled_chains and client.supports_chain(str(m.get("chain") or "").lower())
|
||||||
|
]
|
||||||
|
metrics = []
|
||||||
|
events = []
|
||||||
|
errors = []
|
||||||
|
lookback = max(1, int(cfg.get("nodereal_log_block_lookback") or 120))
|
||||||
|
max_logs = max(1, int(cfg.get("nodereal_max_logs_per_token") or 25))
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
holder_count = client.token_holder_count(chain, contract)
|
||||||
|
if holder_count:
|
||||||
|
metric = _metric_from_nodereal_holder_count(holder_count, mapping)
|
||||||
|
insert_token_metric(metric)
|
||||||
|
metrics.append(metric)
|
||||||
|
holder_event = _event_from_holder_metric(metric)
|
||||||
|
if holder_event and insert_onchain_event(holder_event):
|
||||||
|
events.append(holder_event)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"{mapping.get('symbol')}:nodereal_holder:{str(exc)[:160]}")
|
||||||
|
try:
|
||||||
|
latest = client.block_number(chain)
|
||||||
|
if latest <= 0:
|
||||||
|
continue
|
||||||
|
logs = client.get_logs(
|
||||||
|
chain,
|
||||||
|
{
|
||||||
|
"address": contract,
|
||||||
|
"fromBlock": hex(max(0, latest - lookback)),
|
||||||
|
"toBlock": hex(latest),
|
||||||
|
"topics": [TRANSFER_TOPIC],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for log in logs[:max_logs]:
|
||||||
|
if not isinstance(log, dict):
|
||||||
|
continue
|
||||||
|
event = _event_from_nodereal_transfer(log, 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')}:nodereal_logs:{str(exc)[:160]}")
|
||||||
|
if not mappings:
|
||||||
|
errors.append("nodereal_no_supported_mappings")
|
||||||
|
return {"metrics": metrics, "events": events, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
def fetch_etherscan_events(limit=60):
|
def fetch_etherscan_events(limit=60):
|
||||||
@ -959,32 +1168,17 @@ def run_once(limit=60):
|
|||||||
"check_time": _now().isoformat(),
|
"check_time": _now().isoformat(),
|
||||||
}
|
}
|
||||||
if cfg.get("enabled"):
|
if cfg.get("enabled"):
|
||||||
raw = fetch_dexscreener_raw_events(limit=limit)
|
node = fetch_nodereal_events(limit=limit)
|
||||||
output["raw_events_count"] = len(raw.get("raw_events") or [])
|
output["metrics_count"] += len(node.get("metrics") or [])
|
||||||
output["errors"].extend(raw.get("errors") or [])
|
output["events_count"] += len(node.get("events") or [])
|
||||||
dex = fetch_dexscreener_metrics(limit=limit)
|
output["errors"].extend(node.get("errors") or [])
|
||||||
output["metrics_count"] += len(dex.get("metrics") or [])
|
output["discovered_mappings"] = 0
|
||||||
output["events_count"] += len(dex.get("events") 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
|
|
||||||
if output.get("discovered_mappings"):
|
if output.get("discovered_mappings"):
|
||||||
output["status"] = "bootstrapped"
|
output["status"] = "bootstrapped"
|
||||||
dex = fetch_dexscreener_metrics(limit=limit)
|
node = fetch_nodereal_events(limit=limit)
|
||||||
output["metrics_count"] = len(dex.get("metrics") or [])
|
output["metrics_count"] = len(node.get("metrics") or [])
|
||||||
output["events_count"] = len(dex.get("events") or [])
|
output["events_count"] = len(node.get("events") or [])
|
||||||
output["errors"].extend(dex.get("errors") or [])
|
output["errors"].extend(node.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", [])
|
||||||
@ -1020,6 +1214,7 @@ __all__ = [
|
|||||||
"fetch_dexscreener_raw_events",
|
"fetch_dexscreener_raw_events",
|
||||||
"fetch_etherscan_events",
|
"fetch_etherscan_events",
|
||||||
"fetch_helius_events",
|
"fetch_helius_events",
|
||||||
|
"fetch_nodereal_events",
|
||||||
"get_onchain_params",
|
"get_onchain_params",
|
||||||
"ingest_normalized_events",
|
"ingest_normalized_events",
|
||||||
"normalize_dexscreener_pair",
|
"normalize_dexscreener_pair",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from fastapi.responses import HTMLResponse, Response
|
|||||||
from app.config.system_config import seed_runtime_system_defaults
|
from app.config.system_config import seed_runtime_system_defaults
|
||||||
from app.db import auth_db
|
from app.db import auth_db
|
||||||
from app.db import chat_assistant_db
|
from app.db import chat_assistant_db
|
||||||
|
from app.db.analytics import get_cron_run_logs, get_cron_run_summary, get_pipeline_runs
|
||||||
from app.db.data_export import build_data_export_bundle
|
from app.db.data_export import build_data_export_bundle
|
||||||
from app.db.scheduler_db import (
|
from app.db.scheduler_db import (
|
||||||
enqueue_manual_trigger,
|
enqueue_manual_trigger,
|
||||||
@ -86,6 +87,21 @@ def build_router(templates):
|
|||||||
raise HTTPException(status_code=404, detail="日志不存在")
|
raise HTTPException(status_code=404, detail="日志不存在")
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
@router.get("/api/admin/cron-runs")
|
||||||
|
async def api_admin_cron_runs(limit: int = 80, job_name: str = "", altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
return {"items": get_cron_run_logs(limit=limit, job_name=job_name or None)}
|
||||||
|
|
||||||
|
@router.get("/api/admin/cron-runs/summary")
|
||||||
|
async def api_admin_cron_run_summary(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
return get_cron_run_summary(hours=hours)
|
||||||
|
|
||||||
|
@router.get("/api/admin/pipeline-runs")
|
||||||
|
async def api_admin_pipeline_runs(limit: int = 30, hours: int = 24, offset: int = 0, altcoin_session: str = Cookie(default="")):
|
||||||
|
require_admin(altcoin_session)
|
||||||
|
return get_pipeline_runs(limit=limit, hours=hours, offset=offset)
|
||||||
|
|
||||||
@router.get("/api/admin/chat-logs/overview")
|
@router.get("/api/admin/chat-logs/overview")
|
||||||
async def api_admin_chat_logs_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
async def api_admin_chat_logs_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||||
require_admin(altcoin_session)
|
require_admin(altcoin_session)
|
||||||
|
|||||||
@ -96,6 +96,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
|||||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||||
return render_page("system_logs.html", request, active_nav="system_logs")
|
return render_page("system_logs.html", request, active_nav="system_logs")
|
||||||
|
|
||||||
|
@router.get("/logs", response_class=HTMLResponse)
|
||||||
|
async def logs_page(request: Request):
|
||||||
|
user, redirect = require_page_user(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
try:
|
||||||
|
require_admin(request.cookies.get("altcoin_session", ""))
|
||||||
|
except HTTPException as exc:
|
||||||
|
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||||
|
return render_page("logs.html", request, active_nav="logs")
|
||||||
|
|
||||||
@router.get("/data-export", response_class=HTMLResponse)
|
@router.get("/data-export", response_class=HTMLResponse)
|
||||||
async def data_export_page(request: Request):
|
async def data_export_page(request: Request):
|
||||||
user, redirect = require_page_user(request)
|
user, redirect = require_page_user(request)
|
||||||
|
|||||||
@ -185,15 +185,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<div class="sidebar-section-label admin-link" style="display:none">管理员菜单</div>
|
<div class="sidebar-section-label admin-link" style="display:none">管理员菜单</div>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading" style="display:none"><svg class="link-icon"><use href="#svg-paper"/></svg>策略交易</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading" style="display:none"><svg class="link-icon"><use href="#svg-paper"/></svg>策略交易</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'pipeline' %}active{% endif %}" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
|
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略归因</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略归因</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>策略迭代</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>策略迭代</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'chat_logs' %}active{% endif %}" href="/chat-logs" style="display:none"><svg class="link-icon"><use href="#svg-chat"/></svg>问答日志</a>
|
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
<a class="sidebar-link {% if active_nav == 'system_logs' %}active{% endif %} admin-link" href="/system-logs" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>系统日志</a>
|
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>用户管理</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>用户管理</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-user" onclick="toggleUserMenu()">
|
<div class="sidebar-user" onclick="toggleUserMenu()">
|
||||||
|
|||||||
161
static/logs.html
Normal file
161
static/logs.html
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}日志中心 · AlphaX Agent{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head_css %}
|
||||||
|
<style>
|
||||||
|
main{max-width:1380px;margin:0 auto;width:100%;padding:24px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap}
|
||||||
|
.page-title{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:-.7px}
|
||||||
|
.page-sub{margin-top:5px;font-size:13px;color:var(--stone);line-height:1.55;max-width:860px}
|
||||||
|
.ops-strip{display:grid;grid-template-columns:1.25fr repeat(3,minmax(0,1fr));gap:10px}
|
||||||
|
.ops-card{position:relative;overflow:hidden;border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);background:linear-gradient(145deg,#fff 0%,#f7f8fa 100%);padding:15px;min-height:92px}
|
||||||
|
.ops-card::after{content:"";position:absolute;right:-32px;top:-48px;width:120px;height:120px;border-radius:50%;background:rgba(255,208,47,.22)}
|
||||||
|
.ops-card span{display:block;color:var(--stone);font-size:11px;font-weight:950;letter-spacing:.04em}
|
||||||
|
.ops-card b{display:block;margin-top:8px;color:var(--ink);font-size:24px;line-height:1;font-weight:950;letter-spacing:-.5px}
|
||||||
|
.ops-card small{display:block;margin-top:8px;color:var(--slate);font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.tabs{display:flex;gap:8px;padding:6px;border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);background:var(--canvas);width:max-content;max-width:100%;overflow:auto}
|
||||||
|
.tab-btn{height:38px;border:0;background:transparent;border-radius:10px;padding:0 14px;font-size:13px;font-weight:950;color:var(--stone);cursor:pointer;white-space:nowrap}
|
||||||
|
.tab-btn.active{background:var(--primary);color:var(--on-primary)}
|
||||||
|
.panel{display:none;border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-lg);overflow:hidden}
|
||||||
|
.panel.active{display:block}
|
||||||
|
.toolbar{display:flex;gap:8px;flex-wrap:wrap;padding:14px;border-bottom:1px solid var(--hairline-soft);background:linear-gradient(180deg,#fff,#fafbfc)}
|
||||||
|
.toolbar input,.toolbar select{height:38px;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);padding:0 12px;font-size:13px;color:var(--ink);outline:none}
|
||||||
|
.toolbar input{min-width:220px;flex:1}
|
||||||
|
.toolbar button{height:38px;border:0;border-radius:var(--radius-md);padding:0 14px;background:var(--primary);color:var(--on-primary);font-size:13px;font-weight:900;cursor:pointer}
|
||||||
|
.table-wrap{overflow:auto}
|
||||||
|
table{width:100%;border-collapse:collapse;min-width:980px}
|
||||||
|
th{padding:10px 12px;border-bottom:1px solid var(--hairline-soft);background:var(--surface);color:var(--stone);font-size:11px;font-weight:950;text-align:left;letter-spacing:.04em}
|
||||||
|
td{padding:11px 12px;border-bottom:1px solid var(--hairline-soft);font-size:12px;color:var(--ink);vertical-align:top}
|
||||||
|
tr:hover td{background:var(--surface)}
|
||||||
|
.badge{display:inline-flex;align-items:center;height:23px;border-radius:999px;padding:0 8px;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--stone);font-size:11px;font-weight:950;white-space:nowrap}
|
||||||
|
.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}
|
||||||
|
.badge.err{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}
|
||||||
|
.badge.warn{background:rgba(255,208,47,.16);border-color:rgba(255,208,47,.28);color:var(--yellow-dark)}
|
||||||
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:850}
|
||||||
|
.msg{max-width:460px;word-break:break-word;line-height:1.45;color:var(--slate)}
|
||||||
|
.empty,.loading{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}
|
||||||
|
.layout{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:14px}
|
||||||
|
.detail{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-lg);padding:15px;min-height:220px;position:sticky;top:18px;max-height:calc(100vh - 40px);overflow:auto}
|
||||||
|
.detail h3{font-size:15px;margin-bottom:10px;color:var(--ink)}
|
||||||
|
.meta{display:grid;grid-template-columns:86px minmax(0,1fr);gap:7px 10px;font-size:12px;color:var(--stone);margin-bottom:12px}
|
||||||
|
.meta span:nth-child(2n){color:var(--ink);overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.codebox{white-space:pre-wrap;word-break:break-word;background:#15171d;color:#eef0f5;border-radius:var(--radius-md);padding:12px;font-size:12px;line-height:1.55;max-height:520px;overflow:auto}
|
||||||
|
.mini-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;padding:14px;border-bottom:1px solid var(--hairline-soft)}
|
||||||
|
.mini{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:12px}
|
||||||
|
.mini span{display:block;color:var(--stone);font-size:11px;font-weight:900}.mini b{display:block;margin-top:5px;font-size:20px;color:var(--ink);font-weight:950}
|
||||||
|
.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;color:var(--stone);font-size:12px}
|
||||||
|
.pagination button{height:34px;border:1px solid var(--hairline);background:var(--surface);border-radius:var(--radius-md);padding:0 12px;color:var(--ink);cursor:pointer}.pagination button:disabled{opacity:.45;cursor:default}
|
||||||
|
@media(max-width:1050px){.ops-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.layout{grid-template-columns:1fr}.detail{position:static;max-height:none}.mini-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
@media(max-width:620px){main{padding:18px}.ops-strip{grid-template-columns:1fr}.page-title{font-size:23px}.tabs{width:100%}.tab-btn{flex:1}.mini-grid{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">日志中心</div>
|
||||||
|
<div class="page-sub">把系统错误、链上运行、调度任务、推荐链路和问答记录收在一个运维视图里,排障时不用在多个页面之间来回跳。</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs" id="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="system" onclick="switchTab('system')">系统错误</button>
|
||||||
|
<button class="tab-btn" data-tab="onchain" onclick="switchTab('onchain')">链上运行</button>
|
||||||
|
<button class="tab-btn" data-tab="cron" onclick="switchTab('cron')">调度运行</button>
|
||||||
|
<button class="tab-btn" data-tab="pipeline" onclick="switchTab('pipeline')">链路批次</button>
|
||||||
|
<button class="tab-btn" data-tab="chat" onclick="switchTab('chat')">问答日志</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ops-strip" id="opsStrip"><div class="ops-card"><span>状态</span><b>加载中</b><small>正在读取运行日志</small></div></div>
|
||||||
|
|
||||||
|
<section class="panel active" id="panel-system">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="panel active">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input id="sysSearch" placeholder="搜索错误、路径、用户..." onkeydown="if(event.key==='Enter')loadSystem(0)">
|
||||||
|
<select id="sysLevel" onchange="loadSystem(0)"><option value="all">全部级别</option><option value="error">Error</option><option value="warning">Warning</option></select>
|
||||||
|
<select id="sysSource" onchange="loadSystem(0)"><option value="all">全部来源</option><option value="web">Web</option><option value="scheduler">Scheduler</option><option value="paper_trading">策略交易</option><option value="price_streamer">Price Streamer</option></select>
|
||||||
|
<select class="hoursSel" id="sysHours" onchange="loadSystem(0)"><option value="24">近 24h</option><option value="168" selected>近 7 天</option><option value="720">近 30 天</option><option value="0">全部</option></select>
|
||||||
|
<button onclick="loadSystem(0)">查询</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>时间</th><th>来源</th><th>类型</th><th>消息</th><th>路径 / 用户</th><th>状态</th></tr></thead><tbody id="sysRows"><tr><td colspan="6" class="loading">加载中...</td></tr></tbody></table></div>
|
||||||
|
<div class="pagination" id="sysPager"></div>
|
||||||
|
</div>
|
||||||
|
<aside class="detail" id="sysDetail"><div class="empty">选择一条系统错误查看堆栈。</div></aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panel-onchain">
|
||||||
|
<div class="mini-grid" id="onchainMini"><div class="mini"><span>NodeReal</span><b>--</b></div></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select id="onchainHours" onchange="loadOnchain()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
|
||||||
|
<button onclick="loadOnchain()">刷新</button>
|
||||||
|
<a class="badge" href="/onchain">打开链上异动页</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>时间</th><th>任务</th><th>运行</th><th>结果</th><th>耗时</th><th>摘要</th><th>错误</th></tr></thead><tbody id="onchainRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panel-cron">
|
||||||
|
<div class="mini-grid" id="cronMini"><div class="mini"><span>调度</span><b>--</b></div></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select id="cronJob" onchange="loadCron()"><option value="">全部任务</option><option value="链上">链上</option><option value="粗筛">粗筛</option><option value="爆发确认">爆发确认</option><option value="price-streamer">Price Streamer</option><option value="策略交易">策略交易</option></select>
|
||||||
|
<select id="cronHours" onchange="loadCron()"><option value="24">近 24h</option><option value="168">近 7 天</option></select>
|
||||||
|
<button onclick="loadCron()">刷新</button>
|
||||||
|
<a class="badge" href="/cron">打开调度中心</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>时间</th><th>任务</th><th>运行</th><th>结果</th><th>耗时</th><th>摘要</th><th>错误</th></tr></thead><tbody id="cronRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panel-pipeline">
|
||||||
|
<div class="mini-grid" id="pipeMini"><div class="mini"><span>链路</span><b>--</b></div></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select id="pipeHours" onchange="loadPipeline(0)"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
|
||||||
|
<button onclick="loadPipeline(0)">刷新</button>
|
||||||
|
<a class="badge" href="/pipeline">打开原链路页</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>批次</th><th>时间</th><th>漏斗</th><th>推荐</th><th>转化</th><th>复盘</th><th>状态</th></tr></thead><tbody id="pipeRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
|
||||||
|
<div class="pagination" id="pipePager"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panel-chat">
|
||||||
|
<div class="mini-grid" id="chatMini"><div class="mini"><span>问答</span><b>--</b></div></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input id="chatSearch" placeholder="搜索问题、回答..." onkeydown="if(event.key==='Enter')loadChat(0)">
|
||||||
|
<select id="chatIntent" onchange="loadChat(0)"><option value="all">全部意图</option><option value="coin_analysis">币种分析</option><option value="onchain">链上异动</option><option value="sentiment">舆情</option><option value="review">复盘</option></select>
|
||||||
|
<select id="chatHours" onchange="loadChat(0)"><option value="24">近 24h</option><option value="168">近 7 天</option></select>
|
||||||
|
<button onclick="loadChat(0)">查询</button>
|
||||||
|
<a class="badge" href="/chat-logs">打开原问答日志</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>时间</th><th>用户</th><th>意图</th><th>问题</th><th>回答摘要</th></tr></thead><tbody id="chatRows"><tr><td colspan="5" class="loading">加载中...</td></tr></tbody></table></div>
|
||||||
|
<div class="pagination" id="chatPager"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block password_modal %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
var API='',PAGE=50,state={tab:'system',sysOffset:0,sysTotal:0,pipeOffset:0,pipeTotal:0,chatOffset:0,chatTotal:0};
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||||
|
function short(s,n){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
|
||||||
|
function time(ts){if(!ts)return'--';var d=new Date(ts);if(isNaN(d.getTime()))return String(ts).slice(0,19).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0')}
|
||||||
|
function dur(ms){ms=Number(ms||0);if(ms>=1000)return (ms/1000).toFixed(1)+'s';return ms+'ms'}
|
||||||
|
function badge(v){var s=String(v||'');var cls=s==='success'||s==='processed'||s==='ok'?'ok':(s==='error'||s.indexOf('fail')>=0?'err':(s==='no_onchain_data'||s==='warning'?'warn':''));return '<span class="badge '+cls+'">'+esc(s||'--')+'</span>'}
|
||||||
|
function summary(x){try{if(typeof x==='string')x=JSON.parse(x||'{}')}catch(e){};return Object.keys(x||{}).slice(0,6).map(function(k){return k+': '+x[k]}).join(' · ')||'--'}
|
||||||
|
function switchTab(tab){state.tab=tab;document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.toggle('active',b.dataset.tab===tab)});document.querySelectorAll('section.panel').forEach(function(p){p.classList.toggle('active',p.id==='panel-'+tab)});if(tab==='system')loadSystem(state.sysOffset||0);if(tab==='onchain')loadOnchain();if(tab==='cron')loadCron();if(tab==='pipeline')loadPipeline(state.pipeOffset||0);if(tab==='chat')loadChat(state.chatOffset||0)}
|
||||||
|
async function ensureAdmin(){try{var r=await fetch('/api/admin/check');var d=await r.json();if(!d.is_admin)location.href='/subscription'}catch(e){location.href='/subscription'}}
|
||||||
|
function renderOps(data){document.getElementById('opsStrip').innerHTML=data.map(function(x){return '<div class="ops-card"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><small>'+esc(x[2]||'')+'</small></div>'}).join('')}
|
||||||
|
async function loadOps(){try{var s=await(await fetch('/api/admin/system-errors/stats?hours=24')).json();var c=await(await fetch('/api/admin/cron-runs/summary?hours=24')).json();var oc=await(await fetch('/api/onchain/provider-status?hours=24')).json();renderOps([['系统错误',s.total||0,'近 24 小时'],['调度成功率',(c.overall||{}).success_rate+'%','运行 '+((c.overall||{}).total_runs||0)+' 次'],['链上任务',(oc.last_run||{}).result_status||'--',(oc.last_error||'NodeReal 状态正常')],['链上信号',(oc.coverage||{}).signals||0,'当前窗口标准事件']])}catch(e){renderOps([['状态','加载失败','请检查接口权限']])}}
|
||||||
|
async function loadSystem(offset){state.sysOffset=offset;loadOps();var q=sysSearch.value.trim(),level=sysLevel.value,source=sysSource.value,h=sysHours.value;sysRows.innerHTML='<tr><td colspan="6" class="loading">加载中...</td></tr>';try{var d=await(await fetch('/api/admin/system-errors?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+PAGE+'&level='+level+'&source='+source+'&hours='+h)).json();state.sysTotal=d.total||0;sysRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr onclick="loadSystemDetail('+x.id+')" style="cursor:pointer"><td>'+time(x.created_at)+'</td><td>'+badge(x.source||'app')+'</td><td>'+esc(x.error_type||'Error')+'</td><td class="msg">'+esc(short(x.message,140))+'</td><td class="msg">'+esc(short((x.request_path||'--')+(x.user_email?' · '+x.user_email:''),80))+'</td><td>'+badge(x.level||x.status_code)+'</td></tr>'}).join(''):'<tr><td colspan="6" class="empty">暂无系统错误</td></tr>';pager('sysPager',offset,state.sysTotal,'loadSystem')}catch(e){sysRows.innerHTML='<tr><td colspan="6" class="empty">加载失败</td></tr>'}}
|
||||||
|
async function loadSystemDetail(id){sysDetail.innerHTML='<div class="loading">加载详情...</div>';try{var d=await(await fetch('/api/admin/system-errors/'+id)).json();sysDetail.innerHTML='<h3>#'+esc(d.id)+' · '+esc(d.error_type||'Error')+'</h3><div class="meta"><span>时间</span><span>'+time(d.created_at)+'</span><span>来源</span><span>'+esc(d.source||'app')+' · PID '+esc(d.pid||0)+'</span><span>路径</span><span>'+esc((d.request_method||'')+' '+(d.request_path||'--'))+'</span><span>用户</span><span>'+esc(d.user_email||'--')+'</span><span>指纹</span><span>'+esc(d.fingerprint||'--')+'</span><span>消息</span><span>'+esc(d.message||'--')+'</span></div><div class="codebox">'+esc(d.stack_trace||'无堆栈信息')+'</div>'}catch(e){sysDetail.innerHTML='<div class="empty">详情加载失败</div>'}}
|
||||||
|
async function loadOnchain(){loadOps();onchainRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var h=onchainHours.value;var st=await(await fetch('/api/onchain/provider-status?hours='+h)).json();var rows=await(await fetch('/api/admin/cron-runs?job_name='+encodeURIComponent('链上')+'&limit=80')).json();var p=(st.providers||[])[0]||{};onchainMini.innerHTML=[['NodeReal',p.status||'--',p.api_key_present?'Key 已配置':'无 Key'],['标准信号',(st.coverage||{}).signals||0,'近 '+h+'h'],['Metrics',(st.coverage||{}).metrics||0,'链上指标'],['最近结果',(st.last_run||{}).result_status||'--',st.last_error||'无错误'],['映射合约',(st.coverage||{}).usable_mappings||0,'可采集合约']].map(mini).join('');renderCronRows('onchainRows',rows.items||[])}catch(e){onchainRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
|
||||||
|
async function loadCron(){loadOps();cronRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var s=await(await fetch('/api/admin/cron-runs/summary?hours='+cronHours.value)).json();cronMini.innerHTML=[['总运行',(s.overall||{}).total_runs||0,'近 '+cronHours.value+'h'],['成功率',(s.overall||{}).success_rate+'%','调度稳定性'],['失败',(s.overall||{}).error_runs||0,'异常任务'],['平均耗时',dur((s.overall||{}).avg_duration_ms),'单次任务'],['任务数',(s.job_stats||[]).length,'已配置任务']].map(mini).join('');var d=await(await fetch('/api/admin/cron-runs?job_name='+encodeURIComponent(cronJob.value)+'&limit=100')).json();renderCronRows('cronRows',d.items||[])}catch(e){cronRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
|
||||||
|
function renderCronRows(id,items){var el=document.getElementById(id);el.innerHTML=items.length?items.map(function(x){return '<tr><td>'+time(x.started_at)+'</td><td>'+esc(x.job_name||'--')+'</td><td>'+badge(x.run_status)+'</td><td>'+badge(x.result_status)+'</td><td>'+dur(x.duration_ms)+'</td><td class="msg">'+esc(summary(x.summary_json))+'</td><td class="msg">'+esc(short(x.error_message||'',180))+'</td></tr>'}).join(''):'<tr><td colspan="7" class="empty">暂无运行日志</td></tr>'}
|
||||||
|
async function loadPipeline(offset){state.pipeOffset=offset;loadOps();pipeRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var d=await(await fetch('/api/admin/pipeline-runs?hours='+pipeHours.value+'&limit=30&offset='+offset)).json();var k=d.kpi||{};pipeMini.innerHTML=[['批次数',k.run_count||0,'粗筛批次'],['宇宙过滤',k.universe_gate_count||0,'候选入口'],['质量通过',k.quality_pass_count||0,'过滤后样本'],['交易确认',k.trade_confirm_count||0,'确认机会'],['推荐转化',(k.recommendation_rate||0)+'%','推荐/合格']].map(mini).join('');var p=d.pagination||{};state.pipeTotal=p.total_count||0;pipeRows.innerHTML=(d.runs||[]).length?(d.runs||[]).map(function(x){return '<tr><td class="mono">#'+esc(x.run_id||x.id)+'</td><td>'+time(x.started_at)+'</td><td>'+esc((x.universe_gate_count||0)+' / '+(x.discovery_count||0)+' / '+(x.quality_pass_count||0))+'</td><td>'+esc(x.recommendations||0)+'</td><td>'+esc((x.recommendation_rate||0)+'%')+'</td><td>'+esc((x.perf_success||0)+' / '+(x.perf_failed||0)+' / '+(x.missed_count||0))+'</td><td>'+badge(x.result_status||x.run_status)+'</td></tr>'}).join(''):'<tr><td colspan="7" class="empty">暂无链路批次</td></tr>';pager('pipePager',offset,state.pipeTotal,'loadPipeline',30)}catch(e){pipeRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
|
||||||
|
async function loadChat(offset){state.chatOffset=offset;loadOps();chatRows.innerHTML='<tr><td colspan="5" class="loading">加载中...</td></tr>';try{var ov=await(await fetch('/api/admin/chat-logs/overview?hours='+chatHours.value)).json();chatMini.innerHTML=[['提问数',ov.total_questions||0,'近 '+chatHours.value+'h'],['会话数',ov.total_sessions||0,'涉及 '+(ov.total_users||0)+' 位用户'],['链上问题',((ov.top_intents||[]).find(function(x){return x.intent==='onchain'})||{}).n||0,'onchain intent'],['消息数',ov.total_messages||0,'用户与助手消息'],['热门意图',((ov.top_intents||[])[0]||{}).intent||'--','当前最常见']].map(mini).join('');var d=await(await fetch('/api/admin/chat-logs?search='+encodeURIComponent(chatSearch.value.trim())+'&intent='+encodeURIComponent(chatIntent.value)+'&hours='+chatHours.value+'&offset='+offset+'&limit='+PAGE)).json();state.chatTotal=d.total||0;chatRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr><td>'+time(x.created_at)+'</td><td>'+esc(x.user_email||'--')+'</td><td>'+badge(x.intent||'--')+'</td><td class="msg">'+esc(short(x.content_text||'',170))+'</td><td class="msg">'+esc(short((x.symbol?x.symbol+' · ':'')+(x.session_title||('会话 #'+x.session_id)),120))+'</td></tr>'}).join(''):'<tr><td colspan="5" class="empty">暂无问答日志</td></tr>';pager('chatPager',offset,state.chatTotal,'loadChat')}catch(e){chatRows.innerHTML='<tr><td colspan="5" class="empty">加载失败</td></tr>'}}
|
||||||
|
function mini(x){return '<div class="mini"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><span>'+esc(x[2]||'')+'</span></div>'}
|
||||||
|
function pager(id,offset,total,fn,size){size=size||PAGE;var cur=Math.floor(offset/size)+1,totalPages=Math.max(1,Math.ceil((total||0)/size));document.getElementById(id).innerHTML='<button '+(offset<=0?'disabled':'')+' onclick="'+fn+'('+(offset-size)+')">上一页</button><span>第 '+cur+' / '+totalPages+' 页 · 共 '+(total||0)+' 条</span><button '+(offset+size>=total?'disabled':'')+' onclick="'+fn+'('+(offset+size)+')">下一页</button>'}
|
||||||
|
(async function(){await ensureAdmin();loadOps();switchTab('system')})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div><h1>链上异动</h1><p>跟踪 DEX 放量、流动性变化、资金流和鲸鱼行为。链上信号只负责发现线索,最终仍交给技术确认。</p></div>
|
<div><h1>链上异动</h1><p>以 NodeReal 为主数据源,跟踪 EVM 大额转账、holder 变化和鲸鱼行为。链上信号只负责发现线索,最终仍交给技术确认。</p></div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<select class="select" id="hoursSel" onchange="reloadAll()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
|
<select class="select" id="hoursSel" onchange="reloadAll()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
|
||||||
<button class="btn" onclick="reloadAll()">刷新</button>
|
<button class="btn" onclick="reloadAll()">刷新</button>
|
||||||
@ -18,7 +18,7 @@
|
|||||||
<div class="source-strip" id="providerStatus"><div class="loading">加载数据源状态...</div></div>
|
<div class="source-strip" id="providerStatus"><div class="loading">加载数据源状态...</div></div>
|
||||||
<div class="flow-strip" id="flowStatus"></div>
|
<div class="flow-strip" id="flowStatus"></div>
|
||||||
<section class="panel raw-panel">
|
<section class="panel raw-panel">
|
||||||
<div class="panel-head"><div class="panel-title">重要链上事件</div><div class="panel-note">默认隐藏低优先级曝光源</div></div>
|
<div class="panel-head"><div class="panel-title">重要链上事件</div><div class="panel-note">NodeReal 主链路事件优先</div></div>
|
||||||
<div class="raw-toolbar">
|
<div class="raw-toolbar">
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<button class="btn" onclick="setRawPriority('important')">重要事件</button>
|
<button class="btn" onclick="setRawPriority('important')">重要事件</button>
|
||||||
@ -64,7 +64,7 @@ function fmtAmount(v){v=Number(v||0);if(v>=1000)return v.toFixed(0);if(v>0)retur
|
|||||||
function recLabel(r){if(!r||!r.has_active)return '<span class="badge">未进入</span>';var s=r.execution_status||'';if(s==='buy_now')return '<span class="badge pos">入场窗口</span>';if(s==='wait_pullback')return '<span class="badge blue">等回踩</span>';return '<span class="badge blue">'+esc(r.action_status||'观察中')+'</span>'}
|
function recLabel(r){if(!r||!r.has_active)return '<span class="badge">未进入</span>';var s=r.execution_status||'';if(s==='buy_now')return '<span class="badge pos">入场窗口</span>';if(s==='wait_pullback')return '<span class="badge blue">等回踩</span>';return '<span class="badge blue">'+esc(r.action_status||'观察中')+'</span>'}
|
||||||
function setRawPriority(v){state.rawPriority=v;reloadRawEvents(0)}
|
function setRawPriority(v){state.rawPriority=v;reloadRawEvents(0)}
|
||||||
function statusBadgeClass(s){s=String(s||'');if(s.indexOf('正常')>=0)return 'pos';if(s.indexOf('失败')>=0||s.indexOf('未接入')>=0)return 'warn';if(s.indexOf('关闭')>=0)return '';return 'blue'}
|
function statusBadgeClass(s){s=String(s||'');if(s.indexOf('正常')>=0)return 'pos';if(s.indexOf('失败')>=0||s.indexOf('未接入')>=0)return 'warn';if(s.indexOf('关闭')>=0)return '';return 'blue'}
|
||||||
function providerCard(p){var stats=[['原始',p.raw_events||0],['指标',p.metrics||0],['信号',p.signals||0]].map(function(x){return '<span class="badge">'+x[0]+' '+x[1]+'</span>'}).join('');var key=p.api_key_present?'<span class="badge mapped">Key 已配置</span>':'<span class="badge unmapped">无 Key</span>';var impl=p.implemented?'<span class="badge blue">已接入</span>':'<span class="badge warn">待接入</span>';var foot=p.provider==='dexscreener'?'低优先级曝光源:适合看市场营销热度,不适合单独作为链上机会。':(p.provider==='etherscan'?'高价值资金流源:当前抓取 EVM 大额 ERC20 转账。':'高价值 Solana 源:当前抓取已映射 mint 的解析交易。');return '<div class="source-card"><div class="source-top"><div class="source-name">'+esc(p.label||p.provider)+'</div><span class="badge '+statusBadgeClass(p.status)+'">'+esc(p.status||'--')+'</span></div><div class="source-role">'+esc(p.role||'--')+'</div><div class="source-stats">'+stats+key+impl+'</div><div class="source-foot">'+foot+'</div></div>'}
|
function providerCard(p){var stats=[['原始',p.raw_events||0],['指标',p.metrics||0],['信号',p.signals||0]].map(function(x){return '<span class="badge">'+x[0]+' '+x[1]+'</span>'}).join('');var key=p.api_key_present?'<span class="badge mapped">Key 已配置</span>':'<span class="badge unmapped">无 Key</span>';var impl=p.implemented?'<span class="badge blue">已接入</span>':'<span class="badge warn">待接入</span>';var foot=p.provider==='nodereal'?'主链上数据源:当前负责 EVM Transfer 日志、大额转账和 holder 变化。':(p.status==='已关闭'?'已从主链路移除,保留仅用于兼容和历史数据展示。':'辅助来源:不再作为默认链上主链路。');return '<div class="source-card"><div class="source-top"><div class="source-name">'+esc(p.label||p.provider)+'</div><span class="badge '+statusBadgeClass(p.status)+'">'+esc(p.status||'--')+'</span></div><div class="source-role">'+esc(p.role||'--')+'</div><div class="source-stats">'+stats+key+impl+'</div><div class="source-foot">'+foot+'</div></div>'}
|
||||||
function renderProviderStatus(s){var providers=s.providers||[];$('providerStatus').innerHTML=providers.length?providers.map(providerCard).join(''):'<div class="empty">暂无数据源状态</div>';var c=s.coverage||{};var steps=[['原始流',c.raw_events||0],['币种映射',c.usable_mappings||0],['标准信号',c.signals||0],['技术检查候选',c.queued_candidates||0]];$('flowStatus').innerHTML=steps.map(function(x){return '<div class="flow-step"><span>'+x[0]+'</span><b>'+x[1]+'</b></div>'}).join('')}
|
function renderProviderStatus(s){var providers=s.providers||[];$('providerStatus').innerHTML=providers.length?providers.map(providerCard).join(''):'<div class="empty">暂无数据源状态</div>';var c=s.coverage||{};var steps=[['原始流',c.raw_events||0],['币种映射',c.usable_mappings||0],['标准信号',c.signals||0],['技术检查候选',c.queued_candidates||0]];$('flowStatus').innerHTML=steps.map(function(x){return '<div class="flow-step"><span>'+x[0]+'</span><b>'+x[1]+'</b></div>'}).join('')}
|
||||||
function renderKpis(k){var cells=[['原始流',k.raw_event_count||0,'blue'],['已映射原始流',k.raw_mapped_count||0,'green'],['映射币种',k.token_count||0,'blue'],['标准正向信号',k.positive_events||0,'green'],['标准风险信号',k.risk_events||0,'red'],['DEX 成交',fmtUsd(k.dex_volume_usd||0),'blue']];$('kpis').innerHTML=cells.map(function(x){return '<div class="kpi"><span>'+x[0]+'</span><b class="'+x[2]+'">'+x[1]+'</b></div>'}).join('')}
|
function renderKpis(k){var cells=[['原始流',k.raw_event_count||0,'blue'],['已映射原始流',k.raw_mapped_count||0,'green'],['映射币种',k.token_count||0,'blue'],['标准正向信号',k.positive_events||0,'green'],['标准风险信号',k.risk_events||0,'red'],['DEX 成交',fmtUsd(k.dex_volume_usd||0),'blue']];$('kpis').innerHTML=cells.map(function(x){return '<div class="kpi"><span>'+x[0]+'</span><b class="'+x[2]+'">'+x[1]+'</b></div>'}).join('')}
|
||||||
function tokenRow(t, risk){return '<div class="token-row" onclick="loadDetail(\''+esc(t.symbol)+'\')"><div class="token-main"><div class="sym">'+esc(t.symbol)+'</div><div class="sub">'+esc(t.chain)+' · DEX '+fmtUsd(t.dex_volume_usd)+' · 流动性 '+fmtUsd(t.liquidity_usd)+'</div></div><div class="score '+(risk?'risk':'')+'">'+Number(risk?t.risk_score:t.onchain_score||0).toFixed(0)+'</div></div>'}
|
function tokenRow(t, risk){return '<div class="token-row" onclick="loadDetail(\''+esc(t.symbol)+'\')"><div class="token-main"><div class="sym">'+esc(t.symbol)+'</div><div class="sub">'+esc(t.chain)+' · DEX '+fmtUsd(t.dex_volume_usd)+' · 流动性 '+fmtUsd(t.liquidity_usd)+'</div></div><div class="score '+(risk?'risk':'')+'">'+Number(risk?t.risk_score:t.onchain_score||0).toFixed(0)+'</div></div>'}
|
||||||
|
|||||||
@ -46,7 +46,8 @@ tr:hover td { background:var(--surface); }
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="page-title">系统日志</div>
|
<div class="page-title">系统日志</div>
|
||||||
<div class="page-sub">集中查看 Web、CLI、Scheduler 的内部错误、上下文和堆栈信息。</div>
|
<div class="page-sub">集中查看 Web、CLI、Scheduler 的内部错误、上下文和堆栈信息。新版入口见 <a href="/logs">日志中心</a>。</div>
|
||||||
|
<a class="active admin-link" href="/system-logs" style="display:none">系统日志兼容入口</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,7 @@ def test_dex_signal_codes_from_metric(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path):
|
def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path):
|
||||||
_temp_db(monkeypatch, tmp_path)
|
_temp_db(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
|
||||||
cfg = onchain_monitor.get_onchain_params()
|
cfg = onchain_monitor.get_onchain_params()
|
||||||
chains = set(cfg.get("chains") or [])
|
chains = set(cfg.get("chains") or [])
|
||||||
|
|
||||||
@ -198,7 +199,7 @@ 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"
|
assert overview.json()["provider_status"]["providers"][0]["provider"] == "nodereal"
|
||||||
|
|
||||||
tokens = client.get("/api/onchain/tokens")
|
tokens = client.get("/api/onchain/tokens")
|
||||||
assert tokens.status_code == 200
|
assert tokens.status_code == 200
|
||||||
@ -213,6 +214,7 @@ def test_raw_dexscreener_events_store_without_mapping(monkeypatch, tmp_path):
|
|||||||
_temp_db(monkeypatch, tmp_path)
|
_temp_db(monkeypatch, tmp_path)
|
||||||
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "1")
|
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "1")
|
||||||
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
|
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
|
||||||
|
monkeypatch.setenv("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", "1")
|
||||||
|
|
||||||
def fake_request(url, params=None, timeout=15):
|
def fake_request(url, params=None, timeout=15):
|
||||||
if "token-profiles" in url:
|
if "token-profiles" in url:
|
||||||
@ -286,9 +288,9 @@ def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path):
|
|||||||
assert low.json()["total"] == 1
|
assert low.json()["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_etherscan_events_generate_normalized_onchain_event(monkeypatch, tmp_path):
|
def test_nodereal_events_generate_metrics_and_normalized_event(monkeypatch, tmp_path):
|
||||||
_temp_db(monkeypatch, tmp_path)
|
_temp_db(monkeypatch, tmp_path)
|
||||||
monkeypatch.setenv("ALPHAX_ETHERSCAN_API_KEY", "test-key")
|
monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
|
||||||
onchain_db.upsert_token_mapping("ABC", "ethereum", "0xabc", source="manual", confidence=95)
|
onchain_db.upsert_token_mapping("ABC", "ethereum", "0xabc", source="manual", confidence=95)
|
||||||
onchain_db.insert_token_metric(
|
onchain_db.insert_token_metric(
|
||||||
{
|
{
|
||||||
@ -304,80 +306,53 @@ def test_etherscan_events_generate_normalized_onchain_event(monkeypatch, tmp_pat
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def fake_request(url, params=None, timeout=15):
|
class FakeNodeRealClient:
|
||||||
assert params["chainid"] == "1"
|
def supports_chain(self, chain):
|
||||||
assert params["contractaddress"] == "0xabc"
|
return chain == "ethereum"
|
||||||
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)
|
def token_holder_count(self, chain, contract):
|
||||||
result = onchain_monitor.fetch_etherscan_events(limit=10)
|
assert chain == "ethereum"
|
||||||
|
assert contract == "0xabc"
|
||||||
|
return 120
|
||||||
|
|
||||||
|
def block_number(self, chain):
|
||||||
|
assert chain == "ethereum"
|
||||||
|
return 1000
|
||||||
|
|
||||||
|
def get_logs(self, chain, log_filter):
|
||||||
|
assert log_filter["address"] == "0xabc"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"transactionHash": "0xtx",
|
||||||
|
"data": hex(200000 * 10**18),
|
||||||
|
"topics": [
|
||||||
|
onchain_monitor.TRANSFER_TOPIC,
|
||||||
|
"0x0000000000000000000000001111111111111111111111111111111111111111",
|
||||||
|
"0x0000000000000000000000002222222222222222222222222222222222222222",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: FakeNodeRealClient())
|
||||||
|
result = onchain_monitor.fetch_nodereal_events(limit=10)
|
||||||
|
|
||||||
assert result["errors"] == []
|
assert result["errors"] == []
|
||||||
assert len(result["events"]) == 1
|
assert len(result["events"]) == 1
|
||||||
|
assert len(result["metrics"]) == 1
|
||||||
events = onchain_db.list_onchain_events(hours=50000)
|
events = onchain_db.list_onchain_events(hours=50000)
|
||||||
assert events["total"] == 1
|
assert events["total"] == 1
|
||||||
assert events["items"][0]["source"] == "etherscan"
|
assert events["items"][0]["source"] == "nodereal"
|
||||||
assert events["items"][0]["signal_code"] == "whale_accumulation"
|
assert events["items"][0]["signal_code"] == "whale_accumulation"
|
||||||
|
|
||||||
|
|
||||||
def test_helius_events_generate_normalized_onchain_event(monkeypatch, tmp_path):
|
def test_legacy_helius_is_disabled_by_default(monkeypatch, tmp_path):
|
||||||
_temp_db(monkeypatch, tmp_path)
|
_temp_db(monkeypatch, tmp_path)
|
||||||
monkeypatch.setenv("ALPHAX_HELIUS_API_KEY", "test-key")
|
monkeypatch.setenv("ALPHAX_HELIUS_API_KEY", "test-key")
|
||||||
onchain_db.upsert_token_mapping("SOLX", "solana", "Mint111", source="manual", confidence=95)
|
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)
|
result = onchain_monitor.fetch_helius_events(limit=10)
|
||||||
|
|
||||||
assert result["errors"] == []
|
assert result["events"] == []
|
||||||
assert len(result["events"]) == 1
|
assert result["errors"] == ["helius_disabled"]
|
||||||
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):
|
||||||
|
|||||||
@ -561,10 +561,12 @@ def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec):
|
|||||||
|
|
||||||
|
|
||||||
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
||||||
|
pushed = []
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||||
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
||||||
|
|
||||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
activated = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
activated = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
||||||
@ -582,6 +584,21 @@ def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy
|
|||||||
assert trade["status"] == "closed"
|
assert trade["status"] == "closed"
|
||||||
assert trade["exit_reason"] == "trailing_stop"
|
assert trade["exit_reason"] == "trailing_stop"
|
||||||
assert trade["exit_price"] > trade["entry_price"]
|
assert trade["exit_price"] > trade["entry_price"]
|
||||||
|
assert pushed[-1]["metadata"]["event_type"] == "close"
|
||||||
|
assert "移动止盈成交平仓" in pushed[-1]["header"]["title"]["content"]
|
||||||
|
_assert_no_paper_trading_copy(pushed[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_paper_push_failure_is_recorded(monkeypatch, buy_now_rec):
|
||||||
|
errors = []
|
||||||
|
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: (False, "webhook failed"))
|
||||||
|
monkeypatch.setattr("app.db.paper_trading.record_system_error", lambda **kwargs: errors.append(kwargs) or 1)
|
||||||
|
|
||||||
|
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
|
|
||||||
|
assert errors
|
||||||
|
assert errors[0]["error_type"] == "FeishuPushFailed"
|
||||||
|
assert errors[0]["context"]["event_type"] == "open"
|
||||||
|
|
||||||
|
|
||||||
def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):
|
def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user