From 66f323148946b552fe514b38ab6dc5f8518c836a Mon Sep 17 00:00:00 2001
From: aaron <>
Date: Sun, 7 Jun 2026 20:58:35 +0800
Subject: [PATCH] update
---
.env.example | 36 -
AGENTS.md | 29 +-
README_DOCKER.md | 7 +-
app/cli.py | 7 +-
app/config/config_loader.py | 6 -
app/config/system_config.py | 89 --
app/core/factor_scoring.py | 17 +-
app/core/opportunity_funnel.py | 2 +-
app/core/signal_direction.py | 2 +-
app/db/chat_assistant_db.py | 2 +-
app/db/data_export.py | 1 -
app/db/onchain_db.py | 1110 --------------------
app/db/operations_dashboard.py | 24 -
app/db/recommendation_queries.py | 111 +-
app/db/review_center.py | 38 +-
app/db/runtime_config_db.py | 10 -
app/db/scheduler_db.py | 11 -
app/db/strategy_insights.py | 4 -
app/services/alchemy_client.py | 85 --
app/services/altcoin_confirm.py | 92 --
app/services/chat_assistant.py | 49 +-
app/services/nodereal_client.py | 93 --
app/services/onchain_monitor.py | 1208 ----------------------
app/web/routes_market.py | 3 -
app/web/routes_onchain.py | 84 --
app/web/routes_pages.py | 7 -
app/web/web_server.py | 2 -
docs/MULTI_STRATEGY_ARCHITECTURE.md | 4 +-
docs/OPTIMIZATION_TODO.md | 9 +-
docs/PRODUCT_INFORMATION_ARCHITECTURE.md | 7 +-
docs/postgres_migration.md | 2 +-
static/app.html | 19 +-
static/base.html | 2 -
static/chat.html | 8 +-
static/chat_logs.html | 1 -
static/config.html | 2 +-
static/data_export.html | 2 +-
static/logs.html | 24 +-
static/market.html | 19 +-
static/onchain.html | 102 --
static/operations.html | 2 +-
static/opportunity_detail.html | 2 +-
static/review_center.html | 4 +-
static/strategy.html | 4 +-
tests/test_base_shell_ownership.py | 1 -
tests/test_market_overview_api.py | 7 +-
tests/test_onchain_factor_scoring.py | 105 --
tests/test_onchain_tracking.py | 622 -----------
tests/test_operations_dashboard.py | 6 +-
tests/test_runtime_config.py | 42 +-
tests/test_scheduler_control.py | 2 +-
51 files changed, 85 insertions(+), 4042 deletions(-)
delete mode 100644 app/db/onchain_db.py
delete mode 100644 app/services/alchemy_client.py
delete mode 100644 app/services/nodereal_client.py
delete mode 100644 app/services/onchain_monitor.py
delete mode 100644 app/web/routes_onchain.py
delete mode 100644 static/onchain.html
delete mode 100644 tests/test_onchain_factor_scoring.py
delete mode 100644 tests/test_onchain_tracking.py
diff --git a/.env.example b/.env.example
index 3fbf29b..648ce2d 100644
--- a/.env.example
+++ b/.env.example
@@ -39,42 +39,6 @@ ALPHAX_LLM_RECOMMENDATIONS_ENABLED=1
ALPHAX_LLM_SENTIMENT_ENABLED=1
ALPHAX_LLM_REVIEW_ENABLED=1
-# 链上追踪运行时配置。默认关闭;开启后采集结果只作为发现/风控辅助。
-ALPHAX_ONCHAIN_ENABLED=0
-ALPHAX_ONCHAIN_PROVIDER=nodereal
-# 可选:切换到 Alchemy 可设为 alchemy;并行可设为 nodereal,alchemy。
-ALPHAX_ONCHAIN_CHAINS=ethereum,bsc
-ALPHAX_ONCHAIN_TIMEOUT=15
-ALPHAX_NODEREAL_ENABLED=1
-ALPHAX_NODEREAL_CHAINS=ethereum,bsc
-ALPHAX_NODEREAL_API_KEY=
-ALPHAX_ALCHEMY_ENABLED=0
-ALPHAX_ALCHEMY_CHAINS=ethereum,bsc
-ALPHAX_ALCHEMY_API_KEY=
-# 可选:生产若 onchain_token_map 为空,可用 JSON 数组自举 NodeReal 合约映射。
-# 示例:[{"symbol":"STORJ/USDT","chain":"ethereum","contract_address":"0x...","confidence":95}]
-ALPHAX_ONCHAIN_TOKEN_MAPPINGS=
-ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK=120
-ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN=25
-ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED=1
-ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK=1
-ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN=30
-ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED=1
-ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE=82
-ALPHAX_ALCHEMY_LOG_BLOCK_LOOKBACK=9
-ALPHAX_ALCHEMY_MAX_LOGS_PER_TOKEN=25
-ALPHAX_ALCHEMY_RAW_TRANSFER_ENABLED=1
-ALPHAX_ALCHEMY_RAW_CHAINS=ethereum
-ALPHAX_ALCHEMY_RAW_BLOCK_LOOKBACK=1
-ALPHAX_ALCHEMY_RAW_MAX_LOGS_PER_CHAIN=8
-ALPHAX_ALCHEMY_AUTO_MAPPING_ENABLED=1
-ALPHAX_ALCHEMY_AUTO_MAPPING_CONFIDENCE=82
-ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
-ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
-ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
-ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
-ALPHAX_ONCHAIN_WHALE_TX_USD=250000
-
# 策略交易挂单门控。wait_pullback 只是候选,必须通过这些条件才会创建挂单。
ALPHAX_PAPER_TRADING_MODE=intraday_trading
ALPHAX_PAPER_TARGET_TRADES_PER_DAY_MIN=3
diff --git a/AGENTS.md b/AGENTS.md
index 5d58052..26fe81a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,9 @@
## 1. 项目定位
-AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组成的加密市场机会监控系统。当前核心目标不是完整自动交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究、提示、模拟交易和复盘闭环。
+AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组成的 CEX 加密市场机会监控系统。当前核心目标不是完整自动交易执行,而是围绕 Binance/CEX 行情的“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究、提示、模拟交易和复盘闭环。
+
+当前链上采集/API 功能已经整体下线。NodeReal、Alchemy、DEX Screener、Etherscan、Helius 等链上数据源不参与当前运行时、推荐评分、页面展示、调度任务或复盘归因;后续如需重新规划链上模块,必须作为独立方案重新设计,不要恢复旧的 onchain 入口。
当前仓库是一个 Docker 化运行目录。运行时数据库已经切换为 PostgreSQL;SQLite 只作为历史数据导入来源,不再作为应用运行时数据库。后续开发和排查都应以 PostgreSQL、`DATABASE_URL`、Docker 服务和当前 migration 为准。
@@ -73,7 +75,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- 支持 `scheduler_manual_trigger` 手动触发。
- 使用 lock group 控制并发冲突。
- `app/cli.py`
- - 统一命令入口:`screener`, `confirm`, `tracker`, `paper-trader`, `price-streamer`, `market`, `review`, `event`, `sentiment`, `onchain`, `llm-insights`。
+ - 统一命令入口:`screener`, `confirm`, `tracker`, `paper-trader`, `price-streamer`, `market`, `review`, `event`, `sentiment`, `llm-insights`。
## 4. 代码结构
@@ -114,13 +116,12 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- 稳定因子代码来自 `app/core/signal_taxonomy.py`,例如 `vp_fly_1h_current`、`volume_consecutive_1h`、`ignition_d1_current`、`sector_rotation`、`sentiment_resonance`、`top_trader_long`、`risk_reward_bad`。
- `signal_performance` 是复盘后动态权重来源;`review_engine.py` 更新信号绩效后,`config_loader.get_signal_weights()` 会让下一轮筛选/确认读取生效权重。
- 当前确认层已把核心技术因子、资金面因子、板块因子、舆情因子和买点风险因子接入 `FactorScorer`,并在 `market_context.factor_score_breakdown` / `entry_plan.factor_score_breakdown` 中保留因子明细。
-- `FactorScorer` 已加入因子组去相关,同一类 `momentum` / `structure` / `entry_quality` / `onchain_flow` / `narrative` 信号会受 group cap 限制,避免同一根行情被重复加分。
+- `FactorScorer` 已加入因子组去相关,同一类 `momentum` / `structure` / `entry_quality` / `narrative` 信号会受 group cap 限制,避免同一根行情被重复加分。
- 小样本复盘不能直接杀死核心因子。`signal_performance` 的动态权重至少要满足 `review.min_samples_for_weight` 与 `review.signal_deprecation.min_samples` 后才覆盖确认层基线;未达样本门槛时只用于观察,不应用 0 权重把 15min 启动、日线突破回踩等因子压没。
- 扣分因子应传负数,例如 `FactorScorer.delta("false_breakout", -5, ...)`,不要再外部 `score -= delta`,否则 `factor_score_breakdown` 会把风险误记成正向贡献。
- 确认层会输出 `score_components`:`opportunity_score` 表示机会质量,`entry_score` 表示买点质量,`risk_score` 表示扣分风险;后续策略不要再只看单一 `rec_score`。
- `market_context.decision_log` / `entry_plan.decision_log` 是结构化决策解释;paper trading 开仓事件也会记录当时 `market_regime`、`global_risk` 和 `score_components`。
-- NodeReal 链上因子通过 `app/db/onchain_db.py#get_onchain_factor_context()` 进入确认层,正向事件如 `whale_accumulation`、`smart_money_buying`、`exchange_outflow` 会加分,风险事件如 `exchange_inflow_risk`、`liquidity_remove_risk`、`holder_concentration_risk` 会扣分;这些因子同样受 `signal_performance.weight` 复盘权重约束。
-- 后续新增链上、资金、事件、舆情等非 K 线因子时,必须给出稳定 `factor_code`、默认基准权重、证据字段和复盘归因口径,避免只做展示标签而不参与策略进化。
+- 后续新增资金、事件、舆情等非 K 线因子时,必须给出稳定 `factor_code`、默认基准权重、证据字段和复盘归因口径,避免只做展示标签而不参与策略进化。
- `box_breakout_pullback_4h` 是 4H 箱体突破回踩强结构因子,不是完整策略;它可以作为 `box_retest_4h_v1` 这类策略的核心触发,但仍必须经过市场环境、交易宇宙、确认、入场、风控和失效条件。
### 4.1.2 多策略架构方向
@@ -133,14 +134,11 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- 空头策略不能简单反转多头策略。当前第一版空头机会是 `breakdown_retest_short_1h_v1`,核心剧本是“1H箱体下破 -> 反抽箱体下沿/均线 -> 反抽失败 -> 等反抽或开空”,并使用独立 `strategy_code`、`factor_roles`、RR/止损几何和复盘口径。
- 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。
-### 4.1.3 链上数据源
+### 4.1.3 链上功能状态
-- 当前链上主数据源支持 NodeReal 与 Alchemy,入口分别是 `app/services/nodereal_client.py`、`app/services/alchemy_client.py` 和 `app/services/onchain_monitor.py`。
-- 默认仍可跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`;如果 NodeReal 额度受限,可切到 `ALPHAX_ONCHAIN_PROVIDER=alchemy` 并设置 `ALPHAX_ALCHEMY_API_KEY`;并行模式可用 `ALPHAX_ONCHAIN_PROVIDER=nodereal,alchemy`。
-- NodeReal 通过 `ALPHAX_NODEREAL_API_KEY` 访问 EVM JSON-RPC / Enhanced API;Alchemy 通过 `ALPHAX_ALCHEMY_API_KEY` 访问 Ethereum / BSC 标准 EVM JSON-RPC。
-- DEX Screener、Etherscan、Helius 已从运行时代码链路移除;当前只做 Ethereum / BSC 的 NodeReal 采集。
-- NodeReal / Alchemy 原始 Transfer 会先记录到 `onchain_raw_events`,再通过 ERC-20 `symbol/name/decimals` 自动尝试映射到交易所 `XXX/USDT`,人工 `ALPHAX_ONCHAIN_TOKEN_MAPPINGS` 只作为兜底。
-- 新增链上信号优先落到 `onchain_token_metrics` / `onchain_events`,不要直接创建推荐;高质量事件仍通过 `event_news` 进入技术检查。
+- 链上功能当前已下线,不再有 `onchain` CLI、调度任务、Web 页面、API route、NodeReal/Alchemy client 或链上因子评分。
+- 历史 PostgreSQL migration 中的 `onchain_*` 表可暂时保留,避免破坏已部署库的迁移链;当前业务代码不应读取或写入这些表。
+- 后续如需重启链上方向,必须先重新设计数据源、可读事件模型、映射机制、策略接入边界和复盘评价,不能直接恢复旧实现。
### 4.2 Web/API
@@ -151,7 +149,6 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `app/web/routes_recommendations.py`
- `app/web/routes_review_center.py`
- `app/web/routes_strategy.py`
-- `app/web/routes_onchain.py`
- `app/web/routes_paper_trading.py`
- `app/web/routes_live_trading.py`
- `app/web/routes_market.py`
@@ -277,7 +274,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `live_order_events`
- `market_snapshots`
- `sentiment_events`
-- `onchain_*`
+- `onchain_*` 历史表仅为迁移兼容保留,当前运行时不使用。
- `llm_insights`
- `system_config`
- `system_error_log`
@@ -301,7 +298,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `/app`
- 真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web`
- `/static`
- - 页面文件,如 `app.html`, `pipeline.html`, `paper_trading.html`, `live_trading.html`, `review_center.html`, `market.html`, `onchain.html`, `chat.html`
+ - 页面文件,如 `app.html`, `pipeline.html`, `paper_trading.html`, `live_trading.html`, `review_center.html`, `market.html`, `chat.html`
- `/tests`
- 状态机、认证订阅、推荐链路、调度、模拟交易、行情、复盘、前端页面约束等回归测试
- `/scripts`
@@ -363,7 +360,6 @@ docker compose logs --tail=100 alphax-price-streamer
```bash
docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/stats
docker compose exec alphax-web curl -fsS 'http://127.0.0.1:8190/api/pipeline/runs?page=1&page_size=5'
-docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/onchain/overview
```
### 8.2 CLI
@@ -379,7 +375,6 @@ python -m app.cli market
python -m app.cli review
python -m app.cli event
python -m app.cli sentiment --collect
-python -m app.cli onchain
python -m app.cli llm-insights --scope sentiment --limit 40
```
diff --git a/README_DOCKER.md b/README_DOCKER.md
index 51818af..54b2bdf 100644
--- a/README_DOCKER.md
+++ b/README_DOCKER.md
@@ -7,8 +7,7 @@
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190`。
- 运行时数据库是 PostgreSQL,compose 内置 `postgres:16` 服务。
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。
-- 链上主数据源是 NodeReal,只采集 Ethereum / BSC;`.env` 中配置 `ALPHAX_NODEREAL_API_KEY` 后,`python -m app.cli onchain` 才会产出链上事件。
-- NodeReal 原始 Transfer 会先进入 `onchain_raw_events` 展示,再自动读取 ERC-20 metadata 尝试映射交易所 `XXX/USDT`;`ALPHAX_ONCHAIN_TOKEN_MAPPINGS` 只作为人工兜底。
+- 当前定位是 CEX 行情机会捕捉,链上采集/API 模块已下线,后续重新规划后再接入。
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
@@ -16,7 +15,7 @@
```bash
cp .env.example .env
-# 按需编辑 .env 中的 POSTGRES_* / DATABASE_URL / 推送 / LLM / 链上 API key
+# 按需编辑 .env 中的 POSTGRES_* / DATABASE_URL / 推送 / LLM / 交易所 API key
docker compose build
docker compose up -d postgres alphax-web alphax-scheduler
@@ -133,7 +132,6 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py \
| 粗筛/细筛 | 900s |
| 舆情采集 | 1800s |
| LLM 舆情分析 | 1800s |
-| 链上追踪 | 1800s |
| 复盘 | 86400s |
## 常用验证
@@ -151,7 +149,6 @@ docker compose logs --tail=100 alphax-scheduler
```bash
docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/stats
docker compose exec alphax-web curl -fsS 'http://127.0.0.1:8190/api/pipeline/runs?page=1&page_size=5'
-docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/onchain/overview
```
## LLM 解释层配置
diff --git a/app/cli.py b/app/cli.py
index 775eaa7..81e0aee 100644
--- a/app/cli.py
+++ b/app/cli.py
@@ -3,7 +3,7 @@
import argparse
import sys
-from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, live_trading_smoke, market_overview, onchain_monitor, paper_trader, price_streamer, price_tracker, review_engine, sentiment_monitor
+from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, live_trading_smoke, market_overview, paper_trader, price_streamer, price_tracker, review_engine, sentiment_monitor
def build_parser():
@@ -41,9 +41,6 @@ def build_parser():
sentiment.add_argument("--check", action="store_true", help="输出舆情异动")
sentiment.add_argument("--scores", action="store_true", help="输出评分")
- onchain = subparsers.add_parser("onchain", help="运行链上追踪任务")
- onchain.add_argument("--limit", type=int, default=60, help="本轮最多处理的 token 映射数量")
-
llm = subparsers.add_parser("llm-insights", help="异步生成 LLM 缓存解释")
llm.add_argument("--scope", choices=["recommendations", "sentiment", "sentiment-events", "review"], default="recommendations")
llm.add_argument("--limit", type=int, default=30)
@@ -116,8 +113,6 @@ def main():
}
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
return result
- if args.command == "onchain":
- return onchain_monitor.run_once(limit=args.limit)
if args.command == "llm-insights":
from app.services import llm_insights
diff --git a/app/config/config_loader.py b/app/config/config_loader.py
index c5ade72..296c868 100644
--- a/app/config/config_loader.py
+++ b/app/config/config_loader.py
@@ -38,12 +38,6 @@ _SIGNAL_NAME_ALIASES = {
"大户偏多": "top_trader_long",
"舆情共振": "sentiment_resonance",
"板块联动": "sector_rotation",
- "DEX 放量": "dex_volume_spike",
- "链上成交放量": "dex_volume_spike",
- "流动性增加": "liquidity_add",
- "交易所流出": "exchange_outflow",
- "鲸鱼增持": "whale_accumulation",
- "聪明钱买入": "smart_money_buying",
"流动性撤出风险": "liquidity_remove_risk",
"交易所流入风险": "exchange_inflow_risk",
"持仓集中风险": "holder_concentration_risk",
diff --git a/app/config/system_config.py b/app/config/system_config.py
index beb1d9b..0024a8f 100644
--- a/app/config/system_config.py
+++ b/app/config/system_config.py
@@ -13,7 +13,6 @@ from app.db.runtime_config_db import (
get_live_trading_config,
get_monitoring_config,
get_notification_config,
- get_onchain_config,
get_paper_trading_config,
get_price_streamer_config,
get_scheduler_config,
@@ -73,82 +72,6 @@ def default_llm_config():
}
-def default_onchain_config(default_chains=("ethereum", "bsc")):
- return {
- "enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", True),
- "chains": _env_list("ALPHAX_ONCHAIN_CHAINS", default_chains),
- "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",
- "alchemy_enabled": _env_bool("ALPHAX_ALCHEMY_ENABLED", True),
- "alchemy_chains": _env_list("ALPHAX_ALCHEMY_CHAINS", ("ethereum", "bsc")),
- "alchemy_api_key_env": "ALPHAX_ALCHEMY_API_KEY",
- "token_mappings_env": "ALPHAX_ONCHAIN_TOKEN_MAPPINGS",
- "token_mappings": [],
- "nodereal_log_block_lookback": _env_int("ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK", 120),
- "nodereal_max_logs_per_token": _env_int("ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN", 25),
- "nodereal_raw_transfer_enabled": _env_bool("ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED", True),
- "nodereal_raw_block_lookback": _env_int("ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK", 1),
- "nodereal_raw_max_logs_per_chain": _env_int("ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN", 30),
- "nodereal_auto_mapping_enabled": _env_bool("ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED", True),
- "nodereal_auto_mapping_confidence": _env_int("ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE", 82),
- "alchemy_log_block_lookback": _env_int("ALPHAX_ALCHEMY_LOG_BLOCK_LOOKBACK", 9),
- "alchemy_max_logs_per_token": _env_int("ALPHAX_ALCHEMY_MAX_LOGS_PER_TOKEN", 25),
- "alchemy_raw_transfer_enabled": _env_bool("ALPHAX_ALCHEMY_RAW_TRANSFER_ENABLED", True),
- "alchemy_raw_chains": _env_list("ALPHAX_ALCHEMY_RAW_CHAINS", ("ethereum",)),
- "alchemy_raw_block_lookback": _env_int("ALPHAX_ALCHEMY_RAW_BLOCK_LOOKBACK", 1),
- "alchemy_raw_max_logs_per_chain": _env_int("ALPHAX_ALCHEMY_RAW_MAX_LOGS_PER_CHAIN", 8),
- "alchemy_auto_mapping_enabled": _env_bool("ALPHAX_ALCHEMY_AUTO_MAPPING_ENABLED", True),
- "alchemy_auto_mapping_confidence": _env_int("ALPHAX_ALCHEMY_AUTO_MAPPING_CONFIDENCE", 82),
- "candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
- "candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
- "candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),
- "candidate_cooldown_hours": _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6),
- "whale_tx_usd": _env_float("ALPHAX_ONCHAIN_WHALE_TX_USD", 250000),
- }
-
-
-def _onchain_env_overrides(default_chains=("ethereum", "bsc")):
- """Honor explicit on-chain env vars even when DB runtime config exists."""
- checks = {
- "ALPHAX_ONCHAIN_ENABLED": ("enabled", lambda: _env_bool("ALPHAX_ONCHAIN_ENABLED", False)),
- "ALPHAX_ONCHAIN_PROVIDER": ("provider", lambda: _env_str("ALPHAX_ONCHAIN_PROVIDER", "nodereal")),
- "ALPHAX_ONCHAIN_CHAINS": ("chains", lambda: _env_list("ALPHAX_ONCHAIN_CHAINS", default_chains)),
- "ALPHAX_ONCHAIN_TIMEOUT": ("timeout", lambda: _env_int("ALPHAX_ONCHAIN_TIMEOUT", 15)),
- "ALPHAX_NODEREAL_ENABLED": ("nodereal_enabled", lambda: _env_bool("ALPHAX_NODEREAL_ENABLED", True)),
- "ALPHAX_NODEREAL_CHAINS": ("nodereal_chains", lambda: _env_list("ALPHAX_NODEREAL_CHAINS", ("ethereum", "bsc"))),
- "ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK": ("nodereal_log_block_lookback", lambda: _env_int("ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK", 120)),
- "ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN": ("nodereal_max_logs_per_token", lambda: _env_int("ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN", 25)),
- "ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED": ("nodereal_raw_transfer_enabled", lambda: _env_bool("ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED", True)),
- "ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK": ("nodereal_raw_block_lookback", lambda: _env_int("ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK", 1)),
- "ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN": ("nodereal_raw_max_logs_per_chain", lambda: _env_int("ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN", 30)),
- "ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED": ("nodereal_auto_mapping_enabled", lambda: _env_bool("ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED", True)),
- "ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE": ("nodereal_auto_mapping_confidence", lambda: _env_int("ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE", 82)),
- "ALPHAX_ALCHEMY_ENABLED": ("alchemy_enabled", lambda: _env_bool("ALPHAX_ALCHEMY_ENABLED", False)),
- "ALPHAX_ALCHEMY_CHAINS": ("alchemy_chains", lambda: _env_list("ALPHAX_ALCHEMY_CHAINS", ("ethereum", "bsc"))),
- "ALPHAX_ALCHEMY_LOG_BLOCK_LOOKBACK": ("alchemy_log_block_lookback", lambda: _env_int("ALPHAX_ALCHEMY_LOG_BLOCK_LOOKBACK", 9)),
- "ALPHAX_ALCHEMY_MAX_LOGS_PER_TOKEN": ("alchemy_max_logs_per_token", lambda: _env_int("ALPHAX_ALCHEMY_MAX_LOGS_PER_TOKEN", 25)),
- "ALPHAX_ALCHEMY_RAW_TRANSFER_ENABLED": ("alchemy_raw_transfer_enabled", lambda: _env_bool("ALPHAX_ALCHEMY_RAW_TRANSFER_ENABLED", True)),
- "ALPHAX_ALCHEMY_RAW_CHAINS": ("alchemy_raw_chains", lambda: _env_list("ALPHAX_ALCHEMY_RAW_CHAINS", ("ethereum",))),
- "ALPHAX_ALCHEMY_RAW_BLOCK_LOOKBACK": ("alchemy_raw_block_lookback", lambda: _env_int("ALPHAX_ALCHEMY_RAW_BLOCK_LOOKBACK", 1)),
- "ALPHAX_ALCHEMY_RAW_MAX_LOGS_PER_CHAIN": ("alchemy_raw_max_logs_per_chain", lambda: _env_int("ALPHAX_ALCHEMY_RAW_MAX_LOGS_PER_CHAIN", 8)),
- "ALPHAX_ALCHEMY_AUTO_MAPPING_ENABLED": ("alchemy_auto_mapping_enabled", lambda: _env_bool("ALPHAX_ALCHEMY_AUTO_MAPPING_ENABLED", True)),
- "ALPHAX_ALCHEMY_AUTO_MAPPING_CONFIDENCE": ("alchemy_auto_mapping_confidence", lambda: _env_int("ALPHAX_ALCHEMY_AUTO_MAPPING_CONFIDENCE", 82)),
- "ALPHAX_ONCHAIN_CANDIDATE_ENABLED": ("candidate_enabled", lambda: _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True)),
- "ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE": ("candidate_min_score", lambda: _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70)),
- "ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE": ("candidate_min_confidence", lambda: _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70)),
- "ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS": ("candidate_cooldown_hours", lambda: _env_float("ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS", 6)),
- "ALPHAX_ONCHAIN_WHALE_TX_USD": ("whale_tx_usd", lambda: _env_float("ALPHAX_ONCHAIN_WHALE_TX_USD", 250000)),
- }
- overrides = {}
- for env_name, (key, loader) in checks.items():
- if _env_present(env_name):
- overrides[key] = loader()
- return overrides
-
-
def default_paper_trading_config():
# One shared default keeps buy-now entries and wait-pullback orders from
# drifting into two unrelated RR standards. The explicit entry/order envs
@@ -512,7 +435,6 @@ def default_scheduler_config():
def seed_runtime_system_defaults():
return seed_system_defaults({
"llm": (default_llm_config(), "LLM provider and module switches; API key remains in env"),
- "onchain": (default_onchain_config(), "On-chain provider and signal thresholds; API keys remain in env"),
"paper_trading": (default_paper_trading_config(), "Paper trading account and execution model"),
"live_trading": (default_live_trading_config(), "Live trading exchange, account and risk settings; API secrets remain in env"),
"price_streamer": (default_price_streamer_config(), "Realtime websocket price streamer settings"),
@@ -538,15 +460,6 @@ def llm_config():
return cfg or default_llm_config()
-def onchain_config(default_chains=("ethereum", "bsc")):
- cfg = get_onchain_config(default=None)
- if cfg is None:
- _seed_one("onchain", default_onchain_config(default_chains), "On-chain provider and signal thresholds; API keys remain in env")
- cfg = get_onchain_config(default=None)
- merged = deep_merge(default_onchain_config(default_chains), cfg or {})
- return deep_merge(merged, _onchain_env_overrides(default_chains))
-
-
def paper_trading_config():
cfg = get_paper_trading_config(default=None)
if cfg is None:
@@ -676,7 +589,6 @@ __all__ = [
"default_live_trading_config",
"default_monitoring_config",
"default_notification_config",
- "default_onchain_config",
"default_paper_trading_config",
"default_price_streamer_config",
"default_scheduler_config",
@@ -687,7 +599,6 @@ __all__ = [
"live_trading_config",
"monitoring_config",
"notification_config",
- "onchain_config",
"paper_trading_config",
"price_streamer_config",
"scheduler_config",
diff --git a/app/core/factor_scoring.py b/app/core/factor_scoring.py
index aaf63a0..3eb73c0 100644
--- a/app/core/factor_scoring.py
+++ b/app/core/factor_scoring.py
@@ -99,14 +99,6 @@ FACTOR_GROUPS = {
"top_trader_long": "positioning",
"sector_rotation": "narrative",
"sentiment_resonance": "narrative",
- "dex_volume_spike": "onchain_flow",
- "liquidity_add": "onchain_flow",
- "exchange_outflow": "onchain_flow",
- "whale_accumulation": "onchain_flow",
- "smart_money_buying": "onchain_flow",
- "liquidity_remove_risk": "risk",
- "exchange_inflow_risk": "risk",
- "holder_concentration_risk": "risk",
"funding_extreme": "risk",
"trend_exhaustion": "risk",
"false_breakout": "risk",
@@ -142,7 +134,6 @@ GROUP_CAPS = {
"structure": 16.0,
"positioning": 8.0,
"narrative": 5.0,
- "onchain_flow": 6.0,
"entry_quality": 7.0,
"risk": 12.0,
"relative_strength": 6.0,
@@ -164,12 +155,6 @@ WEIGHT_ALIASES = {
"top_trader_long": ("大户偏多",),
"sector_rotation": ("板块联动",),
"sentiment_resonance": ("舆情共振",),
- "dex_volume_spike": ("DEX 放量", "链上成交放量"),
- "liquidity_add": ("流动性增加",),
- "exchange_outflow": ("交易所流出",),
- "whale_accumulation": ("鲸鱼增持",),
- "smart_money_buying": ("聪明钱买入",),
- "liquidity_remove_risk": ("流动性撤出风险",),
"exchange_inflow_risk": ("交易所流入风险",),
"holder_concentration_risk": ("持仓集中风险",),
"funding_extreme": ("资金费率极端",),
@@ -306,7 +291,7 @@ class FactorScorer:
bucket = groups.setdefault(group, {"score_delta": 0.0, "items": 0})
bucket["score_delta"] = round(bucket["score_delta"] + _safe_float(item.get("score_delta")), 3)
bucket["items"] += 1
- opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative", "onchain_flow"}
+ opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative"}
opportunity_score = round(sum(_safe_float(v.get("score_delta")) for k, v in groups.items() if k in opportunity_groups), 3)
entry_score = round(_safe_float(groups.get("entry_quality", {}).get("score_delta")), 3)
risk_score = round(abs(min(0.0, _safe_float(groups.get("risk", {}).get("score_delta")))), 3)
diff --git a/app/core/opportunity_funnel.py b/app/core/opportunity_funnel.py
index a3fffcb..202db49 100644
--- a/app/core/opportunity_funnel.py
+++ b/app/core/opportunity_funnel.py
@@ -149,7 +149,7 @@ def quality_filter_reasons(candidate: Dict[str, Any], score: int, threshold: int
codes.append("high_chase_risk")
if any(keyword in text for keyword in ("空头加速", "放量阴线", "量价背离", "资金费率极端")):
codes.append("bearish_flow_risk")
- if any(keyword in text for keyword in ("板块联动", "舆情共振", "链上", "DEX", "鲸鱼", "聪明钱")):
+ if any(keyword in text for keyword in ("板块联动", "舆情共振")):
codes.append("multi_source_resonance")
# 24h 强势榜异动属于“发现层”信号,不能因为低分或旧背景被直接打成纯拒绝。
diff --git a/app/core/signal_direction.py b/app/core/signal_direction.py
index 7354e37..fdaff01 100644
--- a/app/core/signal_direction.py
+++ b/app/core/signal_direction.py
@@ -164,7 +164,7 @@ def sanitize_factor_breakdown_for_side(summary: dict[str, Any], side: object) ->
except Exception:
pass
bucket["items"] += 1
- opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative", "onchain_flow"}
+ opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative"}
src["items"] = kept
src["groups"] = groups
src["total_delta"] = round(sum(float(i.get("score_delta") or 0) for i in kept), 3)
diff --git a/app/db/chat_assistant_db.py b/app/db/chat_assistant_db.py
index 4508648..a4ce30b 100644
--- a/app/db/chat_assistant_db.py
+++ b/app/db/chat_assistant_db.py
@@ -300,7 +300,7 @@ def bootstrap_chat(user_id: int) -> dict:
"分析 BTC/USDT 现在的技术面",
"解释当前看板里这条推荐为什么是等回踩",
"看一下市场总览,今天是偏强还是偏弱",
- "这个币的链上异动有哪些",
+ "这个币的舆情和技术面有没有共振",
"帮我复盘最近一次纸面交易",
]
return {
diff --git a/app/db/data_export.py b/app/db/data_export.py
index c5602c6..d08728f 100644
--- a/app/db/data_export.py
+++ b/app/db/data_export.py
@@ -31,7 +31,6 @@ RECENT_TABLES = {
"sentiment_events": ("detected_at", 5000, "sentiment events"),
"llm_insights": ("created_at", 5000, "LLM analysis cache"),
"event_news": ("detected_at", 5000, "news/event candidates"),
- "onchain_events": ("detected_at", 5000, "normalized on-chain events"),
"latest_price_cache": ("updated_at", 2000, "latest price cache"),
}
diff --git a/app/db/onchain_db.py b/app/db/onchain_db.py
deleted file mode 100644
index 03d7a5e..0000000
--- a/app/db/onchain_db.py
+++ /dev/null
@@ -1,1110 +0,0 @@
-"""On-chain discovery storage and read models.
-
-The on-chain layer is a research/discovery input. It stores normalized external
-facts and can enqueue technical-check candidates, but it must not create or
-mutate trading recommendations directly.
-"""
-
-import json
-import os
-from datetime import datetime, timedelta
-
-from app.db.altcoin_db import get_conn
-from app.db.postgres_connection import ensure_migrations_once
-from app.config.system_config import onchain_config
-
-
-MIN_MAPPING_CONFIDENCE = 70
-
-
-SIGNAL_LABELS = {
- "large_token_transfer": "链上大额转账",
- "dex_volume_spike": "链上成交放量",
- "liquidity_add": "流动性增加",
- "liquidity_remove_risk": "流动性撤出风险",
- "exchange_outflow": "交易所流出",
- "exchange_inflow_risk": "交易所流入风险",
- "whale_accumulation": "鲸鱼增持",
- "holder_growth": "持有人增长",
- "holder_concentration_risk": "持仓集中风险",
- "smart_money_buying": "聪明钱买入",
-}
-
-RAW_EVENT_TYPE_LABELS = {
- "evm_transfer": "EVM 原始转账",
-}
-
-RAW_EVENT_EXPLAINERS = {
- "evm_transfer": {
- "plain": "NodeReal 捕捉到 EVM 链上的 ERC-20 Transfer 原始日志。",
- "meaning": "这代表链上确实有资金转移,但没有完成币种映射前,不能直接进入策略候选。",
- "priority": "medium",
- },
-}
-
-POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "holder_growth", "smart_money_buying"}
-RISK_SIGNALS = {"liquidity_remove_risk", "exchange_inflow_risk", "holder_concentration_risk"}
-STANDARD_SIGNAL_SOURCE = "nodereal"
-LEGACY_ONCHAIN_SOURCES = {"dexscreener", "etherscan", "helius"}
-
-
-def _now():
- return datetime.now().isoformat()
-
-
-def _dump(value):
- return json.dumps(value or {}, ensure_ascii=False, sort_keys=True, default=str)
-
-
-def _load(value, fallback=None):
- try:
- if isinstance(value, str) and value.strip():
- return json.loads(value)
- if value is not None:
- return value
- except Exception:
- pass
- return fallback
-
-
-def _symbol_base(symbol):
- return str(symbol or "").upper().replace("/USDT", "").replace("USDT", "").strip()
-
-
-def normalize_symbol(symbol):
- base = _symbol_base(symbol)
- return f"{base}/USDT" if base else ""
-
-
-def signal_label(code):
- return SIGNAL_LABELS.get(str(code or ""), str(code or "链上信号"))
-
-
-def signal_direction(code):
- code = str(code or "")
- if code in RISK_SIGNALS:
- return "risk"
- if code in POSITIVE_SIGNALS:
- return "positive"
- return "neutral"
-
-
-def _is_legacy_onchain_source(source):
- return str(source or "").lower().strip() in LEGACY_ONCHAIN_SOURCES
-
-
-def raw_event_type_label(event_type):
- 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():
- ensure_migrations_once()
-
-
-def upsert_token_mapping(symbol, chain, contract_address, source="", confidence=0, raw=None, is_active=True):
- init_onchain_tables()
- now = _now()
- symbol = normalize_symbol(symbol)
- chain = str(chain or "").lower().strip()
- contract_address = str(contract_address or "").strip()
- if not symbol or not chain or not contract_address:
- return 0
- conn = get_conn()
- cur = conn.execute(
- """
- INSERT INTO onchain_token_map
- (symbol, chain, contract_address, source, confidence, is_active, raw_json, created_at, updated_at)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT(symbol, chain, contract_address) DO UPDATE SET
- source=excluded.source,
- confidence=GREATEST(onchain_token_map.confidence, excluded.confidence),
- is_active=excluded.is_active,
- raw_json=excluded.raw_json,
- updated_at=excluded.updated_at
- """,
- (symbol, chain, contract_address, source or "", int(confidence or 0), 1 if is_active else 0, _dump(raw), now, now),
- )
- conn.commit()
- row = conn.execute(
- "SELECT id FROM onchain_token_map WHERE symbol=%s AND chain=%s AND contract_address=%s",
- (symbol, chain, contract_address),
- ).fetchone()
- conn.close()
- return int(row["id"] if row else 0)
-
-
-def get_token_mappings(symbol="", min_confidence=MIN_MAPPING_CONFIDENCE, active_only=True):
- init_onchain_tables()
- clauses = ["confidence >= %s"]
- params = [int(min_confidence or 0)]
- if symbol:
- clauses.append("symbol=%s")
- params.append(normalize_symbol(symbol))
- if active_only:
- clauses.append("is_active=1")
- conn = get_conn()
- rows = conn.execute(
- f"""
- SELECT * FROM onchain_token_map
- WHERE {' AND '.join(clauses)}
- ORDER BY confidence DESC, updated_at DESC
- """,
- tuple(params),
- ).fetchall()
- conn.close()
- return [dict(row) for row in rows]
-
-
-def _event_hash(event):
- raw = "|".join(
- [
- str(event.get("source") or ""),
- str(event.get("chain") or ""),
- str(event.get("symbol") or ""),
- str(event.get("signal_code") or event.get("event_type") or ""),
- str(event.get("tx_hash") or event.get("contract_address") or ""),
- str(event.get("detected_at") or ""),
- str(round(float(event.get("value_usd") or 0), 2)),
- ]
- ).lower()
- import hashlib
-
- return hashlib.sha256(raw.encode()).hexdigest()[:24]
-
-
-def _raw_event_hash(event):
- raw = "|".join(
- [
- str(event.get("source") or ""),
- str(event.get("chain") or ""),
- str(event.get("event_type") or ""),
- str(event.get("token_address") or ""),
- str(event.get("url") or ""),
- str(round(float(event.get("amount") or 0), 4)),
- str(round(float(event.get("total_amount") or 0), 4)),
- ]
- ).lower()
- import hashlib
-
- return hashlib.sha256(raw.encode()).hexdigest()[:24]
-
-
-def insert_onchain_event(event):
- init_onchain_tables()
- item = dict(event or {})
- item["symbol"] = normalize_symbol(item.get("symbol"))
- item["chain"] = str(item.get("chain") or "").lower().strip()
- item["signal_code"] = str(item.get("signal_code") or "").strip()
- item["event_type"] = str(item.get("event_type") or item["signal_code"] or "onchain_event")
- if not item["symbol"] or not item["chain"] or not item["signal_code"]:
- return 0
- item["signal_label"] = item.get("signal_label") or signal_label(item["signal_code"])
- item["direction"] = item.get("direction") or signal_direction(item["signal_code"])
- item["detected_at"] = str(item.get("detected_at") or _now())
- item["event_hash"] = item.get("event_hash") or _event_hash(item)
- conn = get_conn()
- try:
- cur = conn.execute(
- """
- INSERT INTO onchain_events (
- event_hash, chain, symbol, contract_address, event_type, signal_code, signal_label,
- direction, value_usd, amount, tx_hash, wallet_address, wallet_label,
- counterparty_label, confidence, severity, status, detected_at, source, url, raw_json
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT(event_hash) DO NOTHING
- RETURNING id
- """,
- (
- item["event_hash"],
- item["chain"],
- item["symbol"],
- item.get("contract_address") or "",
- item["event_type"],
- item["signal_code"],
- item["signal_label"],
- item["direction"],
- float(item.get("value_usd") or 0),
- float(item.get("amount") or 0),
- item.get("tx_hash") or "",
- item.get("wallet_address") or "",
- item.get("wallet_label") or "",
- item.get("counterparty_label") or "",
- int(item.get("confidence") or 0),
- item.get("severity") or "B",
- item.get("status") or "new",
- item["detected_at"],
- item.get("source") or "",
- item.get("url") or "",
- _dump(item.get("raw") or item.get("raw_json") or {}),
- ),
- )
- row = cur.fetchone()
- event_id = int(row["id"] if row else 0)
- conn.commit()
- finally:
- conn.close()
- return event_id
-
-
-def find_mapping_by_contract(chain, contract_address):
- init_onchain_tables()
- chain = str(chain or "").lower().strip()
- contract_address = str(contract_address or "").strip()
- if not chain or not contract_address:
- return None
- conn = get_conn()
- row = conn.execute(
- """
- SELECT *
- FROM onchain_token_map
- WHERE chain=%s AND lower(contract_address)=lower(%s) AND is_active=1
- ORDER BY confidence DESC, updated_at DESC
- LIMIT 1
- """,
- (chain, contract_address),
- ).fetchone()
- conn.close()
- return dict(row) if row else None
-
-
-def insert_onchain_raw_event(event):
- init_onchain_tables()
- item = dict(event or {})
- item["source"] = str(item.get("source") or "").strip()
- item["chain"] = str(item.get("chain") or "").lower().strip()
- item["event_type"] = str(item.get("event_type") or "onchain_raw_event").strip()
- item["token_address"] = str(item.get("token_address") or "").strip()
- if not item["source"] or not item["chain"] or not item["event_type"] or not item["token_address"]:
- return 0
- item["detected_at"] = str(item.get("detected_at") or _now())
- item["event_hash"] = item.get("event_hash") or _raw_event_hash(item)
- item["mapped_symbol"] = normalize_symbol(item.get("mapped_symbol")) if item.get("mapped_symbol") else ""
- item["mapping_status"] = str(item.get("mapping_status") or ("mapped" if item["mapped_symbol"] else "unmapped"))
- conn = get_conn()
- try:
- cur = conn.execute(
- """
- INSERT INTO onchain_raw_events (
- event_hash, source, chain, event_type, token_address, symbol_guess, name,
- title, description, url, icon, amount, total_amount, importance,
- mapped_symbol, mapping_status, detected_at, raw_json
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT(event_hash) DO NOTHING
- RETURNING id
- """,
- (
- item["event_hash"],
- item["source"],
- item["chain"],
- item["event_type"],
- item["token_address"],
- item.get("symbol_guess") or "",
- item.get("name") or "",
- item.get("title") or raw_event_type_label(item["event_type"]),
- item.get("description") or "",
- item.get("url") or "",
- item.get("icon") or "",
- float(item.get("amount") or 0),
- float(item.get("total_amount") or 0),
- float(item.get("importance") or 0),
- item["mapped_symbol"],
- item["mapping_status"],
- item["detected_at"],
- _dump(item.get("raw") or item.get("raw_json") or {}),
- ),
- )
- row = cur.fetchone()
- event_id = int(row["id"] if row else 0)
- conn.commit()
- finally:
- conn.close()
- return event_id
-
-
-def insert_token_metric(metric):
- init_onchain_tables()
- item = dict(metric or {})
- item["symbol"] = normalize_symbol(item.get("symbol"))
- item["chain"] = str(item.get("chain") or "").lower().strip()
- item["window"] = str(item.get("window") or "1h").strip()
- item["metric_time"] = str(item.get("metric_time") or _now())
- if not item["symbol"] or not item["chain"] or not item["window"]:
- return 0
- conn = get_conn()
- cur = conn.execute(
- """
- INSERT INTO onchain_token_metrics (
- symbol, chain, contract_address, "window", metric_time,
- dex_volume_usd, dex_volume_change_pct, liquidity_usd, liquidity_change_pct,
- exchange_netflow_usd, whale_accumulation_usd, holder_delta, smart_money_score,
- onchain_score, risk_score, source, raw_json
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT(symbol, chain, contract_address, "window", metric_time) DO UPDATE SET
- dex_volume_usd=excluded.dex_volume_usd,
- dex_volume_change_pct=excluded.dex_volume_change_pct,
- liquidity_usd=excluded.liquidity_usd,
- liquidity_change_pct=excluded.liquidity_change_pct,
- exchange_netflow_usd=excluded.exchange_netflow_usd,
- whale_accumulation_usd=excluded.whale_accumulation_usd,
- holder_delta=excluded.holder_delta,
- smart_money_score=excluded.smart_money_score,
- onchain_score=excluded.onchain_score,
- risk_score=excluded.risk_score,
- source=excluded.source,
- raw_json=excluded.raw_json
- RETURNING id
- """,
- (
- item["symbol"],
- item["chain"],
- item.get("contract_address") or "",
- item["window"],
- item["metric_time"],
- float(item.get("dex_volume_usd") or 0),
- float(item.get("dex_volume_change_pct") or 0),
- float(item.get("liquidity_usd") or 0),
- float(item.get("liquidity_change_pct") or 0),
- float(item.get("exchange_netflow_usd") or 0),
- float(item.get("whale_accumulation_usd") or 0),
- float(item.get("holder_delta") or 0),
- float(item.get("smart_money_score") or 0),
- float(item.get("onchain_score") or 0),
- float(item.get("risk_score") or 0),
- item.get("source") or "",
- _dump(item.get("raw") or item.get("raw_json") or {}),
- ),
- )
- conn.commit()
- metric_id = int(cur.fetchone()["id"] or 0)
- conn.close()
- return metric_id
-
-
-def _latest_metrics_subquery(hours=24):
- return """
- SELECT m.*
- FROM onchain_token_metrics m
- JOIN (
- SELECT symbol, chain, contract_address, MAX(metric_time) AS max_time
- FROM onchain_token_metrics
- WHERE metric_time >= %s
- GROUP BY symbol, chain, contract_address
- ) latest ON latest.symbol=m.symbol
- AND latest.chain=m.chain
- AND latest.contract_address=m.contract_address
- AND latest.max_time=m.metric_time
- """
-
-
-def get_onchain_overview(hours=24):
- init_onchain_tables()
- cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
- conn = get_conn()
- event_rows = conn.execute("SELECT * FROM onchain_events WHERE detected_at >= %s", (cutoff,)).fetchall()
- raw_rows = conn.execute("SELECT * FROM onchain_raw_events WHERE detected_at >= %s", (cutoff,)).fetchall()
- raw_latest = conn.execute(
- """
- SELECT * FROM onchain_raw_events
- 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
- LIMIT 12
- """,
- (cutoff,),
- ).fetchall()
- metric_rows = conn.execute(_latest_metrics_subquery(hours), (cutoff,)).fetchall()
- rec_rows = conn.execute(
- "SELECT symbol, id, execution_status, action_status, display_bucket FROM recommendation WHERE status='active'"
- ).fetchall()
- conn.close()
- active = {row["symbol"]: dict(row) for row in rec_rows}
- events = [dict(row) for row in event_rows]
- raw_events = [dict(row) for row in raw_rows]
- metrics = [dict(row) for row in metric_rows]
- standard_events = [e for e in events if str(e.get("source") or "").lower() == STANDARD_SIGNAL_SOURCE]
- node_metrics = [m for m in metrics if not _is_legacy_onchain_source(m.get("source"))]
- hot = sorted(node_metrics, key=lambda x: float(x.get("onchain_score") or 0), reverse=True)[:8]
- risks = sorted(node_metrics, key=lambda x: float(x.get("risk_score") or 0), reverse=True)[:8]
- mapped_feed = _mapped_raw_signal_items(raw_events, active, limit=8)
- total_netflow = sum(float(x.get("exchange_netflow_usd") or 0) for x in metrics)
- return {
- "hours": int(hours or 24),
- "updated_at": _now(),
- "kpi": {
- "event_count": len(standard_events),
- "raw_event_count": len(raw_events),
- "raw_unmapped_count": sum(1 for e in raw_events if e.get("mapping_status") == "unmapped"),
- "raw_mapped_count": sum(1 for e in raw_events if e.get("mapping_status") == "mapped"),
- "token_count": len({(m["symbol"], m["chain"], m.get("contract_address") or "") for m in node_metrics})
- or len({(e.get("mapped_symbol"), e.get("chain")) for e in raw_events if e.get("mapping_status") == "mapped" and e.get("mapped_symbol")}),
- "positive_events": sum(1 for e in standard_events if e.get("direction") == "positive"),
- "risk_events": sum(1 for e in standard_events if e.get("direction") == "risk"),
- "exchange_netflow_usd": round(total_netflow, 2),
- "mapped_signal_count": len(mapped_feed),
- },
- "hot_tokens": ([_format_metric_item(row, active) for row in hot] or mapped_feed),
- "risk_tokens": [_format_metric_item(row, active) for row in risks],
- "raw_events": _format_raw_events(raw_latest),
- "signals": _signal_counts(standard_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()
- nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
- alchemy_env = str(cfg.get("alchemy_api_key_env") or "ALPHAX_ALCHEMY_API_KEY")
- 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
- AND COALESCE(source, '') NOT IN ('dexscreener', 'etherscan', 'helius')
- """,
- (cutoff,),
- ).fetchone()[0]
- signal_total = conn.execute(
- """
- SELECT COUNT(*)
- FROM onchain_events
- WHERE detected_at >= %s AND source=%s
- """,
- (cutoff, STANDARD_SIGNAL_SOURCE),
- ).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 ""
- provider = str(cfg.get("provider") or "nodereal").strip().lower()
- requested = {p.strip() for p in provider.split(",") if p.strip()}
- if requested & {"all", "multi", "both"}:
- requested = {"nodereal", "alchemy"}
- nodereal_enabled = bool(cfg.get("nodereal_enabled", True)) and "nodereal" in requested
- alchemy_enabled = bool(cfg.get("alchemy_enabled", False)) and "alchemy" in requested
- nodereal_metrics = int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal"))
- nodereal_signals = int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal"))
- alchemy_metrics = int(sum(row["count"] for row in metric_sources if row["source"] == "alchemy"))
- alchemy_signals = int(sum(row["count"] for row in signal_sources if row["source"] == "alchemy"))
- providers = [
- {
- "provider": "nodereal",
- "label": "NodeReal",
- "enabled": nodereal_enabled,
- "api_key_present": bool(os.getenv(nodereal_env, "").strip()),
- "implemented": True,
- "role": "EVM 主链上数据源:Transfer 日志、大额转账、holder 变化",
- "raw_events": int(raw_total or 0),
- "metrics": nodereal_metrics,
- "signals": nodereal_signals,
- "status": _provider_status_label(
- nodereal_enabled,
- True,
- int(raw_total or 0) + nodereal_metrics + nodereal_signals,
- last_error if "nodereal" in str(last_error).lower() else "",
- ),
- },
- {
- "provider": "alchemy",
- "label": "Alchemy",
- "enabled": alchemy_enabled,
- "api_key_present": bool(os.getenv(alchemy_env, "").strip()),
- "implemented": True,
- "role": "EVM 备用/并行链上数据源:Transfer 日志、大额转账、ERC-20 自动映射",
- "raw_events": int(raw_total or 0),
- "metrics": alchemy_metrics,
- "signals": alchemy_signals,
- "status": _provider_status_label(
- alchemy_enabled,
- True,
- int(raw_total or 0) + alchemy_metrics + alchemy_signals,
- last_error if "alchemy" 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):
- counts = {}
- for e in events:
- code = e.get("signal_code") or ""
- if not code:
- continue
- counts.setdefault(code, {"signal_code": code, "signal_label": signal_label(code), "count": 0})
- counts[code]["count"] += 1
- return sorted(counts.values(), key=lambda x: x["count"], reverse=True)
-
-
-def _format_metric_item(row, active=None):
- active = active or {}
- item = dict(row)
- item["raw"] = _load(item.pop("raw_json", "{}"), {})
- rec = active.get(item.get("symbol")) or {}
- item["recommendation"] = {
- "rec_id": rec.get("id") or 0,
- "execution_status": rec.get("execution_status") or "",
- "action_status": rec.get("action_status") or "",
- "display_bucket": rec.get("display_bucket") or "",
- "has_active": bool(rec),
- }
- return item
-
-
-def _mapped_raw_signal_items(raw_events, active=None, limit=8):
- active = active or {}
- grouped = {}
- for event in raw_events:
- if event.get("mapping_status") != "mapped" or not event.get("mapped_symbol"):
- continue
- if str(event.get("source") or "").lower() != STANDARD_SIGNAL_SOURCE:
- continue
- key = (event.get("mapped_symbol"), event.get("chain") or "")
- current = grouped.setdefault(
- key,
- {
- "symbol": event.get("mapped_symbol"),
- "chain": event.get("chain") or "",
- "contract_address": event.get("token_address") or "",
- "source": STANDARD_SIGNAL_SOURCE,
- "onchain_score": 0,
- "risk_score": 0,
- "event_count": 0,
- "mapped_event_count": 0,
- "latest_event_at": "",
- "dex_volume_usd": 0,
- "dex_volume_change_pct": 0,
- "liquidity_usd": 0,
- "liquidity_change_pct": 0,
- },
- )
- importance = float(event.get("importance") or 0)
- current["event_count"] += 1
- current["mapped_event_count"] += 1
- current["onchain_score"] = max(float(current.get("onchain_score") or 0), importance)
- current["latest_event_at"] = max(str(current.get("latest_event_at") or ""), str(event.get("detected_at") or ""))
- items = []
- for item in grouped.values():
- rec = active.get(item.get("symbol")) or {}
- item["recommendation"] = {
- "rec_id": rec.get("id") or 0,
- "execution_status": rec.get("execution_status") or "",
- "action_status": rec.get("action_status") or "",
- "display_bucket": rec.get("display_bucket") or "",
- "has_active": bool(rec),
- }
- items.append(item)
- return sorted(items, key=lambda x: (float(x.get("onchain_score") or 0), str(x.get("latest_event_at") or "")), reverse=True)[: int(limit or 8)]
-
-
-def list_onchain_tokens(limit=30, offset=0, chain="", signal="", hours=24):
- init_onchain_tables()
- limit = max(1, min(int(limit or 30), 100))
- offset = max(0, int(offset or 0))
- cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
- clauses = ["COALESCE(m.source, '') NOT IN ('dexscreener', 'etherscan', 'helius')"]
- params = []
- if chain:
- clauses.append("m.chain=%s")
- params.append(str(chain).lower())
- if signal:
- clauses.append(
- """
- EXISTS (
- SELECT 1 FROM onchain_events e
- WHERE e.symbol=m.symbol AND e.chain=m.chain
- AND e.detected_at >= %s AND e.signal_code=%s
- AND COALESCE(e.source, '') NOT IN ('dexscreener', 'etherscan', 'helius')
- )
- """
- )
- params.extend([cutoff, signal])
- where = " AND ".join(clauses) if clauses else "1=1"
- conn = get_conn()
- total = conn.execute(
- f"""
- SELECT COUNT(*) FROM (
- SELECT m.symbol, m.chain, m.contract_address
- FROM onchain_token_metrics m
- WHERE {where}
- GROUP BY m.symbol, m.chain, m.contract_address
- )
- """,
- tuple(params),
- ).fetchone()[0]
- rows = conn.execute(
- f"""
- SELECT m.*,
- (SELECT COUNT(*) FROM onchain_events e
- WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.detected_at >= %s
- AND COALESCE(e.source, '') NOT IN ('dexscreener', 'etherscan', 'helius')) AS event_count,
- (SELECT COUNT(*) FROM onchain_events e
- WHERE e.symbol=m.symbol AND e.chain=m.chain AND e.direction='risk' AND e.detected_at >= %s
- AND COALESCE(e.source, '') NOT IN ('dexscreener', 'etherscan', 'helius')) AS risk_event_count
- FROM ({_latest_metrics_subquery(hours)}) m
- WHERE {where}
- ORDER BY m.onchain_score DESC, m.risk_score DESC, m.metric_time DESC
- LIMIT %s OFFSET %s
- """,
- (cutoff, cutoff, cutoff, *params, limit, offset),
- ).fetchall()
- rec_rows = conn.execute(
- "SELECT symbol, id, execution_status, action_status, display_bucket FROM recommendation WHERE status='active'"
- ).fetchall()
- conn.close()
- active = {row["symbol"]: dict(row) for row in rec_rows}
- return {
- "items": [_format_metric_item(row, active) for row in rows],
- "total": int(total or 0),
- "limit": limit,
- "offset": offset,
- "has_more": offset + len(rows) < int(total or 0),
- }
-
-
-def get_onchain_token_detail(symbol, hours=72):
- init_onchain_tables()
- symbol = normalize_symbol(symbol)
- cutoff = (datetime.now() - timedelta(hours=int(hours or 72))).isoformat()
- conn = get_conn()
- mappings = conn.execute(
- "SELECT * FROM onchain_token_map WHERE symbol=%s ORDER BY confidence DESC, updated_at DESC",
- (symbol,),
- ).fetchall()
- events = conn.execute(
- """
- SELECT * FROM onchain_events
- WHERE symbol=%s AND detected_at >= %s
- AND COALESCE(source, '') NOT IN ('dexscreener', 'etherscan', 'helius')
- ORDER BY detected_at DESC, id DESC
- LIMIT 100
- """,
- (symbol, cutoff),
- ).fetchall()
- metrics = conn.execute(
- """
- SELECT * FROM onchain_token_metrics
- WHERE symbol=%s AND metric_time >= %s
- AND COALESCE(source, '') NOT IN ('dexscreener', 'etherscan', 'helius')
- ORDER BY metric_time DESC, id DESC
- LIMIT 100
- """,
- (symbol, cutoff),
- ).fetchall()
- raw_events = conn.execute(
- """
- SELECT * FROM onchain_raw_events
- WHERE mapped_symbol=%s AND detected_at >= %s
- AND mapping_status='mapped'
- AND source=%s
- ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
- LIMIT 100
- """,
- (symbol, cutoff, STANDARD_SIGNAL_SOURCE),
- ).fetchall()
- rec = conn.execute(
- """
- SELECT id, rec_time, action_status, execution_status, display_bucket, entry_price, current_price
- FROM recommendation
- WHERE symbol=%s AND status='active'
- ORDER BY id DESC LIMIT 1
- """,
- (symbol,),
- ).fetchone()
- conn.close()
- return {
- "symbol": symbol,
- "hours": int(hours or 72),
- "mappings": [_with_raw(row) for row in mappings],
- "events": [_with_raw(row) for row in events],
- "raw_events": _format_raw_events(raw_events),
- "raw_event_count": len(raw_events),
- "metrics": [_with_raw(row) for row in metrics],
- "recommendation": dict(rec) if rec else None,
- }
-
-
-def get_onchain_factor_context(symbol, hours=24):
- """Return compact on-chain factor evidence for strategy scoring.
-
- This read model intentionally does not create recommendations. It only
- exposes mapped NodeReal facts to the technical confirmation layer.
- """
- init_onchain_tables()
- symbol = normalize_symbol(symbol)
- if not symbol:
- return {"symbol": "", "positive_events": [], "risk_events": [], "metrics": {}, "has_data": False}
- cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
- conn = get_conn()
- try:
- metric = conn.execute(
- """
- SELECT *
- FROM onchain_token_metrics
- WHERE symbol=%s AND metric_time >= %s
- AND COALESCE(source, '') NOT IN ('dexscreener', 'etherscan', 'helius')
- ORDER BY metric_time DESC, id DESC
- LIMIT 1
- """,
- (symbol, cutoff),
- ).fetchone()
- rows = conn.execute(
- """
- SELECT *
- FROM onchain_events
- WHERE symbol=%s AND detected_at >= %s
- AND COALESCE(source, '') NOT IN ('dexscreener', 'etherscan', 'helius')
- ORDER BY detected_at DESC, confidence DESC, value_usd DESC, id DESC
- LIMIT 20
- """,
- (symbol, cutoff),
- ).fetchall()
- finally:
- conn.close()
-
- events = [dict(row) for row in rows]
- positive = [e for e in events if e.get("direction") == "positive"]
- risks = [e for e in events if e.get("direction") == "risk"]
- metric_item = dict(metric) if metric else {}
- return {
- "symbol": symbol,
- "hours": int(hours or 24),
- "has_data": bool(metric_item or events),
- "metrics": metric_item,
- "positive_events": positive,
- "risk_events": risks,
- "event_count": len(events),
- "positive_event_count": len(positive),
- "risk_event_count": len(risks),
- "top_positive": positive[0] if positive else None,
- "top_risk": risks[0] if risks else None,
- }
-
-
-def list_onchain_events(limit=50, offset=0, chain="", signal="", status="", hours=24):
- init_onchain_tables()
- limit = max(1, min(int(limit or 50), 200))
- offset = max(0, int(offset or 0))
- cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
- clauses = ["detected_at >= %s"]
- params = [cutoff]
- if chain:
- clauses.append("chain=%s")
- params.append(str(chain).lower())
- if signal:
- clauses.append("signal_code=%s")
- params.append(signal)
- if status:
- clauses.append("status=%s")
- params.append(status)
- where = " AND ".join(clauses)
- conn = get_conn()
- total = conn.execute(f"SELECT COUNT(*) FROM onchain_events WHERE {where}", tuple(params)).fetchone()[0]
- rows = conn.execute(
- f"""
- SELECT * FROM onchain_events
- WHERE {where}
- ORDER BY detected_at::timestamp DESC, id DESC
- LIMIT %s OFFSET %s
- """,
- (*params, limit, offset),
- ).fetchall()
- conn.close()
- 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="", priority="", hours=24):
- init_onchain_tables()
- limit = max(1, min(int(limit or 50), 200))
- offset = max(0, int(offset or 0))
- cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
- clauses = ["detected_at >= %s"]
- params = [cutoff]
- if chain:
- clauses.append("chain=%s")
- params.append(str(chain).lower())
- if source:
- clauses.append("source=%s")
- params.append(source)
- if event_type:
- clauses.append("event_type=%s")
- params.append(event_type)
- if mapping_status:
- clauses.append("mapping_status=%s")
- params.append(mapping_status)
- if priority:
- if priority == "important":
- clauses.append("importance >= %s")
- params.append(70)
- elif priority == "low":
- clauses.append("importance < %s")
- params.append(70)
- where = " AND ".join(clauses)
- conn = get_conn()
- total = conn.execute(f"SELECT COUNT(*) FROM onchain_raw_events WHERE {where}", tuple(params)).fetchone()[0]
- rows = conn.execute(
- f"""
- SELECT * FROM onchain_raw_events
- WHERE {where}
- ORDER BY detected_at::timestamp DESC, importance DESC, id DESC
- LIMIT %s OFFSET %s
- """,
- (*params, limit, offset),
- ).fetchall()
- conn.close()
- return {
- "items": _format_raw_events(rows),
- "total": int(total or 0),
- "limit": limit,
- "offset": offset,
- "has_more": offset + len(rows) < int(total or 0),
- }
-
-
-def update_event_status(event_ids, status):
- if not event_ids:
- return 0
- init_onchain_tables()
- conn = get_conn()
- cur = conn.execute(
- "UPDATE onchain_events SET status=%s WHERE id IN (" + ",".join(["%s"] * len(event_ids)) + ")",
- (status, *[int(x) for x in event_ids]),
- )
- conn.commit()
- conn.close()
- return int(cur.rowcount or 0)
-
-
-def _with_raw(row):
- item = dict(row)
- if "raw_json" in item:
- item["raw"] = _load(item.pop("raw_json"), {})
- return item
-
-
-def _format_raw_events(rows):
- rows = list(rows or [])
- metadata = _raw_event_token_metadata(rows)
- return [_format_raw_event(row, metadata.get(_raw_event_token_key(row), {})) for row in rows]
-
-
-def _raw_event_token_key(row):
- item = dict(row)
- return (str(item.get("chain") or "").lower(), str(item.get("token_address") or "").lower())
-
-
-def _raw_event_token_metadata(rows):
- keys = sorted({key for key in (_raw_event_token_key(row) for row in rows or []) if key[0] and key[1]})
- if not keys:
- return {}
- clauses = []
- params = []
- for chain, contract in keys:
- clauses.append("(chain=%s AND lower(contract_address)=lower(%s))")
- params.extend([chain, contract])
- conn = get_conn()
- try:
- found = conn.execute(
- f"""
- SELECT chain, contract_address, symbol, raw_json
- FROM onchain_token_map
- WHERE is_active=1 AND ({' OR '.join(clauses)})
- ORDER BY confidence DESC, updated_at DESC
- """,
- tuple(params),
- ).fetchall()
- finally:
- conn.close()
- metadata = {}
- for row in found:
- key = (str(row["chain"] or "").lower(), str(row["contract_address"] or "").lower())
- if key in metadata:
- continue
- raw = _load(row["raw_json"], {}) or {}
- metadata[key] = {
- "symbol": normalize_symbol(row["symbol"]),
- "token_symbol": raw.get("symbol") or _symbol_base(row["symbol"]),
- "name": raw.get("name") or "",
- "decimals": int(raw.get("decimals") or 0),
- }
- return metadata
-
-
-def _format_raw_event(row, token_meta=None):
- item = _with_raw(row)
- token_meta = token_meta or {}
- 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"
- display = _humanize_raw_transfer(item, token_meta)
- item.update(display)
- item["pipeline_note"] = (
- "已映射,可进入后续链上信号分析。"
- if item.get("mapping_status") == "mapped"
- else "未完成币种映射,仅作为原始观察,不进入推荐。"
- )
- item["token_short"] = _short_address(item.get("token_address"))
- return item
-
-
-def _humanize_raw_transfer(item, token_meta):
- token_symbol = token_meta.get("token_symbol") or item.get("symbol_guess") or _symbol_base(item.get("mapped_symbol")) or "Token"
- decimals = int(token_meta.get("decimals") or 0)
- amount = float(item.get("total_amount") or item.get("amount") or 0)
- display_amount = amount
- if decimals > 0 and amount >= 10**decimals:
- display_amount = amount / (10**decimals)
- raw = item.get("raw") or {}
- topics = raw.get("topics") if isinstance(raw, dict) else []
- from_addr = _topic_address(topics[1]) if isinstance(topics, list) and len(topics) > 1 else ""
- to_addr = _topic_address(topics[2]) if isinstance(topics, list) and len(topics) > 2 else ""
- mapped = item.get("mapped_symbol") or normalize_symbol(token_symbol)
- amount_label = f"{_compact_number(display_amount)} {token_symbol}" if display_amount else f"未知数量 {token_symbol}"
- route = ""
- if from_addr and to_addr:
- route = f"从 {_short_address(from_addr)} 转至 {_short_address(to_addr)}"
- elif to_addr:
- route = f"转入 {_short_address(to_addr)}"
- summary = f"{mapped} 出现一笔 ERC-20 转账,数量约 {amount_label}"
- if route:
- summary += f",{route}"
- return {
- "display_amount": round(display_amount, 8) if display_amount else 0,
- "display_amount_label": amount_label,
- "from_address": from_addr,
- "to_address": to_addr,
- "from_short": _short_address(from_addr),
- "to_short": _short_address(to_addr),
- "human_summary": summary,
- }
-
-
-def _topic_address(topic):
- topic = str(topic or "")
- if topic.startswith("0x") and len(topic) >= 42:
- return "0x" + topic[-40:]
- return ""
-
-
-def _compact_number(value):
- value = float(value or 0)
- abs_value = abs(value)
- if abs_value >= 1_000_000_000:
- return f"{value / 1_000_000_000:.2f}B"
- if abs_value >= 1_000_000:
- return f"{value / 1_000_000:.2f}M"
- if abs_value >= 1_000:
- return f"{value / 1_000:.2f}K"
- if abs_value >= 1:
- return f"{value:.2f}".rstrip("0").rstrip(".")
- if abs_value > 0:
- return f"{value:.6f}".rstrip("0").rstrip(".")
- return "0"
-
-
-def _short_address(value):
- value = str(value or "")
- if len(value) <= 14:
- return value
- return value[:6] + "..." + value[-4:]
diff --git a/app/db/operations_dashboard.py b/app/db/operations_dashboard.py
index 5ed137b..0ecafba 100644
--- a/app/db/operations_dashboard.py
+++ b/app/db/operations_dashboard.py
@@ -95,28 +95,6 @@ def _display_error_summary(message: str, source: str = "", error_type: str = "")
return ""
lowered = text.lower()
source_text = f"{source} " if source else ""
- if "alchemy" in lowered and (
- "ssl" in lowered
- or "httpsconnectionpool" in lowered
- or "max retries" in lowered
- or "ssleoferror" in lowered
- or "nameresolution" in lowered
- or "name resolution" in lowered
- ):
- symbol = text.split(":", 1)[0] if "/USDT" in text.split(":", 1)[0] else ""
- prefix = f"{symbol} · " if symbol else ""
- return f"{prefix}Alchemy 链上数据源连接异常"
- if "nodereal" in lowered and (
- "ssl" in lowered
- or "httpsconnectionpool" in lowered
- or "max retries" in lowered
- or "timeout" in lowered
- or "nameresolution" in lowered
- or "name resolution" in lowered
- ):
- symbol = text.split(":", 1)[0] if "/USDT" in text.split(":", 1)[0] else ""
- prefix = f"{symbol} · " if symbol else ""
- return f"{prefix}NodeReal 链上数据源连接异常"
if "feishu" in lowered or "lark" in lowered or "webhook" in lowered:
return "飞书通知配置或发送异常"
if "timeout" in lowered or "timed out" in lowered:
@@ -154,7 +132,6 @@ def _job_display_name(job_name: str) -> str:
"confirm": "交易确认",
"screener": "异动筛选",
"sentiment": "舆情采集",
- "onchain": "链上追踪",
"llm-sentiment": "AI 舆情",
"review": "复盘迭代",
}.get(job_name, job_name)
@@ -212,7 +189,6 @@ def _build_data_sources(conn) -> tuple[list[dict], list[dict]]:
("market", "市场快照", "market_snapshots", "snapshot_time", 600, 1800),
("prices", "实时价格", "latest_price_cache", "updated_at", 360, 900),
("sentiment", "新闻舆情", "event_news", "detected_at", 3600, 10800),
- ("onchain", "链上事件", "onchain_raw_events", "detected_at", 7200, 21600),
("llm", "AI 解读", "llm_insights", "updated_at", 7200, 21600),
]
for code, name, table, time_col, warn, danger in specs:
diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py
index 424d2db..0e82705 100644
--- a/app/db/recommendation_queries.py
+++ b/app/db/recommendation_queries.py
@@ -1,7 +1,7 @@
"""Recommendation and lifecycle-facing DB API."""
import json
-from datetime import datetime, timedelta
+from datetime import datetime
from app.db.recommendation_commands import apply_recommendation_state_transition
from app.db.recommendation_state import (
@@ -190,82 +190,7 @@ def get_active_recommendations(actionable_only: bool = False):
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
continue
result.append(item)
- return _attach_onchain_context(result)
-
-
-def _attach_onchain_context(items):
- if not items:
- return items
- symbols = sorted({item.get("symbol") for item in items if item.get("symbol")})
- if not symbols:
- return items
- placeholders = ",".join(["%s"] * len(symbols))
- try:
- conn = get_conn()
- rows = conn.execute(
- f"""
- SELECT m.*
- FROM onchain_token_metrics m
- JOIN (
- SELECT symbol, MAX(metric_time) AS max_time
- FROM onchain_token_metrics
- WHERE symbol IN ({placeholders})
- GROUP BY symbol
- ) latest ON latest.symbol=m.symbol AND latest.max_time=m.metric_time
- """,
- tuple(symbols),
- ).fetchall()
- events = conn.execute(
- f"""
- SELECT *
- FROM onchain_events
- WHERE symbol IN ({placeholders})
- AND detected_at >= %s
- ORDER BY detected_at::timestamp DESC, id DESC
- """,
- (*symbols, (datetime.now() - timedelta(hours=24)).isoformat()),
- ).fetchall()
- conn.close()
- except Exception:
- return items
- metrics = {row["symbol"]: dict(row) for row in rows}
- by_symbol = {}
- for row in events:
- by_symbol.setdefault(row["symbol"], []).append(dict(row))
- for item in items:
- metric = metrics.get(item.get("symbol")) or {}
- evs = by_symbol.get(item.get("symbol")) or []
- if not metric and not evs:
- continue
- risk_events = [e for e in evs if e.get("direction") == "risk"]
- positive_events = [e for e in evs if e.get("direction") == "positive"]
- if risk_events:
- headline = risk_events[0].get("signal_label") or "链上风险升温"
- elif positive_events:
- headline = positive_events[0].get("signal_label") or "链上资金异动"
- else:
- headline = "链上异动"
- item["onchain_context"] = {
- "headline": headline,
- "chain": metric.get("chain") or (evs[0].get("chain") if evs else ""),
- "onchain_score": metric.get("onchain_score") or 0,
- "risk_score": metric.get("risk_score") or 0,
- "dex_volume_usd": metric.get("dex_volume_usd") or 0,
- "liquidity_usd": metric.get("liquidity_usd") or 0,
- "event_count_24h": len(evs),
- "risk_event_count_24h": len(risk_events),
- "top_events": [
- {
- "signal_code": e.get("signal_code"),
- "signal_label": e.get("signal_label"),
- "direction": e.get("direction"),
- "value_usd": e.get("value_usd") or 0,
- "detected_at": e.get("detected_at"),
- }
- for e in evs[:3]
- ],
- }
- return items
+ return result
def get_active_recommendations_deduped(
@@ -416,10 +341,8 @@ def get_active_recommendations_deduped(
summary["expired_filtered"] = summary.pop("expired", 0)
if not with_meta:
- _attach_onchain_context(all_items)
return attach_recommendation_insights(all_items)
page_items = all_items[offset : offset + limit] if limit else all_items[offset:]
- _attach_onchain_context(page_items)
attach_recommendation_insights(page_items)
return {
"items": page_items,
@@ -520,26 +443,6 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
""",
(symbol,),
).fetchall()
- metric_row = conn.execute(
- """
- SELECT *
- FROM onchain_token_metrics
- WHERE symbol=%s
- ORDER BY metric_time DESC, id DESC
- LIMIT 1
- """,
- (symbol,),
- ).fetchone()
- onchain_rows = conn.execute(
- """
- SELECT *
- FROM onchain_events
- WHERE symbol=%s
- ORDER BY detected_at DESC, id DESC
- LIMIT 60
- """,
- (symbol,),
- ).fetchall()
latest_price = conn.execute("SELECT * FROM latest_price_cache WHERE symbol=%s", (symbol,)).fetchone()
coin_state = conn.execute("SELECT * FROM coin_state WHERE symbol=%s ORDER BY detected_at DESC LIMIT 1", (symbol,)).fetchone()
conn.close()
@@ -589,9 +492,6 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
item = dict(row)
item["detail_json"] = _loads_json(item.get("detail_json"), {})
events.append(item)
- onchain_events = [dict(row) for row in onchain_rows]
- positive_onchain = sum(1 for row in onchain_events if row.get("direction") == "positive")
- risk_onchain = sum(1 for row in onchain_events if row.get("direction") == "risk")
return {
"symbol": symbol,
"current": current,
@@ -603,12 +503,6 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
"paper_orders": paper_orders,
"paper_events": events,
"trade_markers": _build_trade_markers(paper_trades, paper_orders, events),
- "onchain": {
- "metric": dict(metric_row) if metric_row else {},
- "events": onchain_events,
- "positive_count": positive_onchain,
- "risk_count": risk_onchain,
- },
"summary": {
"history_count": len(history),
"screening_count": len(screening),
@@ -616,7 +510,6 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
"paper_trade_count": len(paper_rows),
"paper_order_count": len(order_rows),
"paper_event_count": len(events),
- "onchain_event_count": len(onchain_events),
},
}
diff --git a/app/db/review_center.py b/app/db/review_center.py
index ca5ae18..c79e51d 100644
--- a/app/db/review_center.py
+++ b/app/db/review_center.py
@@ -3,7 +3,7 @@
This module keeps the new review/iteration semantics explicit:
- opportunity review describes whether the system found useful opportunities;
- paper trading review is the only place where PnL is treated as execution PnL;
-- evidence attribution describes whether onchain/sentiment/LLM evidence helped.
+- evidence attribution describes whether CEX events/sentiment/LLM evidence helped.
"""
from __future__ import annotations
@@ -189,28 +189,6 @@ def _evidence_review(conn, since):
""",
(since,),
).fetchall()]
- onchain_rows = [dict(r) for r in conn.execute(
- """
- SELECT source, chain, symbol, signal_code, signal_label, direction, value_usd,
- confidence, severity, detected_at
- FROM onchain_events
- WHERE detected_at >= %s
- ORDER BY detected_at DESC, id DESC
- LIMIT 80
- """,
- (since,),
- ).fetchall()]
- raw_onchain_rows = [dict(r) for r in conn.execute(
- """
- SELECT source, chain, event_type, symbol_guess, mapped_symbol, mapping_status,
- importance, detected_at, title
- FROM onchain_raw_events
- WHERE detected_at >= %s
- ORDER BY importance DESC, detected_at DESC, id DESC
- LIMIT 80
- """,
- (since,),
- ).fetchall()]
llm_rows = [dict(r) for r in conn.execute(
"""
SELECT target_type, insight_type, status, model, prompt_version, updated_at
@@ -222,31 +200,21 @@ def _evidence_review(conn, since):
(since,),
).fetchall()]
- mapped_raw = [r for r in raw_onchain_rows if r.get("mapping_status") == "mapped" or r.get("mapped_symbol")]
- high_onchain = [r for r in onchain_rows if _safe_int(r.get("confidence")) >= 70 or str(r.get("severity") or "").upper() in ("A", "S")]
actionable_news = [r for r in news_rows if r.get("decision") in ("recommend", "observe", "risk") or str(r.get("importance") or "").upper() in ("A", "S")]
llm_success = [r for r in llm_rows if r.get("status") == "success"]
return {
- "definition": "多源归因只判断证据贡献:舆情、链上、LLM 是否帮助发现/解释机会,不直接生成交易收益。",
+ "definition": "多源归因只判断证据贡献:CEX 事件/舆情、LLM 是否帮助发现/解释机会,不直接生成交易收益。",
"summary": {
"news_count": len(news_rows),
"actionable_news_count": len(actionable_news),
- "onchain_signal_count": len(onchain_rows),
- "high_confidence_onchain_count": len(high_onchain),
- "raw_onchain_count": len(raw_onchain_rows),
- "mapped_raw_onchain_count": len(mapped_raw),
"llm_runs": len(llm_rows),
"llm_success_count": len(llm_success),
},
"news_sources": _bucket_count(news_rows, "source", "unknown"),
"news_decisions": _bucket_count(news_rows, "decision", "unprocessed"),
- "onchain_sources": _bucket_count(onchain_rows, "source", "unknown"),
- "onchain_signals": _bucket_count(onchain_rows, "signal_code", "unknown")[:12],
- "raw_mapping": _bucket_count(raw_onchain_rows, "mapping_status", "unknown"),
"llm_status": _bucket_count(llm_rows, "status", "unknown"),
"recent_news": news_rows[:8],
- "recent_onchain": onchain_rows[:8],
"recent_llm": llm_rows[:8],
}
@@ -400,7 +368,7 @@ def get_review_center_dashboard(days=30):
"principles": [
"机会归档不计算交易收益,只记录发现、确认、失效和漏选。",
"真实收益口径只来自策略交易或未来真实交易账本。",
- "链上、舆情、LLM 属于证据层,只做发现和解释,不直接改变推荐状态。",
+ "CEX 事件/舆情、LLM 属于证据层,只做发现和解释,不直接改变推荐状态。",
"策略迭代只发布经过样本约束和灰度闸门验证的规则。",
],
"opportunity": opportunity,
diff --git a/app/db/runtime_config_db.py b/app/db/runtime_config_db.py
index c8280d6..1038832 100644
--- a/app/db/runtime_config_db.py
+++ b/app/db/runtime_config_db.py
@@ -205,14 +205,6 @@ def set_llm_config(value, updated_by="", source="manual"):
return set_config("system", "llm", value, description="LLM provider and module switches; API key stays in env", source=source, updated_by=updated_by)
-def get_onchain_config(default=None):
- return get_config("system", "onchain", default=default)
-
-
-def set_onchain_config(value, updated_by="", source="manual"):
- return set_config("system", "onchain", value, description="On-chain provider and signal thresholds; API keys stay in env", source=source, updated_by=updated_by)
-
-
def get_paper_trading_config(default=None):
return get_config("system", "paper_trading", default=default)
@@ -290,7 +282,6 @@ __all__ = [
"get_llm_config",
"get_monitoring_config",
"get_notification_config",
- "get_onchain_config",
"get_paper_trading_config",
"get_price_streamer_config",
"get_scheduler_config",
@@ -307,7 +298,6 @@ __all__ = [
"set_llm_config",
"set_monitoring_config",
"set_notification_config",
- "set_onchain_config",
"set_paper_trading_config",
"set_price_streamer_config",
"set_scheduler_config",
diff --git a/app/db/scheduler_db.py b/app/db/scheduler_db.py
index 516f3d7..2c21b3f 100644
--- a/app/db/scheduler_db.py
+++ b/app/db/scheduler_db.py
@@ -88,16 +88,6 @@ DEFAULT_JOBS = [
"description": "舆情采集",
"sort_order": 50,
},
- {
- "job_name": "onchain",
- "command": "onchain",
- "args": [],
- "every_seconds": 1800,
- "initial_delay": 150,
- "lock_group": "onchain_write",
- "description": "链上异动追踪",
- "sort_order": 55,
- },
{
"job_name": "llm-sentiment",
"command": "llm-insights",
@@ -384,7 +374,6 @@ def _display_job_name(job_name):
"confirm": "确认",
"screener": "粗筛",
"sentiment": "舆情",
- "onchain": "链上",
"llm-sentiment": "AI舆情",
"paper-trader": "策略交易",
"review": "复盘",
diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py
index 4817c31..40f559c 100644
--- a/app/db/strategy_insights.py
+++ b/app/db/strategy_insights.py
@@ -518,8 +518,6 @@ def get_strategy_insights(days: int | None = None):
text = str(code or "").strip()
if text.startswith(("sentiment_", "listing_", "ecosystem_")):
add_bucket(evidence_map, "舆情:" + text, item)
- elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_")):
- add_bucket(evidence_map, "链上:" + text, item)
mc = safe_dict_json(item.get("market_context_json"))
factor_breakdown = safe_dict_json(mc.get("factor_score_breakdown")) or safe_dict_json(ep.get("factor_score_breakdown"))
score_components = safe_dict_json(mc.get("score_components")) or safe_dict_json(ep.get("score_components"))
@@ -570,8 +568,6 @@ def get_strategy_insights(days: int | None = None):
text = str(code or "").strip()
if text.startswith(("sentiment_", "listing_", "ecosystem_")):
add_trade_bucket(trade_evidence_map, "舆情:" + text, item)
- elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_", "onchain_")):
- add_trade_bucket(trade_evidence_map, "链上:" + text, item)
if item.get("strategy_version"):
add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item)
add_trade_bucket(trade_strategy_code_map, strategy_code, item)
diff --git a/app/services/alchemy_client.py b/app/services/alchemy_client.py
deleted file mode 100644
index dd06213..0000000
--- a/app/services/alchemy_client.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""Small JSON-RPC client for Alchemy EVM endpoints."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-
-import requests
-
-
-DEFAULT_ALCHEMY_CHAIN_ENDPOINTS = {
- "ethereum": "https://eth-mainnet.g.alchemy.com/v2/{api_key}",
- "bsc": "https://bnb-mainnet.g.alchemy.com/v2/{api_key}",
-}
-
-
-@dataclass(frozen=True)
-class AlchemyConfig:
- api_key: str
- timeout: int = 15
- endpoints: dict[str, str] | None = None
-
-
-class AlchemyClient:
- def __init__(self, config: AlchemyConfig):
- self.config = config
- self.endpoints = {**DEFAULT_ALCHEMY_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"alchemy_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"alchemy_http_{resp.status_code}:{resp.text[:200]}")
- data = resp.json()
- if data.get("error"):
- raise RuntimeError(f"alchemy_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 eth_call(self, chain: str, to_address: str, data: str, block: str = "latest") -> str:
- result = self.call(chain, "eth_call", [{"to": to_address, "data": data}, block])
- return str(result or "")
-
- 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
diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py
index c51f540..64eea25 100644
--- a/app/services/altcoin_confirm.py
+++ b/app/services/altcoin_confirm.py
@@ -61,7 +61,6 @@ from app.core.opportunity_funnel import build_screening_detail
from app.core.factor_scoring import FactorScorer
from app.core.signal_direction import excluded_factor_delta, sanitize_factor_breakdown_for_side, sanitize_signals_for_side
from app.core.market_regime import classify_market_regime
-from app.db.onchain_db import get_onchain_factor_context
from app.db.strategy_signal_queries import insert_strategy_signal
from app.services.market_overview import get_crypto_market_overview
from app.strategies.altcoin_breakout import (
@@ -561,87 +560,6 @@ def compute_sector_context(symbol, cand_detail=None):
return ctx
-def _onchain_base_delta(event):
- code = str((event or {}).get("signal_code") or "")
- value_usd = float((event or {}).get("value_usd") or 0)
- confidence = float((event or {}).get("confidence") or 0)
- base = {
- "whale_accumulation": 2.5,
- "smart_money_buying": 2.5,
- "exchange_outflow": 2.0,
- "dex_volume_spike": 2.0,
- "liquidity_add": 1.5,
- "liquidity_remove_risk": 2.5,
- "exchange_inflow_risk": 2.5,
- "holder_concentration_risk": 2.0,
- }.get(code, 1.0)
- if value_usd >= 1_000_000 or confidence >= 85:
- base += 0.5
- return min(base, 3.5)
-
-
-def _apply_onchain_factor_score(symbol, factor_scorer):
- """Score mapped NodeReal evidence as a first-class strategy factor."""
- try:
- ctx = get_onchain_factor_context(symbol, hours=24)
- except Exception:
- return 0.0, [], {"has_data": False, "error": "onchain_context_unavailable"}
- if not ctx.get("has_data"):
- return 0.0, [], ctx
- score_delta = 0.0
- signals = []
- metric = ctx.get("metrics") or {}
- onchain_score = float(metric.get("onchain_score") or 0)
- risk_score = float(metric.get("risk_score") or 0)
- positive_events = ctx.get("positive_events") or []
- risk_events = ctx.get("risk_events") or []
-
- for event in positive_events[:3]:
- code = event.get("signal_code") or "unknown"
- label = event.get("signal_label") or code
- delta = factor_scorer.delta(
- code,
- _onchain_base_delta(event),
- evidence=f"NodeReal正向链上事件: {label}",
- value={"value_usd": event.get("value_usd"), "confidence": event.get("confidence")},
- )
- score_delta += delta
- signals.append(f"链上正向: {label}(置信{event.get('confidence') or 0}, ${float(event.get('value_usd') or 0):.0f})")
-
- for event in risk_events[:3]:
- code = event.get("signal_code") or "holder_concentration_risk"
- label = event.get("signal_label") or code
- delta = factor_scorer.delta(
- code,
- -_onchain_base_delta(event),
- evidence=f"NodeReal风险链上事件: {label}",
- value={"value_usd": event.get("value_usd"), "confidence": event.get("confidence")},
- )
- score_delta += delta
- signals.append(f"⚠️ 链上风险: {label}(置信{event.get('confidence') or 0}, ${float(event.get('value_usd') or 0):.0f})")
-
- if onchain_score >= 75 and not positive_events:
- delta = factor_scorer.delta(
- "smart_money_buying",
- 1.5,
- evidence="NodeReal综合链上重要性分>=75",
- value=onchain_score,
- )
- score_delta += delta
- signals.append(f"链上综合重要性高({onchain_score:.0f})")
- if risk_score >= 50 and not risk_events:
- delta = factor_scorer.delta(
- "holder_concentration_risk",
- -1.5,
- evidence="NodeReal综合链上风险分>=50",
- value=risk_score,
- )
- score_delta += delta
- signals.append(f"⚠️ 链上综合风险高({risk_score:.0f})")
- ctx["score_delta"] = round(score_delta, 3)
- return score_delta, signals, ctx
-
-
def _current_market_regime_context():
"""Read latest market snapshot and classify regime without live network fallback."""
try:
@@ -1296,13 +1214,6 @@ def confirm_burst(symbol, cand):
evidence="Binance futures top trader long pct > 55%",
value=top_long,
)
- onchain_delta, onchain_signals, onchain_context = _apply_onchain_factor_score(symbol, factor_scorer)
- if onchain_signals:
- signals.extend(onchain_signals)
- score += onchain_delta
- else:
- onchain_context = onchain_context or {"has_data": False}
-
# ---- v1.8 新增因子:RS + OI/Funding + 多周期对齐 ----
rs_context = {}
oi_funding_context = {}
@@ -2196,7 +2107,6 @@ def confirm_burst(symbol, cand):
market_context["short_breakdown_retest_1h"] = short_1h if 'short_1h' in locals() else {}
market_context["side"] = trade_side
market_context["factor_score_breakdown"] = factor_score_breakdown
- market_context["onchain_context"] = onchain_context
market_context["market_regime"] = market_regime
market_context["market_snapshot"] = regime_context.get("market_snapshot") or {}
if direction_conflict_signals or direction_removed_factors:
@@ -2231,7 +2141,6 @@ def confirm_burst(symbol, cand):
if entry_plan:
entry_plan["side"] = trade_side
entry_plan["factor_score_breakdown"] = factor_score_breakdown
- entry_plan["onchain_context"] = onchain_context
entry_plan["market_regime"] = market_regime
entry_plan["score_components"] = market_context["score_components"]
entry_plan["decision_log"] = market_context["decision_log"]
@@ -2257,7 +2166,6 @@ def confirm_burst(symbol, cand):
"market_context": market_context,
"derivatives_context": derivatives_context,
"sector_context": sector_context,
- "onchain_context": onchain_context,
"market_regime": market_regime,
"factor_score_breakdown": factor_score_breakdown,
"decision_log": market_context["decision_log"],
diff --git a/app/services/chat_assistant.py b/app/services/chat_assistant.py
index c819b3b..ee4431b 100644
--- a/app/services/chat_assistant.py
+++ b/app/services/chat_assistant.py
@@ -20,7 +20,6 @@ from app.core.pa_engine import calc_atr, full_pa_analysis
from app.db import chat_assistant_db
from app.db.analytics import get_pipeline_runs
from app.db.llm_insights import compute_input_hash, repair_mojibake_json, repair_mojibake_text
-from app.db.onchain_db import get_onchain_token_detail, get_onchain_overview
from app.db.schema import get_conn
from app.services.llm_insights import get_llm_params
from app.services.market_overview import get_crypto_market_overview
@@ -30,7 +29,7 @@ exchange = ccxt.binance({"enableRateLimit": True})
CRYPTO_TERMS = {
"btc", "eth", "bnb", "sol", "xrp", "doge", "ada", "sui", "link", "qnt",
- "币", "加密", "crypto", "usdt", "binance", "行情", "链上", "舆情", "推荐", "复盘",
+ "币", "加密", "crypto", "usdt", "binance", "行情", "舆情", "推荐", "复盘",
"k线", "k 线", "技术面", "止盈", "止损", "山寨",
}
@@ -39,7 +38,6 @@ INTENT_LABELS = {
"market_overview": "市场问答",
"recommendation_explain": "推荐解释",
"sentiment": "舆情解读",
- "onchain": "链上异动",
"review": "复盘查询",
"restricted": "受限内容",
"help": "帮助",
@@ -129,8 +127,6 @@ def detect_intent(message: str, symbol: str = "") -> str:
return "unsupported"
if any(k in text for k in ("怎么用", "能做什么", "帮助", "help", "问什么")):
return "help"
- if any(k in text for k in ("链上", "onchain", "鲸鱼", "转账", "dex", "流动性", "合约")):
- return "onchain"
if any(k in text for k in ("舆情", "新闻", "消息", "情绪", "热点", "叙事", "ai 舆情")):
return "sentiment"
if any(k in text for k in ("复盘", "历史", "亏损", "失败", "胜率", "漏选")):
@@ -403,17 +399,13 @@ def _market_context():
market = get_crypto_market_overview()
except Exception as exc:
market = {"error": str(exc)[:300]}
- try:
- onchain = get_onchain_overview(hours=24)
- except Exception:
- onchain = {}
try:
from app.services.llm_insights import get_latest_sentiment_batch_analysis
sentiment = get_latest_sentiment_batch_analysis() or {}
except Exception:
sentiment = {}
- return {"market": market, "onchain": onchain, "ai_sentiment": sentiment}
+ return {"market": market, "ai_sentiment": sentiment}
def build_context(intent: str, message: str, symbol: str, preferences=None) -> dict:
@@ -430,20 +422,16 @@ def build_context(intent: str, message: str, symbol: str, preferences=None) -> d
return ctx
if intent == "restricted":
return ctx
- if intent in ("coin_analysis", "recommendation_explain", "onchain", "sentiment") and symbol:
+ if intent in ("coin_analysis", "recommendation_explain", "sentiment") and symbol:
ctx["technicals"] = analyze_symbol_technicals(symbol)
ctx["recommendations"] = _latest_recommendations(symbol=symbol, limit=5)
ctx["sentiment_events"] = _sentiment_context(symbol=symbol, limit=8)
- try:
- ctx["onchain"] = get_onchain_token_detail(symbol=symbol, hours=168)
- except Exception as exc:
- ctx["onchain"] = {"error": str(exc)[:200]}
ctx["reviews"] = _review_context(symbol=symbol, limit=8)
- ctx["sources"] = ["binance_klines", "recommendation", "event_news", "onchain", "review_log"]
+ ctx["sources"] = ["binance_klines", "recommendation", "event_news", "review_log"]
elif intent == "market_overview":
ctx.update(_market_context())
ctx["pipeline"] = get_pipeline_runs(limit=5, hours=24, offset=0)
- ctx["sources"] = ["market_overview", "onchain_overview", "llm_sentiment", "pipeline_runs"]
+ ctx["sources"] = ["market_overview", "llm_sentiment", "pipeline_runs"]
elif intent == "review":
ctx["reviews"] = _review_context(symbol=symbol, limit=12)
ctx["recommendations"] = _latest_recommendations(symbol=symbol, limit=8) if symbol else _latest_recommendations(limit=8)
@@ -457,20 +445,11 @@ def build_context(intent: str, message: str, symbol: str, preferences=None) -> d
except Exception:
ctx["ai_sentiment"] = {}
ctx["sources"] = ["event_news", "llm_sentiment"]
- elif intent == "onchain":
- if symbol:
- try:
- ctx["onchain"] = get_onchain_token_detail(symbol=symbol, hours=168)
- except Exception as exc:
- ctx["onchain"] = {"error": str(exc)[:200]}
- else:
- ctx["onchain"] = get_onchain_overview(hours=24)
- ctx["sources"] = ["onchain"]
else:
ctx["capabilities"] = [
"单币技术面:自动拉取 Binance 15m/1h/4h/1d K 线",
"推荐解释:结合当前看板状态、入场窗口、TP/SL、风险理由",
- "舆情和链上:读取当前系统已采集与 AI 解读的结果",
+ "舆情:读取当前系统已采集与 AI 解读的结果",
"复盘:区分信号表现、失效样本和真实推荐结果",
]
return ctx
@@ -480,7 +459,7 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict:
if intent == "restricted":
return {
"summary": "内部策略交易数据不可在智能问答中直接访问。",
- "answer": "我不能读取或解释内部策略交易数据。你可以继续问公开行情、单币技术面、推荐解释、链上异动、舆情影响或复盘结果(不含交易账本明细)。",
+ "answer": "我不能读取或解释内部策略交易数据。你可以继续问公开行情、单币技术面、推荐解释、舆情影响或复盘结果(不含交易账本明细)。",
"evidence": [],
"related_records": [],
"followups": ["分析 BTC/USDT 的技术面", "解释最新推荐为什么不是可买"],
@@ -488,7 +467,7 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict:
if intent == "unsupported":
return {
"summary": "我只能回答加密货币和 AlphaX 当前数据相关的问题。",
- "answer": "这个问题超出了 Crypto 研究助手的范围。你可以问某个币的技术面、看板推荐原因、链上异动、舆情影响或复盘表现。",
+ "answer": "这个问题超出了 Crypto 研究助手的范围。你可以问某个币的技术面、看板推荐原因、舆情影响或复盘表现。",
"evidence": [],
"related_records": [],
"followups": ["分析 BTC/USDT 的技术面", "今天市场适合追强势币吗?"],
@@ -498,7 +477,6 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict:
tech_summary = tech.get("summary") or {}
recommendations = context.get("recommendations") or []
sentiment = context.get("sentiment_events") or []
- onchain = context.get("onchain") or {}
evidence = []
if tech_summary:
evidence.append(f"技术面:{tech_summary.get('headline', '')}")
@@ -507,8 +485,6 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict:
evidence.append(f"策略状态:{r.get('execution_label') or r.get('action_status') or r.get('execution_status')},原因:{r.get('execution_reason') or r.get('state_reason') or '--'}")
if sentiment:
evidence.append(f"舆情:近 72h 有 {len(sentiment)} 条相关事件,最新为「{sentiment[0].get('title', '')[:60]}」。")
- if isinstance(onchain, dict) and (onchain.get("events") or onchain.get("metrics")):
- evidence.append(f"链上:近 7 天有 {len(onchain.get('events') or [])} 条映射事件。")
if not evidence:
evidence.append("当前数据库没有足够样本,结论需要降级为观察。")
@@ -519,7 +495,7 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict:
answer = state.get("summary") or "我读取了全市场行情,但当前没有足够信息形成强结论。"
elif symbol:
summary = tech_summary.get("headline") or f"{symbol} 需要继续观察"
- answer = "结论:" + summary + " 证据区已汇总技术面、推荐、舆情、链上和复盘上下文。"
+ answer = "结论:" + summary + " 证据区已汇总技术面、推荐、舆情和复盘上下文。"
else:
summary = "已读取当前系统数据"
answer = "我已经按你的问题读取了当前数据库,但没有识别到明确币种;可以继续追问具体币种。"
@@ -577,7 +553,7 @@ def _followups(intent: str, symbol: str = "") -> list[str]:
base = symbol.replace("/USDT", "")
return [
f"{base} 现在追高风险大吗?",
- f"{base} 的链上和舆情有没有共振?",
+ f"{base} 的舆情和技术面有没有共振?",
f"{base} 如果要等回踩,关键价位在哪里?",
]
if intent == "market_overview":
@@ -591,7 +567,6 @@ def _answer_style_for_intent(intent: str) -> str:
"recommendation_explain": "decision",
"market_overview": "market",
"sentiment": "news",
- "onchain": "onchain",
"review": "review",
"restricted": "notice",
"unsupported": "notice",
@@ -614,10 +589,10 @@ def _call_chat_llm(message: str, context: dict, history=None) -> dict:
"context": context,
"recent_history": (history or [])[-8:],
"rules": [
- "只回答加密货币、AlphaX 当前数据、技术面、链上、舆情、复盘相关问题,不要访问内部策略交易数据。",
+ "只回答加密货币、AlphaX 当前数据、技术面、舆情、复盘相关问题,不要访问内部策略交易数据。",
"不要给真实下单指令,不要修改推荐状态,不要承诺收益。",
"回答使用中文,采用两段式:先结论,再证据。",
- "根据 intent 选择 answer_style:technical/decision/market/news/onchain/review/notice/help/default。",
+ "根据 intent 选择 answer_style:technical/decision/market/news/review/notice/help/default。",
"输出严格 JSON:summary, answer, answer_style, evidence[], risk_flags[], related_records[], followups[]。",
],
}
diff --git a/app/services/nodereal_client.py b/app/services/nodereal_client.py
deleted file mode 100644
index 960de9c..0000000
--- a/app/services/nodereal_client.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""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 eth_call(self, chain: str, to_address: str, data: str, block: str = "latest") -> str:
- result = self.call(chain, "eth_call", [{"to": to_address, "data": data}, block])
- return str(result or "")
-
- 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
diff --git a/app/services/onchain_monitor.py b/app/services/onchain_monitor.py
deleted file mode 100644
index 53359a7..0000000
--- a/app/services/onchain_monitor.py
+++ /dev/null
@@ -1,1208 +0,0 @@
-"""On-chain signal collector and candidate bridge.
-
-V1 deliberately treats on-chain data as a discovery/risk layer. It writes
-normalized events/metrics and may request a technical check through event_news,
-but it never creates recommendations or changes recommendation state directly.
-"""
-
-import json
-import os
-from datetime import datetime, timedelta
-
-from app.config.system_config import onchain_config
-from app.db import onchain_db
-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 (
- MIN_MAPPING_CONFIDENCE,
- POSITIVE_SIGNALS,
- RISK_SIGNALS,
- find_mapping_by_contract,
- get_token_mappings,
- init_onchain_tables,
- insert_onchain_event,
- insert_onchain_raw_event,
- insert_token_metric,
- normalize_symbol,
- signal_direction,
- signal_label,
-)
-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.alchemy_client import AlchemyClient, AlchemyConfig, DEFAULT_ALCHEMY_CHAIN_ENDPOINTS
-from app.services.nodereal_client import DEFAULT_CHAIN_ENDPOINTS, NodeRealClient, NodeRealConfig
-
-
-DEFAULT_CHAINS = ("ethereum", "bsc")
-NON_TARGET_NATIVE_BASES = {
- "AVAX", "FIL", "SUI", "APT", "DOT", "ADA", "XRP", "LTC", "BCH", "ATOM", "NEAR",
- "SEI", "INJ", "TON", "ETC", "ICP", "HBAR", "ALGO", "VET", "TRX", "XLM", "KAS",
- "TIA", "EGLD", "FLOW", "KAVA", "MINA", "IOTA", "XMR", "DASH", "ZEC",
-}
-BRIDGED_TOKEN_MARKERS = (
- "wrapped", "wormhole", "portal", "bridged", "bridge", "axelar", "allbridge",
- "binance-peg", "multichain", "layerzero", "lz", "wavax", "wfil",
-)
-TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
-ERC20_SYMBOL_SELECTOR = "0x95d89b41"
-ERC20_NAME_SELECTOR = "0x06fdde03"
-ERC20_DECIMALS_SELECTOR = "0x313ce567"
-
-
-def _provider_error_summary(provider: str, chain: str = "", scope: str = "", symbol: str = "", exc: Exception | str = "") -> str:
- provider_label = {"alchemy": "Alchemy", "nodereal": "NodeReal"}.get(str(provider or "").lower(), provider or "链上数据源")
- chain_label = {"ethereum": "Ethereum", "bsc": "BSC"}.get(str(chain or "").lower(), chain or "")
- scope_label = {
- "logs": "映射代币日志",
- "raw_logs": "原始转账流",
- "metadata": "Token 资料",
- }.get(str(scope or ""), scope or "链上数据")
- text = str(exc or "")
- reason = "采集失败"
- lowered = text.lower()
- if "name resolution" in lowered or "nameresolution" in lowered or "temporary failure in name resolution" in lowered:
- reason = "DNS 解析异常"
- elif "ssl" in lowered or "connectionpool" in lowered or "max retries" in lowered or "eof" in lowered:
- reason = "连接异常"
- elif "timeout" in lowered or "timed out" in lowered:
- reason = "请求超时"
- elif "rate" in lowered or "429" in lowered:
- reason = "额度或限流"
- elif "403" in lowered or "401" in lowered or "api_key" in lowered:
- reason = "鉴权异常"
- prefix = f"{symbol}:" if symbol else ""
- chain_part = f"{chain_label} " if chain_label else ""
- return f"{prefix}{provider_label} {chain_part}{scope_label} {reason}"
-
-# ---------------------------------------------------------------------------
-# Known CEX hot/deposit wallet addresses (lowercase).
-# Sources: Etherscan/BscScan labeled addresses, Arkham, Nansen public tags.
-# Used to classify transfer direction: inflow (to CEX) vs outflow (from CEX).
-# ---------------------------------------------------------------------------
-_CEX_ADDRESSES: set[str] = {
- # Binance
- "0x28c6c06298d514db089934071355e5743bf21d60",
- "0x21a31ee1afc51d94c2efccaa2092ad1028285549",
- "0xdfd5293d8e347dfe59e90efd55b2956a1343963d",
- "0x56eddb7aa87536c09ccc2793473599fd21a8b17f",
- "0x9696f59e4d72e237be84ffd425dcad154bf96976",
- "0xf977814e90da44bfa03b6295a0616a897441acec",
- "0x8894e0a0c962cb723c1ef8a1b67f07aa277d42ad",
- "0xe2fc31f816a9b94326492132018c3aecc4a93ae1",
- "0x3c783c21a0383057d128bae431894a5c19f9cf06",
- "0xb38e8c17e38363af6ebdcb3dae12e0243582891d",
- "0x5a52e96bacdabb82fd05763e25335261b270efcb",
- "0x835678a611b28684005a5e2233695fb6cbbb00a4",
- # OKX
- "0x6cc5f688a315f3dc28a7781717a9a798a59fda7b",
- "0x236f9f97e0e62388479bf9e5ba4889e46b0273c3",
- "0xa7efae728d2936e78bda97dc267687568dd593f3",
- "0x98ec059dc3adfbdd63429454aeb0c990fba4a128",
- "0x6fb624b48d9299674022a23d92515e76ba880113",
- # Bybit
- "0xf89d7b9c864f589bbf53a82105107622b35eaa40",
- "0x1db92e2eebc8e0c075a02bea49a2935bcd2dfcf4",
- # Coinbase
- "0x71660c4005ba85c37ccec55d0c4493e66fe775d3",
- "0x503828976d22510aad0201ac7ec88293211d23da",
- "0xddfabcdc4d8ffc6d5beaf154f18b778f892a0740",
- "0x3cd751e6b0078be393132286c442345e68ff0aaa",
- "0xb5d85cbf7cb3ee0d56b3bb207d5fc4b82f43f511",
- "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43",
- # Kraken
- "0x2910543af39aba0cd09dbb2d50200b3e800a63d2",
- "0x267be1c1d684f78cb4f6a176c4911b741e4ffdc0",
- # KuCoin
- "0xd6216fc19db775df9774a6e33526131da7d19a2c",
- "0xf16e9b0d03470827a95cdfd0cb8a8a3b46969b91",
- "0x738cf6903e6c4e699d1c2dd9ab8b67fcdb3121ea",
- # Gate.io
- "0x0d0707963952f2fba59dd06f2b425ace40b492fe",
- "0x1c4b70a3968436b9a0a9cf5205c787eb81bb558c",
- # Huobi / HTX
- "0xab5c66752a9e8167967685f1450532fb96d5d24f",
- "0x6748f50f686bfbca6fe8ad62b22228b87f31ff2b",
- "0xfdb16996831753d5331ff813c29a93c76834a0ad",
- "0x46340b20830761efd32832a74d7169b29feb9758",
- # Bitfinex
- "0x876eabf441b2ee5b5b0554fd502a8e0600950cfa",
- "0x742d35cc6634c0532925a3b844bc9e7595f2bd3e",
- # Crypto.com
- "0x6262998ced04146fa42253a5c0af90ca02dfd2a3",
- "0x46340b20830761efd32832a74d7169b29feb9758",
- # MEXC
- "0x3cc936b795a188f0e246cbb2d74c5bd190aecf18",
- # Upbit
- "0x5e032243d507c743b061ef27c9169ae92ed40ec0",
-}
-
-
-def is_cex_address(address: str) -> bool:
- """Check if an address belongs to a known centralized exchange."""
- return str(address or "").lower().strip() in _CEX_ADDRESSES
-
-
-def classify_transfer_signal(from_addr: str, to_addr: str) -> tuple[str, str]:
- """Classify a transfer's signal code and direction based on CEX address labels.
-
- Returns (signal_code, direction):
- - to CEX → ("exchange_inflow_risk", "risk") — likely selling
- - from CEX → ("exchange_outflow", "positive") — likely accumulating
- - neither → ("whale_accumulation", "positive") — large wallet-to-wallet move
- """
- to_is_cex = is_cex_address(to_addr)
- from_is_cex = is_cex_address(from_addr)
- if to_is_cex and not from_is_cex:
- return "exchange_inflow_risk", "risk"
- if from_is_cex and not to_is_cex:
- return "exchange_outflow", "positive"
- # Both CEX (internal transfer) or neither (wallet-to-wallet whale move)
- return "whale_accumulation", "positive"
-
-
-def _env_bool(name, default=False):
- value = os.getenv(name)
- if value is None:
- return default
- return str(value).strip().lower() in ("1", "true", "yes", "on")
-
-
-def _env_int(name, default):
- try:
- return int(os.getenv(name, str(default)) or default)
- except Exception:
- return default
-
-
-def _env_float(name, default):
- try:
- return float(os.getenv(name, str(default)) or default)
- except Exception:
- return default
-
-
-def get_onchain_params():
- """Runtime provider config. Keep this out of rules.yaml."""
- cfg = onchain_config(DEFAULT_CHAINS)
- chains_raw = cfg.get("chains") or list(DEFAULT_CHAINS)
- if isinstance(chains_raw, str):
- chains = [x.strip().lower() for x in chains_raw.split(",") if x.strip()]
- else:
- chains = [str(x).strip().lower() for x in chains_raw if str(x).strip()]
- nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
- alchemy_env = str(cfg.get("alchemy_api_key_env") or "ALPHAX_ALCHEMY_API_KEY")
- token_mappings_env = str(cfg.get("token_mappings_env") or "ALPHAX_ONCHAIN_TOKEN_MAPPINGS")
- return {
- "enabled": bool(cfg.get("enabled", False)),
- "provider": str(cfg.get("provider") or "nodereal").strip().lower(),
- "chains": chains or list(DEFAULT_CHAINS),
- "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,
- "alchemy_enabled": bool(cfg.get("alchemy_enabled", False)),
- "alchemy_chains": _normalize_chain_list(cfg.get("alchemy_chains") or ("ethereum", "bsc")),
- "alchemy_api_key": os.getenv(alchemy_env, "").strip(),
- "alchemy_api_key_env": alchemy_env,
- "token_mappings": _load_token_mappings(cfg.get("token_mappings"), os.getenv(token_mappings_env, "")),
- "token_mappings_env": token_mappings_env,
- "nodereal_log_block_lookback": int(cfg.get("nodereal_log_block_lookback") or 120),
- "nodereal_max_logs_per_token": int(cfg.get("nodereal_max_logs_per_token") or 25),
- "nodereal_raw_transfer_enabled": bool(cfg.get("nodereal_raw_transfer_enabled", True)),
- "nodereal_raw_block_lookback": int(cfg.get("nodereal_raw_block_lookback") or 1),
- "nodereal_raw_max_logs_per_chain": int(cfg.get("nodereal_raw_max_logs_per_chain") or 30),
- "nodereal_auto_mapping_enabled": bool(cfg.get("nodereal_auto_mapping_enabled", True)),
- "nodereal_auto_mapping_confidence": int(cfg.get("nodereal_auto_mapping_confidence") or 82),
- "alchemy_log_block_lookback": int(cfg.get("alchemy_log_block_lookback") or 120),
- "alchemy_max_logs_per_token": int(cfg.get("alchemy_max_logs_per_token") or 25),
- "alchemy_raw_transfer_enabled": bool(cfg.get("alchemy_raw_transfer_enabled", True)),
- "alchemy_raw_chains": _normalize_chain_list(cfg.get("alchemy_raw_chains") or ("ethereum",)),
- "alchemy_raw_block_lookback": int(cfg.get("alchemy_raw_block_lookback") or 1),
- "alchemy_raw_max_logs_per_chain": int(cfg.get("alchemy_raw_max_logs_per_chain") or 30),
- "alchemy_auto_mapping_enabled": bool(cfg.get("alchemy_auto_mapping_enabled", True)),
- "alchemy_auto_mapping_confidence": int(cfg.get("alchemy_auto_mapping_confidence") or 82),
- "candidate_enabled": bool(cfg.get("candidate_enabled", True)),
- "candidate_min_score": float(cfg.get("candidate_min_score") or 70),
- "candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
- "candidate_cooldown_hours": float(cfg.get("candidate_cooldown_hours") or 6),
- "whale_tx_usd": float(cfg.get("whale_tx_usd") or 250000),
- }
-
-
-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 _load_token_mappings(config_value=None, env_value=""):
- items = []
- if isinstance(config_value, list):
- items.extend(config_value)
- if env_value:
- try:
- parsed = json.loads(env_value)
- if isinstance(parsed, list):
- items.extend(parsed)
- except Exception:
- for part in str(env_value or "").split(","):
- bits = [x.strip() for x in part.split(":")]
- if len(bits) >= 3:
- items.append({"symbol": bits[0], "chain": bits[1], "contract_address": bits[2]})
- normalized = []
- seen = set()
- for item in items:
- if not isinstance(item, dict):
- continue
- symbol = normalize_symbol(item.get("symbol"))
- chain = str(item.get("chain") or "").lower().strip()
- contract = str(item.get("contract_address") or item.get("address") or "").strip()
- if not symbol or not chain or not contract:
- continue
- key = (symbol, chain, contract.lower())
- if key in seen:
- continue
- seen.add(key)
- normalized.append({
- "symbol": symbol,
- "chain": chain,
- "contract_address": contract,
- "source": item.get("source") or "nodereal_seed",
- "confidence": int(item.get("confidence") or 95),
- "raw": item.get("raw") or {},
- })
- return normalized
-
-
-def seed_configured_token_mappings(cfg=None):
- cfg = cfg or get_onchain_params()
- seeded = []
- errors = []
- for item in cfg.get("token_mappings") or []:
- try:
- mapping_id = onchain_db.upsert_token_mapping(
- item["symbol"],
- item["chain"],
- item["contract_address"],
- source=item.get("source") or "nodereal_seed",
- confidence=item.get("confidence") or 95,
- raw=item.get("raw") or {},
- is_active=True,
- )
- if mapping_id:
- seeded.append(item)
- except Exception as exc:
- errors.append(f"{item.get('symbol')}:seed_mapping:{str(exc)[:160]}")
- return {"seeded": len(seeded), "items": seeded, "errors": errors}
-
-
-def _now():
- return datetime.now()
-
-
-def _safe_float(value, default=0.0):
- try:
- return float(value or 0)
- except Exception:
- return default
-
-
-def _safe_int(value, default=0):
- try:
- return int(float(value or 0))
- except Exception:
- return default
-
-
-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):
- conn = get_conn()
- row = conn.execute(
- """
- SELECT * FROM onchain_token_metrics
- WHERE symbol=%s AND chain=%s AND contract_address=%s AND "window"='1h'
- ORDER BY metric_time DESC, id DESC LIMIT 1
- """,
- (symbol, chain, contract_address or ""),
- ).fetchone()
- conn.close()
- return dict(row) if row else None
-
-
-def _event_from_metric(metric, signal_code, source="nodereal"):
- direction = signal_direction(signal_code)
- severity = "RISK" if direction == "risk" else "A" if _safe_float(metric.get("onchain_score")) >= 75 else "B"
- return {
- "chain": metric.get("chain"),
- "symbol": metric.get("symbol"),
- "contract_address": metric.get("contract_address") or "",
- "event_type": "onchain_signal",
- "signal_code": signal_code,
- "signal_label": signal_label(signal_code),
- "direction": direction,
- "value_usd": metric.get("dex_volume_usd") or metric.get("whale_accumulation_usd") or abs(metric.get("exchange_netflow_usd") or 0),
- "confidence": 75 if direction != "risk" else 80,
- "severity": severity,
- "detected_at": metric.get("metric_time") or _now().isoformat(),
- "source": source,
- "url": metric.get("url") or "",
- "raw": metric,
- }
-
-
-def _latest_price_from_metric(mapping):
- symbol = normalize_symbol(mapping.get("symbol"))
- chain = str(mapping.get("chain") or "").lower()
- contract = str(mapping.get("contract_address") or "")
- conn = get_conn()
- try:
- 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:
- return 0
-
-
-def _topic_to_address(topic):
- topic = str(topic or "").lower()
- if topic.startswith("0x") and len(topic) >= 42:
- return "0x" + topic[-40:]
- return ""
-
-
-def _decode_abi_string(value):
- text = str(value or "").strip()
- if not text or text == "0x":
- return ""
- payload = text[2:] if text.startswith("0x") else text
- try:
- raw = bytes.fromhex(payload)
- except Exception:
- return ""
- if not raw:
- return ""
- try:
- if len(raw) >= 96:
- offset = int.from_bytes(raw[:32], "big")
- if 0 <= offset + 32 <= len(raw):
- length = int.from_bytes(raw[offset:offset + 32], "big")
- body = raw[offset + 32:offset + 32 + length]
- return body.decode("utf-8", errors="ignore").strip("\x00 ").strip()
- return raw.rstrip(b"\x00").decode("utf-8", errors="ignore").strip()
- except Exception:
- return ""
-
-
-def _clean_erc20_symbol(value):
- symbol = str(value or "").upper().strip().replace("$", "")
- symbol = "".join(ch for ch in symbol if ch.isalnum())
- if symbol.endswith("USDT") and len(symbol) > 4:
- symbol = symbol[:-4]
- return symbol[:20]
-
-
-def _is_auto_mapping_symbol_allowed(base, token_name=""):
- base = _clean_erc20_symbol(base)
- if not base:
- return False
- symbol = f"{base}/USDT"
- if not _tradable_symbol(symbol):
- return False
- if base in NON_TARGET_NATIVE_BASES:
- return False
- text = f"{base} {token_name or ''}".lower()
- if any(marker in text for marker in BRIDGED_TOKEN_MARKERS):
- return False
- return True
-
-
-def _read_erc20_metadata(client, chain, contract):
- metadata = {"symbol": "", "name": "", "decimals": 18}
- try:
- metadata["symbol"] = _decode_abi_string(client.eth_call(chain, contract, ERC20_SYMBOL_SELECTOR))
- except Exception:
- metadata["symbol"] = ""
- try:
- metadata["name"] = _decode_abi_string(client.eth_call(chain, contract, ERC20_NAME_SELECTOR))
- except Exception:
- metadata["name"] = ""
- try:
- decimals = _hex_to_int(client.eth_call(chain, contract, ERC20_DECIMALS_SELECTOR))
- if 0 <= decimals <= 36:
- metadata["decimals"] = decimals
- except Exception:
- pass
- metadata["symbol"] = _clean_erc20_symbol(metadata.get("symbol"))
- return metadata
-
-
-def _auto_map_evm_contract(client, chain, contract, cfg=None, provider="nodereal"):
- cfg = cfg or get_onchain_params()
- provider = str(provider or "nodereal").lower()
- if not cfg.get(f"{provider}_auto_mapping_enabled", True):
- return None
- existing = find_mapping_by_contract(chain, contract)
- if existing:
- return existing
- metadata = _read_erc20_metadata(client, chain, contract)
- base = metadata.get("symbol") or ""
- if not _is_auto_mapping_symbol_allowed(base, metadata.get("name")):
- return None
- symbol = normalize_symbol(base)
- confidence = max(1, min(95, int(cfg.get(f"{provider}_auto_mapping_confidence") or 82)))
- source = f"{provider}_erc20_metadata"
- mapping_id = onchain_db.upsert_token_mapping(
- symbol=symbol,
- chain=chain,
- contract_address=contract,
- source=source,
- confidence=confidence,
- raw=metadata,
- is_active=True,
- )
- if not mapping_id:
- return None
- return {
- "id": mapping_id,
- "symbol": symbol,
- "chain": str(chain or "").lower(),
- "contract_address": contract,
- "source": source,
- "confidence": confidence,
- "raw_json": json.dumps(metadata, ensure_ascii=False),
- }
-
-
-def _auto_map_nodereal_contract(client, chain, contract, cfg=None):
- return _auto_map_evm_contract(client, chain, contract, cfg=cfg, provider="nodereal")
-
-
-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 _alchemy_client(cfg=None):
- cfg = cfg or get_onchain_params()
- return AlchemyClient(
- AlchemyConfig(
- api_key=cfg.get("alchemy_api_key") or "",
- timeout=int(cfg.get("timeout") or 15),
- endpoints=dict(DEFAULT_ALCHEMY_CHAIN_ENDPOINTS),
- )
- )
-
-
-def _event_from_evm_transfer(log, mapping, cfg=None, source="nodereal"):
- cfg = cfg or get_onchain_params()
- source = str(source or "nodereal").lower()
- 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()
- from_addr = _topic_to_address(topics[1])
- to_addr = _topic_to_address(topics[2])
- sig_code, direction = classify_transfer_signal(from_addr, to_addr)
- severity = "RISK" if direction == "risk" else "A"
- confidence = 80 if direction == "risk" else 76
- # Descriptive labels based on classification
- if sig_code == "exchange_inflow_risk":
- wallet_label = "CEX 充值地址"
- counterparty_label = "发送方 " + _short_addr(from_addr)
- elif sig_code == "exchange_outflow":
- wallet_label = "接收钱包 " + _short_addr(to_addr)
- counterparty_label = "CEX 提币地址"
- else:
- wallet_label = "EVM 接收地址"
- counterparty_label = "EVM 发送地址 " + _short_addr(from_addr)
- return {
- "chain": chain,
- "symbol": mapping.get("symbol"),
- "contract_address": mapping.get("contract_address") or "",
- "event_type": "token_transfer",
- "signal_code": sig_code,
- "signal_label": signal_label(sig_code),
- "direction": direction,
- "value_usd": value_usd,
- "amount": amount,
- "tx_hash": tx_hash,
- "wallet_address": to_addr,
- "wallet_label": wallet_label,
- "counterparty_label": counterparty_label,
- "confidence": confidence,
- "severity": severity,
- "detected_at": _now().isoformat(timespec="seconds"),
- "source": source,
- "url": _chain_explorer_tx_url(chain, tx_hash),
- "raw": log,
- }
-
-
-def _event_from_nodereal_transfer(log, mapping, cfg=None):
- return _event_from_evm_transfer(log, mapping, cfg=cfg, source="nodereal")
-
-
-def _raw_event_from_evm_transfer(log, chain, source="nodereal"):
- source = str(source or "nodereal").lower()
- topics = log.get("topics") or []
- if len(topics) < 3:
- return None
- contract = str(log.get("address") or "").strip()
- tx_hash = str(log.get("transactionHash") or "").strip()
- amount_raw = _hex_to_int(log.get("data"))
- if not contract or amount_raw <= 0:
- return None
- from_addr = _topic_to_address(topics[1])
- to_addr = _topic_to_address(topics[2])
- source_label = "Alchemy" if source == "alchemy" else "NodeReal"
- return {
- "source": source,
- "chain": str(chain or "").lower(),
- "event_type": "evm_transfer",
- "token_address": contract,
- "symbol_guess": "",
- "name": "",
- "title": f"{source_label} ERC-20 原始转账",
- "description": f"合约 {_short_addr(contract)} · {_short_addr(from_addr)} -> {_short_addr(to_addr)}",
- "url": _chain_explorer_tx_url(chain, tx_hash),
- "amount": amount_raw,
- "total_amount": 0,
- "importance": min(100, max(1, len(str(amount_raw)) * 4)),
- "mapped_symbol": "",
- "mapping_status": "unmapped",
- "detected_at": _now().isoformat(timespec="seconds"),
- "raw": log,
- }
-
-
-def _raw_event_from_nodereal_transfer(log, chain):
- return _raw_event_from_evm_transfer(log, chain, source="nodereal")
-
-
-def _apply_raw_event_mapping(raw_event, client=None, cfg=None, provider=None):
- item = dict(raw_event or {})
- chain = str(item.get("chain") or "").lower()
- contract = str(item.get("token_address") or "").strip()
- if not chain or not contract:
- return item
- mapping = find_mapping_by_contract(chain, contract)
- if not mapping and client:
- mapping = _auto_map_evm_contract(client, chain, contract, cfg=cfg, provider=provider or item.get("source") or "nodereal")
- if mapping:
- item["mapped_symbol"] = normalize_symbol(mapping.get("symbol"))
- item["mapping_status"] = "mapped"
- item["symbol_guess"] = item.get("symbol_guess") or item["mapped_symbol"].split("/")[0]
- item["raw"] = {
- **(item.get("raw") or {}),
- "mapping": {
- "symbol": item["mapped_symbol"],
- "source": mapping.get("source") or "",
- "confidence": mapping.get("confidence") or 0,
- },
- }
- return item
-
-
-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"]}
- seed_result = seed_configured_token_mappings(cfg)
- client = _nodereal_client(cfg)
- raw_result = fetch_nodereal_raw_events(client=client, cfg=cfg, limit=limit)
- enabled_chains = set(cfg.get("nodereal_chains") or DEFAULT_CHAINS)
- all_mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
- chain_mappings = [m for m in all_mappings if str(m.get("chain") or "").lower() in enabled_chains]
- mappings = []
- unsupported_chains = set()
- for mapping in chain_mappings:
- chain = str(mapping.get("chain") or "").lower()
- if client.supports_chain(chain):
- mappings.append(mapping)
- else:
- unsupported_chains.add(chain)
- metrics = []
- events = []
- errors = list(seed_result.get("errors") or []) + list(raw_result.get("errors") or [])
- diagnostics = {
- "seeded_mappings": seed_result.get("seeded", 0),
- "mapping_total": len(all_mappings),
- "chain_mapping_total": len(chain_mappings),
- "supported_mapping_total": len(mappings),
- "enabled_chains": sorted(enabled_chains),
- "unsupported_chains": sorted(unsupported_chains),
- }
- 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 _is_evm_address(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(_provider_error_summary("nodereal", chain=chain, scope="metadata", symbol=mapping.get("symbol"), exc=exc))
- 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(_provider_error_summary("nodereal", chain=chain, scope="logs", symbol=mapping.get("symbol"), exc=exc))
- if not all_mappings:
- diagnostics["mapping_note"] = "no_strategy_mappings_raw_events_only"
- elif not chain_mappings:
- diagnostics["mapping_note"] = "no_enabled_chain_mappings_raw_events_only"
- elif not mappings:
- diagnostics["mapping_note"] = "no_supported_mappings_raw_events_only"
- return {
- "metrics": metrics,
- "events": events,
- "raw_events": raw_result.get("raw_events") or [],
- "errors": errors,
- "diagnostics": diagnostics,
- }
-
-
-def fetch_alchemy_events(limit=60):
- cfg = get_onchain_params()
- if not cfg.get("alchemy_enabled", False):
- return {"metrics": [], "events": [], "raw_events": [], "errors": ["alchemy_disabled"]}
- if not cfg.get("alchemy_api_key"):
- return {"metrics": [], "events": [], "raw_events": [], "errors": ["alchemy_api_key_missing"]}
- seed_result = seed_configured_token_mappings(cfg)
- client = _alchemy_client(cfg)
- raw_result = fetch_alchemy_raw_events(client=client, cfg=cfg, limit=limit)
- enabled_chains = set(cfg.get("alchemy_chains") or DEFAULT_CHAINS)
- all_mappings = get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
- chain_mappings = [m for m in all_mappings if str(m.get("chain") or "").lower() in enabled_chains]
- mappings = []
- unsupported_chains = set()
- for mapping in chain_mappings:
- chain = str(mapping.get("chain") or "").lower()
- if client.supports_chain(chain):
- mappings.append(mapping)
- else:
- unsupported_chains.add(chain)
- events = []
- errors = list(seed_result.get("errors") or []) + list(raw_result.get("errors") or [])
- diagnostics = {
- "seeded_mappings": seed_result.get("seeded", 0),
- "mapping_total": len(all_mappings),
- "chain_mapping_total": len(chain_mappings),
- "supported_mapping_total": len(mappings),
- "enabled_chains": sorted(enabled_chains),
- "unsupported_chains": sorted(unsupported_chains),
- }
- lookback = max(1, int(cfg.get("alchemy_log_block_lookback") or 120))
- max_logs = max(1, int(cfg.get("alchemy_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 _is_evm_address(contract):
- continue
- 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_evm_transfer(log, mapping, cfg=cfg, source="alchemy")
- if not event:
- continue
- if insert_onchain_event(event):
- events.append(event)
- except Exception as exc:
- errors.append(_provider_error_summary("alchemy", chain=chain, scope="logs", symbol=mapping.get("symbol"), exc=exc))
- if not all_mappings:
- diagnostics["mapping_note"] = "no_strategy_mappings_raw_events_only"
- elif not chain_mappings:
- diagnostics["mapping_note"] = "no_enabled_chain_mappings_raw_events_only"
- elif not mappings:
- diagnostics["mapping_note"] = "no_supported_mappings_raw_events_only"
- return {
- "metrics": [],
- "events": events,
- "raw_events": raw_result.get("raw_events") or [],
- "errors": errors,
- "diagnostics": diagnostics,
- }
-
-
-def fetch_nodereal_raw_events(client=None, cfg=None, limit=60):
- cfg = cfg or get_onchain_params()
- if not cfg.get("nodereal_raw_transfer_enabled", True):
- return {"raw_events": [], "errors": []}
- client = client or _nodereal_client(cfg)
- chains = [c for c in (cfg.get("nodereal_chains") or DEFAULT_CHAINS) if client.supports_chain(c)]
- lookback = max(0, min(12, int(cfg.get("nodereal_raw_block_lookback") or 1)))
- per_chain = max(1, min(int(cfg.get("nodereal_raw_max_logs_per_chain") or 30), int(limit or 60)))
- inserted = []
- errors = []
- for chain in chains:
- try:
- latest = client.block_number(chain)
- if latest <= 0:
- continue
- logs = client.get_logs(
- chain,
- {
- "fromBlock": hex(max(0, latest - lookback)),
- "toBlock": hex(latest),
- "topics": [TRANSFER_TOPIC],
- },
- )
- raw_items = []
- for log in logs:
- if not isinstance(log, dict):
- continue
- item = _raw_event_from_nodereal_transfer(log, chain)
- if item:
- raw_items.append(item)
- raw_items.sort(key=lambda item: item.get("amount") or 0, reverse=True)
- for item in raw_items[:per_chain]:
- item = _apply_raw_event_mapping(item, client=client, cfg=cfg, provider="nodereal")
- if insert_onchain_raw_event(item):
- inserted.append(item)
- except Exception as exc:
- errors.append(_provider_error_summary("nodereal", chain=chain, scope="raw_logs", exc=exc))
- return {"raw_events": inserted, "errors": errors}
-
-
-def fetch_alchemy_raw_events(client=None, cfg=None, limit=60):
- cfg = cfg or get_onchain_params()
- if not cfg.get("alchemy_raw_transfer_enabled", True):
- return {"raw_events": [], "errors": []}
- client = client or _alchemy_client(cfg)
- chains = [c for c in (cfg.get("alchemy_raw_chains") or ("ethereum",)) if client.supports_chain(c)]
- lookback = max(0, min(12, int(cfg.get("alchemy_raw_block_lookback") or 1)))
- per_chain = max(1, min(int(cfg.get("alchemy_raw_max_logs_per_chain") or 30), int(limit or 60)))
- inserted = []
- errors = []
- for chain in chains:
- try:
- latest = client.block_number(chain)
- if latest <= 0:
- continue
- logs = client.get_logs(
- chain,
- {
- "fromBlock": hex(max(0, latest - lookback)),
- "toBlock": hex(latest),
- "topics": [TRANSFER_TOPIC],
- },
- )
- raw_items = []
- for log in logs:
- if not isinstance(log, dict):
- continue
- item = _raw_event_from_evm_transfer(log, chain, source="alchemy")
- if item:
- raw_items.append(item)
- raw_items.sort(key=lambda item: item.get("amount") or 0, reverse=True)
- for item in raw_items[:per_chain]:
- item = _apply_raw_event_mapping(item, client=client, cfg=cfg, provider="alchemy")
- if insert_onchain_raw_event(item):
- inserted.append(item)
- except Exception as exc:
- errors.append(_provider_error_summary("alchemy", chain=chain, scope="raw_logs", exc=exc))
- return {"raw_events": inserted, "errors": errors}
-
-
-def _short_addr(value):
- value = str(value or "")
- if len(value) <= 12:
- return value
- return value[:6] + "..." + value[-4:]
-
-
-def _is_evm_address(value):
- text = str(value or "").strip()
- if len(text) != 42 or not text.startswith("0x"):
- return False
- try:
- int(text[2:], 16)
- return True
- except Exception:
- return False
-
-
-def ingest_normalized_events(events):
- """Test/integration helper for provider adapters."""
- init_db()
- init_onchain_tables()
- inserted = []
- for event in events or []:
- eid = insert_onchain_event(event)
- if eid:
- item = dict(event)
- item["id"] = eid
- inserted.append(item)
- queued = enqueue_onchain_candidates()
- return {"inserted": len(inserted), "queued": queued.get("queued", 0), "events": inserted, "candidate_result": queued}
-
-
-def _enabled_onchain_providers(cfg):
- raw = str(cfg.get("provider") or "nodereal").strip().lower()
- requested = [p.strip() for p in raw.split(",") if p.strip()]
- if any(p in {"all", "multi", "both"} for p in requested):
- requested = ["nodereal", "alchemy"]
- providers = []
- if "nodereal" in requested and cfg.get("nodereal_enabled", True):
- providers.append("nodereal")
- if "alchemy" in requested and cfg.get("alchemy_enabled", False):
- providers.append("alchemy")
- return providers or ["nodereal"]
-
-
-def _candidate_title(event):
- label = event.get("signal_label") or signal_label(event.get("signal_code"))
- value = _safe_float(event.get("value_usd"))
- value_txt = f" · ${value:,.0f}" if value > 0 else ""
- return f"链上异动 {event.get('symbol')}: {label}{value_txt}"
-
-
-def enqueue_onchain_candidates(min_score=None, min_confidence=None, cooldown_hours=None, limit=20):
- cfg = get_onchain_params()
- if not cfg.get("candidate_enabled", True):
- return {"queued": 0, "skipped": 0, "symbols": [], "reason": "candidate_disabled"}
- min_score = cfg.get("candidate_min_score", 70) if min_score is None else min_score
- min_confidence = cfg.get("candidate_min_confidence", 70) if min_confidence is None else min_confidence
- cooldown_hours = cfg.get("candidate_cooldown_hours", 6) if cooldown_hours is None else cooldown_hours
- init_onchain_tables()
- init_event_tables()
- cutoff = (_now() - timedelta(hours=24)).isoformat()
- conn = get_conn()
- queued = []
- skipped_ids = []
- errors = []
- try:
- rows = conn.execute(
- """
- SELECT e.*,
- COALESCE((
- SELECT m.onchain_score FROM onchain_token_metrics m
- WHERE m.symbol=e.symbol AND m.chain=e.chain
- ORDER BY m.metric_time::timestamp DESC, m.id DESC LIMIT 1
- ), 0) AS latest_onchain_score,
- COALESCE((
- SELECT m.risk_score FROM onchain_token_metrics m
- WHERE m.symbol=e.symbol AND m.chain=e.chain
- ORDER BY m.metric_time::timestamp DESC, m.id DESC LIMIT 1
- ), 0) AS latest_risk_score
- FROM onchain_events e
- WHERE e.status IN ('new', 'candidate_failed')
- AND e.detected_at >= %s
- AND e.direction='positive'
- ORDER BY e.confidence DESC, e.value_usd DESC, e.detected_at::timestamp DESC
- LIMIT %s
- """,
- (cutoff, int(limit or 20)),
- ).fetchall()
- now = _now().isoformat(timespec="seconds")
- cooldown_cutoff = (_now() - timedelta(hours=float(cooldown_hours or 6))).isoformat()
- for row in rows:
- event = dict(row)
- try:
- symbol = normalize_symbol(event.get("symbol"))
- if not symbol or not _tradable_symbol(symbol):
- skipped_ids.append(event["id"])
- continue
- score = max(_safe_float(event.get("latest_onchain_score")), _safe_float(event.get("confidence")))
- if score < float(min_score or 0) or int(event.get("confidence") or 0) < int(min_confidence or 0):
- continue
- recent = conn.execute(
- """
- SELECT id FROM event_news
- WHERE source='onchain' AND symbol=%s AND detected_at >= %s
- LIMIT 1
- """,
- (symbol, cooldown_cutoff),
- ).fetchone()
- if recent:
- skipped_ids.append(event["id"])
- continue
- title = _candidate_title(event)
- h = event_hash("onchain", title, symbol)
- inserted = conn.execute(
- """
- INSERT INTO event_news
- (event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
- VALUES (%s, 'onchain', %s, %s, %s, %s, %s, %s, 'onchain_candidate', %s, 0)
- ON CONFLICT(event_hash) DO NOTHING
- RETURNING id
- """,
- (
- h,
- symbol,
- title,
- event.get("url") or "",
- event.get("detected_at") or now,
- now,
- event.get("severity") or "A",
- json.dumps(
- {
- "onchain_event_id": event.get("id"),
- "chain": event.get("chain"),
- "signal_code": event.get("signal_code"),
- "signal_label": event.get("signal_label"),
- "confidence": event.get("confidence"),
- "value_usd": event.get("value_usd"),
- "onchain_score": event.get("latest_onchain_score"),
- "risk_score": event.get("latest_risk_score"),
- },
- ensure_ascii=False,
- ),
- ),
- ).fetchone()
- if inserted:
- conn.execute("UPDATE onchain_events SET status='candidate_queued' WHERE id=%s", (event.get("id"),))
- queued.append(symbol)
- else:
- skipped_ids.append(event["id"])
- conn.commit()
- except Exception as exc:
- conn.rollback()
- symbol = normalize_symbol(event.get("symbol"))
- errors.append(f"{symbol}:candidate_enqueue:{str(exc)[:160]}")
- skipped_ids.append(event["id"])
- if skipped_ids:
- conn.execute(
- "UPDATE onchain_events SET status='candidate_skipped' WHERE id IN (" + ",".join(["%s"] * len(skipped_ids)) + ")",
- tuple(skipped_ids),
- )
- conn.commit()
- return {"queued": len(queued), "skipped": len(skipped_ids), "symbols": queued, "errors": errors}
- except Exception:
- conn.rollback()
- raise
- finally:
- conn.close()
-
-
-def run_once(limit=60):
- started = _now()
- init_db()
- init_onchain_tables()
- cfg = get_onchain_params()
- output = {
- "status": "disabled" if not cfg.get("enabled") else "processed",
- "metrics_count": 0,
- "events_count": 0,
- "raw_events_count": 0,
- "candidate_queued": 0,
- "errors": [],
- "check_time": _now().isoformat(),
- }
- if cfg.get("enabled"):
- provider_results = {}
- for provider in _enabled_onchain_providers(cfg):
- if provider == "alchemy":
- node = fetch_alchemy_events(limit=limit)
- else:
- node = fetch_nodereal_events(limit=limit)
- provider_results[provider] = {
- "metrics_count": len(node.get("metrics") or []),
- "events_count": len(node.get("events") or []),
- "raw_events_count": len(node.get("raw_events") or []),
- "diagnostics": node.get("diagnostics") or {},
- }
- output["metrics_count"] += len(node.get("metrics") or [])
- output["events_count"] += len(node.get("events") or [])
- output["raw_events_count"] += len(node.get("raw_events") or [])
- output["errors"].extend(node.get("errors") or [])
- output["provider_results"] = provider_results
- output["discovered_mappings"] = 0
- if output.get("discovered_mappings"):
- output["status"] = "bootstrapped"
- output["metrics_count"] = 0
- output["events_count"] = 0
- output["raw_events_count"] = 0
- for provider in _enabled_onchain_providers(cfg):
- node = fetch_alchemy_events(limit=limit) if provider == "alchemy" else fetch_nodereal_events(limit=limit)
- output["metrics_count"] += len(node.get("metrics") or [])
- output["events_count"] += len(node.get("events") or [])
- output["raw_events_count"] += len(node.get("raw_events") or [])
- output["errors"].extend(node.get("errors") or [])
- queued = enqueue_onchain_candidates()
- output["candidate_queued"] = queued.get("queued", 0)
- output["candidate_symbols"] = queued.get("symbols", [])
- output["errors"].extend(queued.get("errors") or [])
- if not output["metrics_count"] and not output["events_count"] and not output["raw_events_count"]:
- output["status"] = "no_onchain_data"
- log_cron_run(
- job_name="链上",
- script_name="onchain_monitor.py",
- run_status="success" if not output["errors"] else "error",
- result_status=output["status"],
- started_at=started.isoformat(),
- finished_at=_now().isoformat(),
- duration_ms=int((_now() - started).total_seconds() * 1000),
- summary={
- "metrics_count": output["metrics_count"],
- "events_count": output["events_count"],
- "raw_events_count": output["raw_events_count"],
- "candidate_queued": output["candidate_queued"],
- "enabled": cfg.get("enabled"),
- },
- error_message="; ".join(output["errors"][:5]),
- )
- print(json.dumps(output, ensure_ascii=False, indent=2, default=str))
- return output
-
-
-__all__ = [
- "POSITIVE_SIGNALS",
- "RISK_SIGNALS",
- "enqueue_onchain_candidates",
- "fetch_alchemy_events",
- "fetch_nodereal_events",
- "get_onchain_params",
- "ingest_normalized_events",
- "run_once",
- "seed_configured_token_mappings",
-]
diff --git a/app/web/routes_market.py b/app/web/routes_market.py
index 0bce2a1..175d82c 100644
--- a/app/web/routes_market.py
+++ b/app/web/routes_market.py
@@ -1,7 +1,6 @@
from fastapi import APIRouter, Cookie
from fastapi.responses import JSONResponse
-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
@@ -18,7 +17,6 @@ async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(def
crypto_market = get_crypto_market_overview()
except Exception as exc:
market_error = str(exc)[:500]
- onchain = get_onchain_overview(hours=hours)
newsfeed = {}
ai_analysis = {}
try:
@@ -49,7 +47,6 @@ async def api_market_overview(hours: int = 24, altcoin_session: str = Cookie(def
"crypto_market": crypto_market,
"market_error": market_error,
"newsfeed": newsfeed,
- "onchain": onchain,
"ai_analysis": ai_analysis,
},
}
diff --git a/app/web/routes_onchain.py b/app/web/routes_onchain.py
deleted file mode 100644
index 5ae288c..0000000
--- a/app/web/routes_onchain.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from fastapi import APIRouter, Cookie
-
-from app.db.onchain_db import (
- get_onchain_overview,
- get_onchain_provider_status,
- get_onchain_token_detail,
- list_onchain_events,
- list_onchain_raw_events,
- list_onchain_tokens,
-)
-from app.web.shared import require_api_user_with_subscription
-
-
-router = APIRouter()
-
-
-@router.get("/api/onchain/overview")
-async def api_onchain_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
- require_api_user_with_subscription(altcoin_session)
- 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")
-async def api_onchain_tokens(
- limit: int = 30,
- offset: int = 0,
- chain: str = "",
- signal: str = "",
- hours: int = 24,
- altcoin_session: str = Cookie(default=""),
-):
- require_api_user_with_subscription(altcoin_session)
- return list_onchain_tokens(limit=limit, offset=offset, chain=chain, signal=signal, hours=hours)
-
-
-@router.get("/api/onchain/tokens/{symbol:path}")
-async def api_onchain_token_detail(symbol: str, hours: int = 72, altcoin_session: str = Cookie(default="")):
- require_api_user_with_subscription(altcoin_session)
- return get_onchain_token_detail(symbol=symbol, hours=hours)
-
-
-@router.get("/api/onchain/events")
-async def api_onchain_events(
- limit: int = 50,
- offset: int = 0,
- chain: str = "",
- signal: str = "",
- status: str = "",
- hours: int = 24,
- altcoin_session: str = Cookie(default=""),
-):
- require_api_user_with_subscription(altcoin_session)
- return list_onchain_events(limit=limit, offset=offset, chain=chain, signal=signal, status=status, hours=hours)
-
-
-@router.get("/api/onchain/raw-events")
-async def api_onchain_raw_events(
- limit: int = 50,
- offset: int = 0,
- chain: str = "",
- source: str = "",
- event_type: str = "",
- mapping_status: str = "",
- priority: str = "",
- hours: int = 24,
- altcoin_session: str = Cookie(default=""),
-):
- require_api_user_with_subscription(altcoin_session)
- return list_onchain_raw_events(
- limit=limit,
- offset=offset,
- chain=chain,
- source=source,
- event_type=event_type,
- mapping_status=mapping_status,
- priority=priority,
- hours=hours,
- )
diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py
index c1b45b2..9bb1862 100644
--- a/app/web/routes_pages.py
+++ b/app/web/routes_pages.py
@@ -242,13 +242,6 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
return redirect
return render_page("sentiment.html", request, active_nav="sentiment")
- @router.get("/onchain", response_class=HTMLResponse)
- async def onchain_page(request: Request):
- user, redirect = require_page_user(request)
- if redirect:
- return redirect
- return render_page("onchain.html", request, active_nav="onchain")
-
@router.get("/iteration", response_class=HTMLResponse)
async def iteration_page(request: Request):
user, redirect = require_page_user(request)
diff --git a/app/web/web_server.py b/app/web/web_server.py
index 80e87ba..5324849 100644
--- a/app/web/web_server.py
+++ b/app/web/web_server.py
@@ -21,7 +21,6 @@ from app.web.routes_chat import router as chat_router
from app.web.routes_content import build_router as build_content_router
from app.web.routes_market import router as market_router
from app.web.routes_live_trading import router as live_trading_router
-from app.web.routes_onchain import router as onchain_router
from app.web.routes_paper_trading import router as paper_trading_router
from app.web.routes_pages import build_router as build_pages_router
from app.web.routes_recommendations import router as recommendations_router
@@ -53,7 +52,6 @@ app.include_router(chat_router)
app.include_router(recommendations_router)
app.include_router(review_center_router)
app.include_router(strategy_router)
-app.include_router(onchain_router)
app.include_router(paper_trading_router)
app.include_router(live_trading_router)
app.include_router(market_router)
diff --git a/docs/MULTI_STRATEGY_ARCHITECTURE.md b/docs/MULTI_STRATEGY_ARCHITECTURE.md
index 2ddceb5..4f92fa8 100644
--- a/docs/MULTI_STRATEGY_ARCHITECTURE.md
+++ b/docs/MULTI_STRATEGY_ARCHITECTURE.md
@@ -35,7 +35,7 @@
- `prerequisite`:先决条件。决定能不能看,例如交易量、交易宇宙、市场环境、是否异常币。不能单独产生交易信号。
- `trigger`:触发条件。让策略产生候选,例如箱体突破回踩、1H 量价齐飞、短周期启动。
-- `confirmation`:确认条件。提高交易可信度,例如板块共振、链上正向、大户偏多、1H 未衰竭。
+- `confirmation`:确认条件。提高交易可信度,例如板块共振、舆情共振、大户偏多、1H 未衰竭。
- `entry`:入场条件。决定现在能不能买,例如 15m 承接、离箱体上沿距离、盈亏比。
- `risk`:风控条件。一票否决或降级,例如 risk_off、假突破、止损过宽、账户回撤过大。
- `attribution`:归因条件。用于复盘解释,不一定参与实时决策。
@@ -225,7 +225,7 @@
### 策略交易页
- 持仓、挂单、已完成、日志增加 `策略` 列。
-- 策略筛选器:全部 / 箱体突破回踩 / 量价加速 / 短周期观察 / 链上确认。
+- 策略筛选器:全部 / 箱体突破回踩 / 量价加速 / 短周期观察 / 舆情确认。
- 交易详情展示当时的 `strategy_snapshot`。
### 机会总览页
diff --git a/docs/OPTIMIZATION_TODO.md b/docs/OPTIMIZATION_TODO.md
index f53551d..9db8d72 100644
--- a/docs/OPTIMIZATION_TODO.md
+++ b/docs/OPTIMIZATION_TODO.md
@@ -23,13 +23,13 @@
- `Market Regime Engine` 第一版已建立,可识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。
- `Global Risk Engine` 第一版已接入 paper trading 开仓和挂单成交前,critical 禁止新开仓,high 只允许高质量机会。
- 确认层已把 `market_regime` 写入 `market_context` / `entry_plan`,paper trading 开仓事件也会记录当时市场环境和全局风控结果。
-- `FactorScorer` 已加入因子组去相关,限制同类动量/结构/链上/叙事信号重复叠加导致虚高分。
+- `FactorScorer` 已加入因子组去相关,限制同类动量/结构/叙事信号重复叠加导致虚高分。
- 确认层已输出 `opportunity_score`、`entry_score`、`risk_score` 三分制,并写入 `score_components`。
- 确认层已写入结构化 `decision_log`,用于解释确认/拒绝、分数、风险标记和核心证据。
- 实盘控制台已建立多账号配置、账户读取、订单/历史读取、Binance API 基础执行能力。
- 策略交易到 live trading 的自动同步链路已具备 demo 环境验证能力。
-- NodeReal 已作为当前链上主数据源,DEX Screener / Etherscan / Helius 运行链路已移除。
-- `FactorScorer` 已建立,确认层核心技术因子、板块、舆情、大户、链上因子已接入复盘权重。
+- 链上采集/API 模块已下线,当前策略聚焦 CEX 行情、事件舆情和交易所衍生品数据。
+- `FactorScorer` 已建立,确认层核心技术因子、板块、舆情、大户因子已接入复盘权重。
- `factor_score_breakdown` 已进入确认上下文,复盘可追踪因子贡献。
- `box_breakout_pullback_4h` 已作为 4H 箱体突破回踩强结构因子接入确认层,但它只是 `box_retest_4h_v1` 这类策略的核心触发候选,不应单独等同于完整策略。
- 已新增 `docs/MULTI_STRATEGY_ARCHITECTURE.md`,定义多策略并行、策略血缘、因子角色和独立复盘口径。
@@ -96,7 +96,7 @@
已完成:
-- 给因子增加大类:`momentum`、`participation`、`structure`、`positioning`、`narrative`、`onchain_flow`、`risk`、`entry_quality`。
+- 给因子增加大类:`momentum`、`participation`、`structure`、`positioning`、`narrative`、`risk`、`entry_quality`。
- 每个大类内部优先取最强信号,不简单全部累加。
- 每个大类设置分数上限。
- `factor_score_breakdown` 增加 group 视角。
@@ -148,7 +148,6 @@
- Narrative Graph 简化版:板块、龙头、二线、跟风、假相关。
- Sector Leader / Laggard 识别:热门板块不等于所有成员都加分。
-- Onchain Flow 增强:链上事件按钱包角色、金额、方向、持续性细分。
- 舆情质量过滤:只在有稳定数据源时做 bot ratio / smart KOL。
- 策略分 regime 回测。
- `strategy_catalog`、`strategy_run_log`、`strategy_performance_daily` 等长期策略运营表。
diff --git a/docs/PRODUCT_INFORMATION_ARCHITECTURE.md b/docs/PRODUCT_INFORMATION_ARCHITECTURE.md
index fbc1008..b18bfac 100644
--- a/docs/PRODUCT_INFORMATION_ARCHITECTURE.md
+++ b/docs/PRODUCT_INFORMATION_ARCHITECTURE.md
@@ -7,13 +7,12 @@ AlphaX 的页面入口按“普通用户能直接理解”和“管理员/研发
普通用户只看到能帮助判断市场和机会的页面:
- 机会中心:当前机会、机会归档、机会状态。
-- 市场总览:全市场环境、强势榜、成交额、资金费率、链上/舆情摘要。
+- 市场总览:全市场环境、强势榜、成交额、资金费率、舆情摘要。
- 消息面:新闻源和 AI 舆情分析。
-- 链上观察:重要资金流、风险线索、相关币种。
- AI 助手:对话式加密研究助手。
- 订阅、邀请:账号商业功能。
-用户页面应该避免这些词作为第一层展示:`cron`、`scheduler`、`provider`、`raw logs`、`pipeline`、`JSON`、`runtime config`、`strategy version`。如果必须保留,应转成用户能理解的说法,例如“系统自动刷新中”“链上监控正常”“进入机会检查”。
+用户页面应该避免这些词作为第一层展示:`cron`、`scheduler`、`provider`、`raw logs`、`pipeline`、`JSON`、`runtime config`、`strategy version`。如果必须保留,应转成用户能理解的说法,例如“系统自动刷新中”“进入机会检查”。
## 管理员菜单
@@ -33,5 +32,5 @@ AlphaX 的页面入口按“普通用户能直接理解”和“管理员/研发
- 先给结论,再给证据。
- 首页和普通用户页面只显示最重要状态,不展示完整工程流水。
- 收益只来自策略交易账本,不把观察样本当收益。
-- 链上和舆情是机会发现与风险上下文,不直接表达买入指令。
+- 舆情是机会发现与风险上下文,不直接表达买入指令。
- 管理员页面可以保留工程细节,但需要聚合入口,避免侧边栏变成功能清单。
diff --git a/docs/postgres_migration.md b/docs/postgres_migration.md
index 1c3ae85..d69a5a3 100644
--- a/docs/postgres_migration.md
+++ b/docs/postgres_migration.md
@@ -105,7 +105,7 @@ bash scripts/postgres/restore.sh backups/postgres/alphax_dev_YYYYmmdd_HHMMSS.dum
## Current Boundary
-This milestone does not replace SQLite in the application runtime. It gives us a repeatable PostgreSQL schema, import path, validation report, and backup/restore workflow. Runtime cutover should be a separate phase with focused tests for recommendations, auth, scheduler state, LLM insights, sentiment, and onchain pages.
+This milestone does not replace SQLite in the application runtime. It gives us a repeatable PostgreSQL schema, import path, validation report, and backup/restore workflow. Runtime cutover should be a separate phase with focused tests for recommendations, auth, scheduler state, LLM insights, and sentiment pages.
Verified locally on 2026-05-16:
diff --git a/static/app.html b/static/app.html
index 1619cab..2f09a46 100644
--- a/static/app.html
+++ b/static/app.html
@@ -214,12 +214,6 @@
.ai-insight .ai-text { color: var(--ink); font-size: 12px; line-height: 1.45; margin-top: 3px; word-break: break-word; }
.ai-insight .ai-list { display: flex; flex-wrap: wrap; gap: 4px; }
.ai-insight .ai-pill { display: inline-flex; padding: 4px 7px; border-radius: 999px; font-size: 11px; color: var(--slate); background: var(--canvas); border: 1px solid var(--hairline-soft); }
-.onchain-brief { margin: 0 18px 8px; border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.045); padding: 9px 10px; display: grid; gap: 6px; }
-.onchain-brief.risk { border-color: rgba(229,62,62,.18); background: var(--red-light); }
-.onchain-head { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--ink); font-size:12px; font-weight:900; }
-.onchain-meta { color:var(--stone); font-size:11px; line-height:1.45; }
-.onchain-score { color:var(--blue); font-weight:950; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
-.onchain-brief.risk .onchain-score { color:var(--red); }
.strategy-diagnostics { margin: 0 18px 8px; display: grid; gap: 8px; }
.score-split { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.score-part { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 9px; min-width: 0; }
@@ -322,7 +316,6 @@
.summary-price-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
.summary-top { display:block; }
.summary-score { margin-top:10px; }
- .onchain-brief { margin: 0 14px 8px; }
.strategy-diagnostics { margin: 0 14px 8px; }
.score-split { grid-template-columns: 1fr; }
.regime-brief { display: block; }
@@ -1025,15 +1018,6 @@ function renderRecCard(r) {
var invalidHtml = (aiInsight.invalid_if || []).slice(0, 4).map(function(x){ return ''+cleanDisplayText(x)+' '; }).join('');
aiInsightHtml = 'AI 解读 缓存 '+cleanDisplayText(aiInsight.summary || aiInsight.why_now_or_not || '暂无摘要')+'
为什么现在 / 为什么不现在
'+cleanDisplayText(aiInsight.why_now_or_not || '--')+'
关键证据
'+(evidenceHtml || '-- ')+'
风险提示
'+(riskHtml || '-- ')+'
观察点
'+(watchHtml || '-- ')+'
失效条件
'+(invalidHtml || '-- ')+'
';
}
- var onchainHtml = '';
- var oc = r.onchain_context || null;
- if (oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score)) {
- var ocRisk = Number(oc.risk_event_count_24h || 0) > 0 || Number(oc.risk_score || 0) >= 60;
- var ocTitle = cleanDisplayText(oc.headline || (ocRisk ? '链上风险升温' : '链上资金异动'));
- var ocScore = ocRisk ? Number(oc.risk_score || 0).toFixed(0) : Number(oc.onchain_score || 0).toFixed(0);
- var ocMeta = [oc.chain || '链上', '24h事件 '+(oc.event_count_24h || 0), oc.dex_volume_usd ? ('DEX量 $'+fmtCompactNumber(oc.dex_volume_usd)) : ''].filter(Boolean).join(' · ');
- onchainHtml = '
'+ocTitle+' '+ocScore+'
'+escHtml(ocMeta)+'
';
- }
function levelFrameText(key) {
if (key === 'intraday_breakout') return '15m/1H';
if (key === 'short_swing') return '1H/4H';
@@ -1053,9 +1037,8 @@ function renderRecCard(r) {
var riskText = riskLine ? fmtP(riskLine) : '--';
var targetText = spaceRef ? fmtP(spaceRef) : '--';
var compactSignals = sigs.slice(0,3).map(function(s){ return ''+displaySignalText(s)+' '; }).join('');
- var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? ''+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+' ' : '';
var orderChip = r.paper_order && r.paper_order.id ? '挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+' ' : '';
- return ''+base.slice(0,2).toUpperCase()+'
'+base+' '+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'
'+sideBadgeHtml(r)+''+phase.label+' '+score+' 总分
当前价 $'+priceFmt+'
'+changeLabel+' '+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'
'+(isWait?(side === 'short'?'计划反抽':'计划回踩'):(side === 'short'?'计划开空':'计划入场'))+' '+fmtP(entryRef || price)+'
策略 '+cleanDisplayText(strategyLabel || '--')+'
'+decisionTitle+' · '+decisionFocus+' '+decisionReason+'
';
+ return ''+base.slice(0,2).toUpperCase()+'
'+base+' '+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'
'+sideBadgeHtml(r)+''+phase.label+' '+score+' 总分
当前价 $'+priceFmt+'
'+changeLabel+' '+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'
'+(isWait?(side === 'short'?'计划反抽':'计划回踩'):(side === 'short'?'计划开空':'计划入场'))+' '+fmtP(entryRef || price)+'
策略 '+cleanDisplayText(strategyLabel || '--')+'
'+decisionTitle+' · '+decisionFocus+' '+decisionReason+'
';
} catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r);
diff --git a/static/base.html b/static/base.html
index 35577da..f9386ba 100644
--- a/static/base.html
+++ b/static/base.html
@@ -242,7 +242,6 @@ a { color: inherit; text-decoration: none; }
-
@@ -263,7 +262,6 @@ a { color: inherit; text-decoration: none; }
-
diff --git a/static/chat.html b/static/chat.html
index f65476d..047ddb6 100644
--- a/static/chat.html
+++ b/static/chat.html
@@ -114,14 +114,14 @@ function short(v,n){v=String(v||'');return v.length>n?v.slice(0,n)+'...':v;}
function fmtNum(v){v=Number(v||0);if(!v)return'--';if(v>=100)return v.toFixed(2);if(v>=1)return v.toFixed(3);if(v>=0.01)return v.toFixed(4);return v.toFixed(8);}
function normMessages(items){return (items||[]).map(function(m){m=deepFix(m||{});return {id:m.id,role:m.role,text:m.content_text||'',content:m.content||{},context:m.context||{},created_at:m.created_at,intent:m.intent,symbol:m.symbol};});}
function renderSessions(){if(!state.sessions.length){sessionList.innerHTML='暂无对话
';return;}sessionList.innerHTML=state.sessions.map(function(s){var active=s.id===state.sessionId?' active':'';return ''+esc(s.title||'新对话')+' '+esc(short(s.last_message_text||s.summary||'还没有消息',88))+'
';}).join('');}
-function renderEmpty(){messages.innerHTML='问 AlphaX 一个 Crypto 问题 直接输入你的问题即可,支持单币技术面、推荐解释、链上异动、舆情影响和复盘结果。
';}
+function renderEmpty(){messages.innerHTML='问 AlphaX 一个 Crypto 问题 直接输入你的问题即可,支持单币技术面、推荐解释、舆情影响和复盘结果。
';}
function renderMessages(){if(!state.messages.length){renderEmpty();return;}messages.innerHTML=state.messages.map(renderMessage).join('');messages.scrollTop=messages.scrollHeight;}
function renderMessage(m){if(m.role==='user'){return '';}return '';}
function renderProgress(lines){if(!lines||!lines.length)return'';return ''+lines.map(function(line,idx){return '
'+esc(line)+' '+(idx===0?' ':'')+'
';}).join('')+'
';}
function renderEvidenceList(items){if(!items||!items.length)return '暂无明确证据,已降级为空态回答。
';return items.slice(0,8).map(function(x){if(typeof x==='string')return ''+esc(x)+'
';if(Array.isArray(x))return ''+esc(x.map(function(v){return typeof v==='string'?v:JSON.stringify(v);}).join(' · '))+'
';if(x&&typeof x==='object'){var label=x.label||x.name||x.title||x.reason||x.summary||x.key||'';var value=x.value||x.text||x.detail||x.note||x.signal||x.message||'';var extra=x.timeframe||x.symbol||x.period||x.source||'';var text=(label?label:'证据')+(value?(':'+value):'')+(extra?(' · '+extra):'');return ''+esc(text||JSON.stringify(x))+'
';}return ''+esc(String(x))+'
';}).join('');}
function renderRecords(items){if(!items||!items.length)return '无直接记录 ';return items.slice(0,8).map(function(r){if(typeof r==='string')return ''+esc(r)+' ';if(r&&typeof r==='object'){var parts=[];if(r.type)parts.push(r.type);if(r.label||r.title||r.name)parts.push(r.label||r.title||r.name);if(r.symbol)parts.push(r.symbol);if(r.status)parts.push(r.status);if(r.timeframe)parts.push(r.timeframe);if(r.created_at||r.detected_at)parts.push((r.created_at||r.detected_at).slice(0,16).replace('T',' '));var text=parts.filter(Boolean).join(' · ');return ''+esc(text||'记录')+' ';}return ''+esc(String(r))+' ';}).join('');}
function renderTfGrid(ctx){var tech=(ctx&&ctx.technicals)||{},tfs=tech.timeframes||{},order=['15m','1h','4h','1d'];var has=order.some(function(tf){return tfs[tf]&&tfs[tf].available;});if(!has)return'';return ''+order.map(function(tf){var x=tfs[tf]||{};if(!x.available)return '
'+tf+' 无数据 '+esc(x.reason||'Binance 未返回')+'
';var trend={uptrend:'上行',rebound:'反弹',weak:'偏弱',downtrend:'下行',sideways:'震荡'}[x.trend]||x.trend||'--';var sub='RSI '+(x.rsi14||'--')+' · 量 '+(x.volume_ratio_20||0)+'x';return '
'+tf+' '+esc(trend)+' · $'+fmtNum(x.price)+' '+esc(sub)+'
';}).join('')+'
';}
-function styleFor(m,c,ctx){return c.answer_style||({coin_analysis:'technical',recommendation_explain:'decision',market_overview:'market',sentiment:'news',onchain:'onchain',review:'review',restricted:'notice',unsupported:'notice',help:'help'}[m.intent||ctx.intent]||'default');}
+function styleFor(m,c,ctx){return c.answer_style||({coin_analysis:'technical',recommendation_explain:'decision',market_overview:'market',sentiment:'news',review:'review',restricted:'notice',unsupported:'notice',help:'help'}[m.intent||ctx.intent]||'default');}
function splitPlainText(text){var raw=String(text||'').replace(/\r\n/g,'\n').trim();if(!raw)return[];var out=[];var current=[];var lines=raw.split('\n');function flush(){var block=current.join(' ').replace(/\s+/g,' ').trim();if(block)out.push(block);current=[];}
lines.forEach(function(line){var t=line.trim();if(!t){flush();return;}if(/^【[^】]+】/.test(t)||/^(结论|证据|风险|建议|判断|复盘|总结|要点|关注点|结论如下)[::]/.test(t)){flush();out.push(t);return;}var numbered=/^(\d+)[\.、]\s*(.+)$/.exec(t);if(numbered){flush();out.push(numbered[0]);return;}if(/^[-•]\s+/.test(t)){flush();out.push(t);return;}if(current.length&¤t[current.length-1].length+t.length>220){flush();current.push(t);flush();return;}current.push(t);});
flush();if(!out.length)out.push(raw);return out;}
@@ -129,7 +129,7 @@ function renderPlainTextAnswer(text){var blocks=splitPlainText(text);return ''+esc(title)+' '+(items&&items.length?renderEvidenceList(items):'
'+esc(empty||'暂无明确数据。')+'
')+'
';}
function renderRecordSection(title,items){if(!items||!items.length)return'';return ''+esc(title)+' '+renderRecords(items)+'
';}
function renderMarketBrief(ctx){var src=(ctx&&ctx.technical_summary)||{},items=[];if(src.stance)items.push(['状态',src.stance]);if(src.risk_level)items.push(['风险',src.risk_level]);if(src.latest_price)items.push(['价格','$'+fmtNum(src.latest_price)]);if(!items.length)return'';return ''+items.map(function(x){return '
'+esc(x[0])+' '+esc(x[1])+'
';}).join('')+'
';}
-function renderAnswer(m){var c=m.content||{},ctx=m.context||{},answer=String(c.answer||m.text||c.summary||'--');var evidence=Array.isArray(c.evidence)?c.evidence:[];var risks=Array.isArray(c.risk_flags)?c.risk_flags:[];var records=Array.isArray(c.related_records)?c.related_records:[];var style=styleFor(m,c,ctx);var tag=m.intent||ctx.intent||style||'回答';var headOnly=''+esc(c.summary||'研究结论')+' '+esc(tag)+'
';var head=headOnly+renderPlainTextAnswer(answer);if(style==='notice'||style==='help'||m.intent==='error')return headOnly+renderPlainTextAnswer(answer)+''+esc(answer)+'
';if(style==='technical')return head+renderTfGrid(ctx)+renderEvidenceSection('关键判断',evidence,'暂无明确技术证据。')+(risks.length?renderEvidenceSection('风险提示',risks,''):'')+renderRecordSection('相关记录',records);if(style==='market')return head+renderMarketBrief(ctx)+renderEvidenceSection('市场要点',evidence,'暂无市场要点。');if(style==='decision')return head+renderEvidenceSection('决策依据',evidence,'暂无推荐依据。')+(risks.length?renderEvidenceSection('不成立条件',risks,''):'')+renderRecordSection('推荐记录',records);if(style==='news')return head+renderEvidenceSection('事件解读',evidence,'暂无舆情事件。')+renderRecordSection('新闻记录',records);if(style==='onchain')return head+renderEvidenceSection('链上信号',evidence,'暂无链上映射信号。')+renderRecordSection('相关事件',records);if(style==='review')return head+renderEvidenceSection('复盘发现',evidence,'暂无复盘样本。')+(risks.length?renderEvidenceSection('需要警惕',risks,''):'')+renderRecordSection('复盘记录',records);return head+renderEvidenceSection('要点',evidence,'暂无明确证据。')+renderRecordSection('相关记录',records);}
+function renderAnswer(m){var c=m.content||{},ctx=m.context||{},answer=String(c.answer||m.text||c.summary||'--');var evidence=Array.isArray(c.evidence)?c.evidence:[];var risks=Array.isArray(c.risk_flags)?c.risk_flags:[];var records=Array.isArray(c.related_records)?c.related_records:[];var style=styleFor(m,c,ctx);var tag=m.intent||ctx.intent||style||'回答';var headOnly=''+esc(c.summary||'研究结论')+' '+esc(tag)+'
';var head=headOnly+renderPlainTextAnswer(answer);if(style==='notice'||style==='help'||m.intent==='error')return headOnly+renderPlainTextAnswer(answer)+''+esc(answer)+'
';if(style==='technical')return head+renderTfGrid(ctx)+renderEvidenceSection('关键判断',evidence,'暂无明确技术证据。')+(risks.length?renderEvidenceSection('风险提示',risks,''):'')+renderRecordSection('相关记录',records);if(style==='market')return head+renderMarketBrief(ctx)+renderEvidenceSection('市场要点',evidence,'暂无市场要点。');if(style==='decision')return head+renderEvidenceSection('决策依据',evidence,'暂无推荐依据。')+(risks.length?renderEvidenceSection('不成立条件',risks,''):'')+renderRecordSection('推荐记录',records);if(style==='news')return head+renderEvidenceSection('事件解读',evidence,'暂无舆情事件。')+renderRecordSection('新闻记录',records);if(style==='review')return head+renderEvidenceSection('复盘发现',evidence,'暂无复盘样本。')+(risks.length?renderEvidenceSection('需要警惕',risks,''):'')+renderRecordSection('复盘记录',records);return head+renderEvidenceSection('要点',evidence,'暂无明确证据。')+renderRecordSection('相关记录',records);}
async function init(){try{var d=await (await fetch('/api/chat/bootstrap')).json();state.sessions=(d.sessions&&d.sessions.items)||[];renderSessions();if(state.sessions[0])loadSession(state.sessions[0].id);else renderEmpty();}catch(e){messages.innerHTML='聊天助手加载失败
';}}
async function refreshSessions(){var d=await (await fetch('/api/chat/sessions?limit=20')).json();state.sessions=d.items||[];renderSessions();}
async function newSession(){state.sessionId=0;state.messages=[];renderSessions();renderEmpty();messageInput.focus();}
@@ -145,7 +145,7 @@ renderAnswer=function(m){
+ ''
+ '
读取 AlphaX 当前数据
'
+ '
汇总多周期行情与结构
'
- + '
结合推荐、舆情、链上与复盘
'
+ + '
结合推荐、舆情与复盘
'
+ '
'
+ '稍等一下,我正在把不同数据源拼起来,随后会给你结论和依据。
';
}
diff --git a/static/chat_logs.html b/static/chat_logs.html
index a0673b0..d504d33 100644
--- a/static/chat_logs.html
+++ b/static/chat_logs.html
@@ -72,7 +72,6 @@ tr:hover td{background:var(--surface)}
市场问答
推荐解释
舆情解读
- 链上异动
复盘查询
受限内容
diff --git a/static/config.html b/static/config.html
index 41bb931..cb443f6 100644
--- a/static/config.html
+++ b/static/config.html
@@ -23,7 +23,7 @@
刷新
- 建议:新闻源、LLM、链上、调度、策略交易属于系统配置;复盘 meta、learned_rules、策略覆盖属于策略运行时配置。
+ 建议:新闻源、LLM、调度、策略交易属于系统配置;复盘 meta、learned_rules、策略覆盖属于策略运行时配置。
diff --git a/static/data_export.html b/static/data_export.html
index 38ce8e9..f4835f3 100644
--- a/static/data_export.html
+++ b/static/data_export.html
@@ -36,7 +36,7 @@
推荐链路 recommendation / screening_log / cron_run_log
策略交易 挂单 / 持仓 / 交易事件
复盘迭代 review / missed / strategy rules
- 证据源 舆情 / 链上 / AI 记录
+ 证据源 舆情 / AI 记录
运行配置 策略与系统配置快照
diff --git a/static/logs.html b/static/logs.html
index 8893964..ee342fd 100644
--- a/static/logs.html
+++ b/static/logs.html
@@ -59,7 +59,6 @@ tr:hover td{background:var(--surface)}
系统错误
- 链上运行
调度运行
链路批次
问答日志
@@ -94,20 +93,10 @@ tr:hover td{background:var(--surface)}
-
-
-
全部任务 链上 粗筛 爆发确认 Price Streamer 策略交易
+
全部任务 粗筛 爆发确认 Price Streamer 策略交易
近 24h 近 7 天
刷新
打开调度中心
@@ -130,7 +119,7 @@ tr:hover td{background:var(--surface)}
-
全部意图 币种分析 链上异动 舆情 复盘
+
全部意图 币种分析 舆情 复盘
近 24h 近 7 天
查询
打开原问答日志
@@ -150,19 +139,18 @@ function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return
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 '
'+esc(s||'--')+' '}
+function badge(v){var s=String(v||'');var cls=s==='success'||s==='processed'||s==='ok'?'ok':(s==='error'||s.indexOf('fail')>=0?'err':(s==='warning'?'warn':''));return '
'+esc(s||'--')+' '}
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)}
+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==='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 '
'+esc(x[0])+' '+esc(x[1])+' '+esc(x[2]||'')+'
'}).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 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();renderOps([['系统错误',s.total||0,'近 24 小时'],['调度成功率',(c.overall||{}).success_rate+'%','运行 '+((c.overall||{}).total_runs||0)+' 次'],['调度失败',(c.overall||{}).error_runs||0,'需要排查的任务'],['平均耗时',dur((c.overall||{}).avg_duration_ms),'近 24 小时']])}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='
加载中... ';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 '
'+time(x.created_at)+' '+badge(x.source||'app')+' '+esc(x.error_type||'Error')+' '+esc(short(x.message,140))+' '+esc(short((x.request_path||'--')+(x.user_email?' · '+x.user_email:''),80))+' '+badge(x.level||x.status_code)+' '}).join(''):'
暂无系统错误 ';pager('sysPager',offset,state.sysTotal,'loadSystem')}catch(e){sysRows.innerHTML='
加载失败 '}}
async function loadSystemDetail(id){sysDetail.innerHTML='
加载详情...
';try{var d=await(await fetch('/api/admin/system-errors/'+id)).json();sysDetail.innerHTML='
#'+esc(d.id)+' · '+esc(d.error_type||'Error')+' 时间 '+time(d.created_at)+' 来源 '+esc(d.source||'app')+' · PID '+esc(d.pid||0)+' 路径 '+esc((d.request_method||'')+' '+(d.request_path||'--'))+' 用户 '+esc(d.user_email||'--')+' 指纹 '+esc(d.fingerprint||'--')+' 消息 '+esc(d.message||'--')+'
'+esc(d.stack_trace||'无堆栈信息')+'
'}catch(e){sysDetail.innerHTML='
详情加载失败
'}}
-async function loadOnchain(){loadOps();onchainRows.innerHTML='
加载中... ';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='
加载失败 '}}
async function loadCron(){loadOps();cronRows.innerHTML='
加载中... ';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='
加载失败 '}}
function renderCronRows(id,items){var el=document.getElementById(id);el.innerHTML=items.length?items.map(function(x){return '
'+time(x.started_at)+' '+esc(x.job_name||'--')+' '+badge(x.run_status)+' '+badge(x.result_status)+' '+dur(x.duration_ms)+' '+esc(summary(x.summary_json))+' '+esc(short(x.error_message||'',180))+' '}).join(''):'
暂无运行日志 '}
async function loadPipeline(offset){state.pipeOffset=offset;loadOps();pipeRows.innerHTML='
加载中... ';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 '
#'+esc(x.run_id||x.id)+' '+time(x.started_at)+' '+esc((x.universe_gate_count||0)+' / '+(x.discovery_count||0)+' / '+(x.quality_pass_count||0))+' '+esc(x.recommendations||0)+' '+esc((x.recommendation_rate||0)+'%')+' '+esc((x.perf_success||0)+' / '+(x.perf_failed||0)+' / '+(x.missed_count||0))+' '+badge(x.result_status||x.run_status)+' '}).join(''):'
暂无链路批次 ';pager('pipePager',offset,state.pipeTotal,'loadPipeline',30)}catch(e){pipeRows.innerHTML='
加载失败 '}}
-async function loadChat(offset){state.chatOffset=offset;loadOps();chatRows.innerHTML='
加载中... ';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 '
'+time(x.created_at)+' '+esc(x.user_email||'--')+' '+badge(x.intent||'--')+' '+esc(short(x.content_text||'',170))+' '+esc(short((x.symbol?x.symbol+' · ':'')+(x.session_title||('会话 #'+x.session_id)),120))+' '}).join(''):'
暂无问答日志 ';pager('chatPager',offset,state.chatTotal,'loadChat')}catch(e){chatRows.innerHTML='
加载失败 '}}
+async function loadChat(offset){state.chatOffset=offset;loadOps();chatRows.innerHTML='
加载中... ';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.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 '
'+time(x.created_at)+' '+esc(x.user_email||'--')+' '+badge(x.intent||'--')+' '+esc(short(x.content_text||'',170))+' '+esc(short((x.symbol?x.symbol+' · ':'')+(x.session_title||('会话 #'+x.session_id)),120))+' '}).join(''):'
暂无问答日志 ';pager('chatPager',offset,state.chatTotal,'loadChat')}catch(e){chatRows.innerHTML='
加载失败 '}}
function mini(x){return '
'+esc(x[0])+' '+esc(x[1])+' '+esc(x[2]||'')+'
'}
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='
上一页 第 '+cur+' / '+totalPages+' 页 · 共 '+(total||0)+' 条 =total?'disabled':'')+' onclick="'+fn+'('+(offset+size)+')">下一页 '}
(async function(){await ensureAdmin();loadOps();switchTab('system')})();
diff --git a/static/market.html b/static/market.html
index 3775f6e..160d387 100644
--- a/static/market.html
+++ b/static/market.html
@@ -10,7 +10,7 @@
市场总览
-
基于整个加密市场判断今天的大环境:BTC/ETH 方向、山寨市场广度、成交额、资金费率,再结合链上和 AI 舆情作为辅助证据。
+
基于 CEX 行情判断今天的大环境:BTC/ETH 方向、山寨市场广度、成交额、资金费率,再结合新闻舆情和 AI 摘要作为辅助证据。
@@ -62,16 +58,15 @@
{% endblock %}
diff --git a/static/onchain.html b/static/onchain.html
deleted file mode 100644
index c55531c..0000000
--- a/static/onchain.html
+++ /dev/null
@@ -1,102 +0,0 @@
-{% extends "base.html" %}
-{% block title %}AlphaX Agent — 链上观察{% endblock %}
-{% block extra_head_css %}
-
-{% endblock %}
-{% block content %}
-
-
-
链上观察 观察资金在链上的异常流动:大额转账、交易所流入流出、持有人变化和潜在风险。链上信号只提供线索,最终仍由价格结构确认。
-
-
- 刷新
-
-
-
链上观察不是买入指令。正向资金流会进入机会检查;交易所流入、流动性撤出、持仓集中等负向信号只作为风险提醒。
-
- 总览
- 链上事件
- 相关币种
-
-
-
-
-
-
-
-
-
- 全部链 Ethereum BSC
- 全部信号 大额转账 鲸鱼增持 持有人增长 交易所流出 交易所流入 持仓集中风险
-
- 币种 链 重要性 风险分 映射事件 最近事件 数据源 策略状态 加载中...
-
-
-
-
-
-{% endblock %}
-{% block extra_script %}
-
-{% endblock %}
diff --git a/static/operations.html b/static/operations.html
index c1fa9af..4a49988 100644
--- a/static/operations.html
+++ b/static/operations.html
@@ -42,7 +42,7 @@
- 数据源新鲜度
市场、价格、舆情、链上和 AI 缓存是否还在更新
+ 数据源新鲜度
市场、价格、舆情和 AI 缓存是否还在更新
diff --git a/static/opportunity_detail.html b/static/opportunity_detail.html
index a0d17d7..ea2abb2 100644
--- a/static/opportunity_detail.html
+++ b/static/opportunity_detail.html
@@ -39,7 +39,7 @@ function sideRr(side,entry,stop,tp){return side==='short'?((entry-tp)/(stop-entr
function signals(r){return Array.isArray(r.signal_labels)&&r.signal_labels.length?r.signal_labels:(Array.isArray(r.signals)?r.signals:[])}
function aiInsight(r){return r.llm_insight&&r.llm_insight.content?r.llm_insight.content:null}
function renderRows(rows,opts){opts=opts||{};if(!rows||!rows.length)return'
'+(opts.empty||'暂无数据')+'
';return '
'+rows.map(function(x){return '
'+esc(opts.time?opts.time(x):'--')+'
'+esc(opts.title?opts.title(x):'--')+'
'+esc(opts.sub?opts.sub(x):'')+'
'+esc(opts.val?opts.val(x):'')+'
'}).join('')+'
'}
-function renderDetail(d){window.__opportunityDetail=d||{};var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),side=recSide(r),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r),strategyMap={long_intraday_momentum_15m_1h_v1:'多头日内动量启动',long_momentum_breakout_15m_1h_v1:'多头日内动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',long_compression_breakout_1h_4h_v1:'多头压缩突破',long_box_retest_4h_v1:'多头4H箱体回踩',short_breakdown_retest_1h_v1:'空头破位反抽',short_weak_bounce_failure_15m_1h_v1:'空头弱反弹失败'},strategyName=r.strategy_name||strategyMap[r.strategy_code||'']||r.strategy_code||'未识别策略';$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?sideRr(side,entry,stop,tp1).toFixed(2):'--';var chg=entry&¤t?sidePnl(side,current,entry):0;var aiHtml=ai?'
'+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'
暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var markerCount=(d.trade_markers||[]).length;var root='
'+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+sideBadge(r)+''+esc(statusLabel(r))+' '+esc(strategyName)+' 总分 '+esc(r.rec_score||0)+' '+esc(r.strategy_version||'--')+'
当前价 '+price(current)+'
相对参考 '+pct(chg)+'
'+(side==='short'?'计划开空':'计划入场')+' '+price(entry)+'
止损 / 止盈 '+price(stop)+' / '+price(tp1)+'
盈亏比 '+esc(rr)+'
多周期 K 线
'+(markerCount?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
决策与策略剧本
'+esc(strategyName)+' · 触发、入场、失效条件分开看
当前结论 '+esc(statusLabel(r))+'
策略来源 '+esc(strategyName)+'
原因 '+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
'+(side==='short'?'开空模型':'入场模型')+' '+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
失效条件 '+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'
机会分 '+num(sc.opportunity_score||0)+'
买点分 '+num(sc.entry_score||0)+'
风险扣分 '+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+' '}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
市场环境
'+esc(rg.label||rg.regime||'--')+'
'+(Array.isArray(rg.reasons)?rg.reasons:['市场环境已进入推荐上下文']).slice(0,8).map(function(s){return''+esc(clean(s))+' '}).join('')+'
链上与外部证据
正向 '+esc(onchain.positive_count||0)+' · 风险 '+esc(onchain.risk_count||0)+'
链上分 '+num(metric.onchain_score||0)+'
风险分 '+num(metric.risk_score||0)+'
'+renderRows(onchain.events,{empty:'暂无链上事件',time:function(x){return fmtTime(x.detected_at)},title:function(x){return x.signal_label||x.signal_code||'链上事件'},sub:function(x){return [x.chain,x.direction,x.tx_hash].filter(Boolean).join(' · ')},val:function(x){return x.value_usd?('$'+num(x.value_usd)):''},cls:function(x){return x.direction==='risk'?'red':'green'}})+'
'+renderRows(d.paper_orders,{empty:'暂无挂单',time:function(x){return fmtTime(x.created_at)},title:function(x){return '挂单 · '+(x.status||'--')},sub:function(x){return '目标 '+price(x.target_price)+' · 当前 '+price(x.current_price_at_create)},val:function(x){return x.cancel_reason||x.status||''}})+'
'+renderRows(d.paper_trades,{empty:'暂无持仓交易',time:function(x){return fmtTime(x.opened_at)},title:function(x){return '交易 · '+(x.status||'--')},sub:function(x){return '入场 '+price(x.entry_price)+' · 当前 '+price(x.current_price)},val:function(x){return pct(x.pnl_pct||x.realized_pnl_pct)},cls:function(x){return Number(x.pnl_pct||x.realized_pnl_pct||0)>=0?'green':'red'}})+'
'+renderRows(d.paper_events,{empty:'暂无交易事件',time:function(x){return fmtTime(x.event_time)},title:function(x){return x.event_type||'事件'},sub:function(x){return x.message||'--'},val:function(x){return price(x.price)}})+'
复盘记录
'+esc((d.summary||{}).review_count||0)+' 条
'+renderRows(d.reviews,{empty:'暂无复盘',time:function(x){return fmtTime(x.review_time)},title:function(x){return x.outcome||'复盘'},sub:function(x){return x.lesson||'--'},val:function(x){return pct(x.pnl_48h||0)},cls:function(x){return Number(x.pnl_48h||0)>=0?'green':'red'}})+'
';$('detailRoot').innerHTML=root;loadKline()}
+function renderDetail(d){window.__opportunityDetail=d||{};var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),side=recSide(r),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r),strategyMap={long_intraday_momentum_15m_1h_v1:'多头日内动量启动',long_momentum_breakout_15m_1h_v1:'多头日内动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',long_compression_breakout_1h_4h_v1:'多头压缩突破',long_box_retest_4h_v1:'多头4H箱体回踩',short_breakdown_retest_1h_v1:'空头破位反抽',short_weak_bounce_failure_15m_1h_v1:'空头弱反弹失败'},strategyName=r.strategy_name||strategyMap[r.strategy_code||'']||r.strategy_code||'未识别策略';$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?sideRr(side,entry,stop,tp1).toFixed(2):'--';var chg=entry&¤t?sidePnl(side,current,entry):0;var aiHtml=ai?'
'+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'
暂无 AI 解读
';var markerCount=(d.trade_markers||[]).length;var root='
'+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+sideBadge(r)+''+esc(statusLabel(r))+' '+esc(strategyName)+' 总分 '+esc(r.rec_score||0)+' '+esc(r.strategy_version||'--')+'
当前价 '+price(current)+'
相对参考 '+pct(chg)+'
'+(side==='short'?'计划开空':'计划入场')+' '+price(entry)+'
止损 / 止盈 '+price(stop)+' / '+price(tp1)+'
盈亏比 '+esc(rr)+'
多周期 K 线
'+(markerCount?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
决策与策略剧本
'+esc(strategyName)+' · 触发、入场、失效条件分开看
当前结论 '+esc(statusLabel(r))+'
策略来源 '+esc(strategyName)+'
原因 '+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
'+(side==='short'?'开空模型':'入场模型')+' '+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
失效条件 '+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'
机会分 '+num(sc.opportunity_score||0)+'
买点分 '+num(sc.entry_score||0)+'
风险扣分 '+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+' '}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
市场环境
'+esc(rg.label||rg.regime||'--')+'
'+(Array.isArray(rg.reasons)?rg.reasons:['市场环境已进入推荐上下文']).slice(0,8).map(function(s){return''+esc(clean(s))+' '}).join('')+'
'+renderRows(d.paper_orders,{empty:'暂无挂单',time:function(x){return fmtTime(x.created_at)},title:function(x){return '挂单 · '+(x.status||'--')},sub:function(x){return '目标 '+price(x.target_price)+' · 当前 '+price(x.current_price_at_create)},val:function(x){return x.cancel_reason||x.status||''}})+'
'+renderRows(d.paper_trades,{empty:'暂无持仓交易',time:function(x){return fmtTime(x.opened_at)},title:function(x){return '交易 · '+(x.status||'--')},sub:function(x){return '入场 '+price(x.entry_price)+' · 当前 '+price(x.current_price)},val:function(x){return pct(x.pnl_pct||x.realized_pnl_pct)},cls:function(x){return Number(x.pnl_pct||x.realized_pnl_pct||0)>=0?'green':'red'}})+'
'+renderRows(d.paper_events,{empty:'暂无交易事件',time:function(x){return fmtTime(x.event_time)},title:function(x){return x.event_type||'事件'},sub:function(x){return x.message||'--'},val:function(x){return price(x.price)}})+'
复盘记录
'+esc((d.summary||{}).review_count||0)+' 条
'+renderRows(d.reviews,{empty:'暂无复盘',time:function(x){return fmtTime(x.review_time)},title:function(x){return x.outcome||'复盘'},sub:function(x){return x.lesson||'--'},val:function(x){return pct(x.pnl_48h||0)},cls:function(x){return Number(x.pnl_48h||0)>=0?'green':'red'}})+'
';$('detailRoot').innerHTML=root;loadKline()}
function loadKline(){var c=$('kline');if(!c)return;var active=document.querySelector('.kline-int-btn.active');var interval=active?active.dataset.int:'1h';fetch(API+'/api/kline?symbol='+encodeURIComponent(c.dataset.symbol)+'&interval='+interval+'&limit=100').then(function(r){return r.json()}).then(function(resp){var candles=resp.candles||[];if(!window.AlphaXCharts||!window.AlphaXCharts.renderKline)throw new Error('chart unavailable');c.innerHTML='';window.AlphaXCharts.renderKline(c,{symbol:c.dataset.symbol,candles:candles,entryPrice:Number(c.dataset.entryPrice||0),stopLoss:Number(c.dataset.stopLoss||0),tp1:Number(c.dataset.tp1||0),recTime:c.dataset.recTime||'',refPrice:Number(c.dataset.refPrice||0),tradeMarkers:(window.__opportunityDetail&&window.__opportunityDetail.trade_markers)||[]});c.classList.remove('loading')}).catch(function(){c.innerHTML='
K线加载失败
'})}
function switchKline(btn){document.querySelectorAll('.kline-int-btn').forEach(function(b){b.classList.remove('active')});btn.classList.add('active');var c=$('kline');c.classList.add('loading');c.innerHTML='
加载 K 线...
';loadKline()}
async function load(){var q=new URLSearchParams(location.search);var recId=q.get('rec_id')||'';var symbol=q.get('symbol')||'';var url=API+'/api/opportunity/detail?symbol='+encodeURIComponent(symbol)+'&rec_id='+encodeURIComponent(recId);try{var d=await (await fetch(url)).json();if(d.error){$('detailRoot').innerHTML='
没有找到该机会
';return}renderDetail(d)}catch(e){$('detailRoot').innerHTML='
机会详情加载失败
'}}
diff --git a/static/review_center.html b/static/review_center.html
index 3e1f3f6..2e7d802 100644
--- a/static/review_center.html
+++ b/static/review_center.html
@@ -60,9 +60,9 @@ function decisionCls(d){return ({promote:'promote',pause:'pause',gray:'gray',tun
function renderStrategyBoard(d){var se=d.strategy_evaluation||{},s=se.summary||{},items=se.strategies||[];var cards=items.slice(0,6).map(function(x){var score=Math.max(0,Math.min(100,Number(x.evaluation_score||0)));return '
'+esc(x.strategy_name||x.strategy_code)+'
'+esc(x.description||'暂无策略说明')+'
'+score.toFixed(0)+'
信号 '+esc(x.signal_count||0)+'
机会 '+esc(x.opportunity_count||0)+'
窗口平仓 '+esc(x.closed_trade_count||0)+'
胜率 '+num(x.win_rate_pct,1)+'%
收益 '+usd(x.realized_pnl_usdt)+'
均值 '+pct(x.avg_realized_pnl_pct)+'
成交 '+num(x.order_fill_rate_pct,1)+'%
转化 '+num(x.trade_conversion_pct,1)+'%
'+esc(x.decision_label||x.decision)+' '+esc((x.reasons||[])[0]||'继续观察')+' '+esc((x.next_actions||[])[0]||'等待更多样本')+'
'}).join('')||'
暂无策略评价数据
';$('strategyBoard').innerHTML='
多策略优胜劣汰
'+esc(se.definition||'按策略独立评价发现、执行、收益和风险。')+'
策略 '+esc(s.strategy_count||0)+' · 已交易 '+esc(s.traded_strategy_count||0)+' · 待暂停 '+esc(s.pause_count||0)+' '+cards+'
'}
function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'
状态分布
'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
复盘结果
'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
'}
function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],ts=ta.strategy_code||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['当前持仓/窗口平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['窗口已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'
策略表现
'+rows(ts.slice(0,8),function(x){return x.strategy_name||x.strategy_code||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
退出原因
'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'
执行事件
'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'
真实交易因子
'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
因子组表现
'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
入场路径表现
'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '窗口平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
观察/挂单推进
'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'
'}
-function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'
链上信号
'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'
舆情决策
'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'
'}
+function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'
舆情决策
'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'
新闻来源
'+rows(e.news_sources,function(x){return x.name||'--'},function(x){return x.count})+'
'}
function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'
最新发布结论:'+esc(s.latest_release_decision||'hold')+'
'+esc(s.latest_release_reason||'暂无发布说明')+'
闸门
发布决策
'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'
候选状态
'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'
'}
-function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='
最近策略交易
'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'
漏选爆发
'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'
舆情事件
'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'
链上信号
'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'
'}
+function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[];$('recentPanel').innerHTML='
最近策略交易
'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'
漏选爆发
'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'
舆情事件
'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'
'}
function render(d){renderStrategyDigest(d);renderStrategyBoard(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return '
'+esc(x)+'
'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)}
async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['strategyDigest','strategyBoard','principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='
加载失败
'})}}
loadAll();
diff --git a/static/strategy.html b/static/strategy.html
index 99a262f..043974a 100644
--- a/static/strategy.html
+++ b/static/strategy.html
@@ -20,12 +20,12 @@
机会样本 系统发现并入库的机会,不等于交易。
可执行转化 确认层输出现在可买或等回踩。
策略执行 只有 buy_now 被策略交易开仓才进入收益账本。
-
证据归因 链上、舆情、技术因子只做贡献分析。
+
证据归因 舆情、技术因子只做贡献分析。
-
+
交易级因子归因
只统计已平仓策略交易,用真实账本收益评价因子、入场路径、退出原因和环境。
diff --git a/tests/test_base_shell_ownership.py b/tests/test_base_shell_ownership.py
index 86538b5..6c422aa 100644
--- a/tests/test_base_shell_ownership.py
+++ b/tests/test_base_shell_ownership.py
@@ -43,7 +43,6 @@ def test_base_template_owns_shared_app_tab_style():
"paper_trading.html",
"live_trading.html",
"logs.html",
- "onchain.html",
"admin.html",
"iteration.html",
]
diff --git a/tests/test_market_overview_api.py b/tests/test_market_overview_api.py
index 22483ca..a51a37d 100644
--- a/tests/test_market_overview_api.py
+++ b/tests/test_market_overview_api.py
@@ -29,6 +29,10 @@ def test_crypto_market_overview_uses_full_market_inputs(monkeypatch):
"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},
})
+ monkeypatch.setattr(market_overview, "_benchmark_overview", lambda pairs=None: {
+ "BTC/USDT": {"symbol": "BTC/USDT", "price": 100_000, "change_24h": 1.5, "volume_24h": 1_000_000_000},
+ "ETH/USDT": {"symbol": "ETH/USDT", "price": 5_000, "change_24h": 2.1, "volume_24h": 800_000_000},
+ })
overview = market_overview.compute_crypto_market_overview()
@@ -56,8 +60,6 @@ def test_market_overview_api_returns_crypto_market_not_candidate_stats(monkeypat
"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)
@@ -68,4 +70,5 @@ def test_market_overview_api_returns_crypto_market_not_candidate_stats(monkeypat
assert data["market"]["crypto_market"]["source"] == "binance_spot_usdt_market"
assert "stats" not in data["market"]
assert "market_context_overview" not in data["market"]
+ assert "onchain" not in data["market"]
assert resp.headers["cache-control"] == "no-cache, no-store, must-revalidate"
diff --git a/tests/test_onchain_factor_scoring.py b/tests/test_onchain_factor_scoring.py
deleted file mode 100644
index 4f4837d..0000000
--- a/tests/test_onchain_factor_scoring.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from datetime import datetime
-
-import pytest
-
-from app.core.factor_scoring import FactorScorer
-from app.db.onchain_db import get_onchain_factor_context, insert_onchain_event, insert_token_metric
-from app.services.altcoin_confirm import _apply_onchain_factor_score
-
-
-def test_onchain_factor_context_returns_recent_positive_and_risk_events():
- now = datetime.now().isoformat()
- insert_token_metric(
- {
- "symbol": "BEAM/USDT",
- "chain": "ethereum",
- "window": "1h",
- "metric_time": now,
- "onchain_score": 82,
- "risk_score": 12,
- "source": "nodereal",
- }
- )
- insert_onchain_event(
- {
- "symbol": "BEAM/USDT",
- "chain": "ethereum",
- "signal_code": "whale_accumulation",
- "direction": "positive",
- "value_usd": 1_200_000,
- "confidence": 88,
- "detected_at": now,
- "source": "nodereal",
- }
- )
- insert_onchain_event(
- {
- "symbol": "BEAM/USDT",
- "chain": "ethereum",
- "signal_code": "exchange_inflow_risk",
- "direction": "risk",
- "value_usd": 800_000,
- "confidence": 80,
- "detected_at": now,
- "source": "nodereal",
- }
- )
-
- ctx = get_onchain_factor_context("BEAM/USDT", hours=24)
-
- assert ctx["has_data"] is True
- assert ctx["metrics"]["onchain_score"] == pytest.approx(82)
- assert ctx["positive_event_count"] == 1
- assert ctx["risk_event_count"] == 1
- assert ctx["top_positive"]["signal_code"] == "whale_accumulation"
-
-
-def test_onchain_factor_score_uses_review_weights():
- now = datetime.now().isoformat()
- insert_onchain_event(
- {
- "symbol": "SMART/USDT",
- "chain": "bsc",
- "signal_code": "smart_money_buying",
- "direction": "positive",
- "value_usd": 500_000,
- "confidence": 76,
- "detected_at": now,
- "source": "nodereal",
- }
- )
- scorer = FactorScorer(weights={"smart_money_buying": 0})
-
- delta, signals, ctx = _apply_onchain_factor_score("SMART/USDT", scorer)
-
- assert ctx["has_data"] is True
- assert signals
- assert delta == 0
- assert scorer.summary()["items"][0]["factor_code"] == "smart_money_buying"
- assert scorer.summary()["items"][0]["score_delta"] == 0
-
-
-def test_onchain_risk_factor_is_recorded_as_negative_score():
- now = datetime.now().isoformat()
- insert_onchain_event(
- {
- "symbol": "RISKY/USDT",
- "chain": "ethereum",
- "signal_code": "exchange_inflow_risk",
- "direction": "risk",
- "value_usd": 900_000,
- "confidence": 82,
- "detected_at": now,
- "source": "nodereal",
- }
- )
- scorer = FactorScorer(weights={})
-
- delta, signals, ctx = _apply_onchain_factor_score("RISKY/USDT", scorer)
- summary = scorer.summary()
-
- assert ctx["has_data"] is True
- assert signals
- assert delta < 0
- assert summary["items"][0]["score_delta"] < 0
- assert summary["risk_score"] > 0
diff --git a/tests/test_onchain_tracking.py b/tests/test_onchain_tracking.py
deleted file mode 100644
index 46fe407..0000000
--- a/tests/test_onchain_tracking.py
+++ /dev/null
@@ -1,622 +0,0 @@
-import json
-import os
-import sqlite3
-import sys
-from datetime import datetime, timedelta
-
-from fastapi.testclient import TestClient
-
-PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
-if PROJECT_DIR not in sys.path:
- sys.path.insert(0, PROJECT_DIR)
-
-from app.db import altcoin_db, onchain_db, scheduler_db
-from app.services import onchain_monitor
-from app.web import web_server
-
-
-def _temp_db(monkeypatch, tmp_path):
- db_path = tmp_path / "altcoin_monitor.db"
- monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
- monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
- altcoin_db.init_db()
- onchain_db.init_onchain_tables()
- return db_path
-
-
-def test_mapping_requires_confidence_and_preserves_multi_chain(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- onchain_db.upsert_token_mapping("ABC", "ethereum", "0xaaa", source="manual", confidence=95)
- onchain_db.upsert_token_mapping("ABC", "bsc", "0xbbb", source="manual", confidence=55)
-
- usable = onchain_db.get_token_mappings("ABC", min_confidence=70)
-
- assert len(usable) == 1
- assert usable[0]["chain"] == "ethereum"
- assert usable[0]["contract_address"] == "0xaaa"
-
-
-def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- assert onchain_monitor._is_auto_mapping_symbol_allowed("STORJ", "Storj") is True
- assert onchain_monitor._is_auto_mapping_symbol_allowed("AVAX", "Avalanche") is False
- assert onchain_monitor._is_auto_mapping_symbol_allowed("FIL", "Wrapped Filecoin") is False
- assert onchain_monitor._is_auto_mapping_symbol_allowed("USDT", "Tether USD") is False
-
-
-def test_onchain_candidate_enqueues_event_news_not_recommendation(monkeypatch, tmp_path):
- db_path = _temp_db(monkeypatch, tmp_path)
- onchain_db.insert_token_metric(
- {
- "symbol": "ABC/USDT",
- "chain": "ethereum",
- "contract_address": "0xaaa",
- "window": "1h",
- "metric_time": datetime.now().isoformat(),
- "dex_volume_usd": 500000,
- "dex_volume_change_pct": 160,
- "liquidity_usd": 300000,
- "liquidity_change_pct": 35,
- "onchain_score": 82,
- "risk_score": 0,
- "source": "test",
- }
- )
- event_id = onchain_db.insert_onchain_event(
- {
- "chain": "ethereum",
- "symbol": "ABC/USDT",
- "contract_address": "0xaaa",
- "signal_code": "dex_volume_spike",
- "direction": "positive",
- "value_usd": 500000,
- "confidence": 88,
- "severity": "A",
- "detected_at": datetime.now().isoformat(),
- "source": "test",
- }
- )
-
- result = onchain_monitor.enqueue_onchain_candidates(min_score=70, min_confidence=70, cooldown_hours=6)
-
- assert event_id > 0
- assert result["queued"] == 1
- conn = sqlite3.connect(db_path)
- conn.row_factory = sqlite3.Row
- news = conn.execute("SELECT * FROM event_news WHERE source='onchain'").fetchone()
- rec_count = conn.execute("SELECT COUNT(*) FROM recommendation").fetchone()[0]
- status = conn.execute("SELECT status FROM onchain_events WHERE id=?", (event_id,)).fetchone()[0]
- conn.close()
- assert news["event_type"] == "onchain_candidate"
- assert json.loads(news["raw_json"])["signal_code"] == "dex_volume_spike"
- assert rec_count == 0
- assert status == "candidate_queued"
-
-
-def test_onchain_candidate_duplicate_event_hash_does_not_abort_transaction(monkeypatch, tmp_path):
- db_path = _temp_db(monkeypatch, tmp_path)
- old_time = (datetime.now() - timedelta(hours=12)).isoformat()
- event_time = datetime.now().isoformat()
- stale_event = {
- "id": 9001,
- "symbol": "DUP/USDT",
- "signal_label": "DEX交易量放大",
- "signal_code": "dex_volume_spike",
- "value_usd": 500000,
- }
- duplicate_title = onchain_monitor._candidate_title(stale_event)
- duplicate_hash = onchain_monitor.event_hash("onchain", duplicate_title, "DUP/USDT")
- conn = altcoin_db.get_conn()
- conn.execute(
- """
- INSERT INTO event_news
- (event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
- VALUES (%s, 'onchain', %s, %s, '', %s, %s, 'A', 'onchain_candidate', '{}', 0)
- """,
- (duplicate_hash, "DUP/USDT", duplicate_title, old_time, old_time),
- )
- conn.commit()
- conn.close()
- first_id = onchain_db.insert_onchain_event(
- {
- "chain": "ethereum",
- "symbol": "DUP/USDT",
- "contract_address": "0xdup",
- "signal_code": "dex_volume_spike",
- "signal_label": "DEX交易量放大",
- "direction": "positive",
- "value_usd": 500000,
- "confidence": 90,
- "severity": "A",
- "detected_at": event_time,
- "source": "test",
- }
- )
- second_id = onchain_db.insert_onchain_event(
- {
- "chain": "ethereum",
- "symbol": "OK/USDT",
- "contract_address": "0xok",
- "signal_code": "dex_volume_spike",
- "direction": "positive",
- "value_usd": 600000,
- "confidence": 91,
- "severity": "A",
- "detected_at": event_time,
- "source": "test",
- }
- )
-
- result = onchain_monitor.enqueue_onchain_candidates(min_score=1, min_confidence=1, cooldown_hours=6)
-
- conn = sqlite3.connect(db_path)
- conn.row_factory = sqlite3.Row
- statuses = {
- row["id"]: row["status"]
- for row in conn.execute("SELECT id, status FROM onchain_events WHERE id IN (?, ?)", (first_id, second_id)).fetchall()
- }
- queued_news = conn.execute("SELECT * FROM event_news WHERE source='onchain' AND symbol='OK/USDT'").fetchone()
- conn.close()
- assert result["queued"] == 1
- assert result["skipped"] == 1
- assert statuses[first_id] == "candidate_skipped"
- assert statuses[second_id] == "candidate_queued"
- assert queued_news is not None
-
-
-def test_negative_onchain_signal_is_risk_context_only(monkeypatch, tmp_path):
- db_path = _temp_db(monkeypatch, tmp_path)
- onchain_db.insert_onchain_event(
- {
- "chain": "ethereum",
- "symbol": "RISK/USDT",
- "signal_code": "exchange_inflow_risk",
- "direction": "risk",
- "value_usd": 900000,
- "confidence": 92,
- "severity": "RISK",
- "detected_at": datetime.now().isoformat(),
- "source": "test",
- }
- )
-
- result = onchain_monitor.enqueue_onchain_candidates(min_score=1, min_confidence=1)
-
- conn = sqlite3.connect(db_path)
- news_count = conn.execute("SELECT COUNT(*) FROM event_news WHERE source='onchain'").fetchone()[0]
- conn.close()
- assert result["queued"] == 0
- assert news_count == 0
-
-
-def test_onchain_api_and_page(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- onchain_db.insert_token_metric(
- {
- "symbol": "ABC/USDT",
- "chain": "base",
- "contract_address": "0xabc",
- "window": "1h",
- "metric_time": datetime.now().isoformat(),
- "dex_volume_usd": 123000,
- "dex_volume_change_pct": 90,
- "liquidity_usd": 456000,
- "liquidity_change_pct": 12,
- "onchain_score": 76,
- "risk_score": 8,
- "source": "test",
- }
- )
- client = TestClient(web_server.app)
-
- page = client.get("/onchain")
- assert page.status_code == 200
- assert "链上观察" in page.text
-
- overview = client.get("/api/onchain/overview")
- assert overview.status_code == 200
- assert overview.json()["kpi"]["token_count"] == 1
- assert overview.json()["provider_status"]["providers"][0]["provider"] == "nodereal"
-
- tokens = client.get("/api/onchain/tokens")
- assert tokens.status_code == 200
- 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_event_api_and_overview_counts(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- onchain_db.upsert_token_mapping("ABC", "ethereum", "0xabc", source="manual", confidence=95)
- onchain_db.insert_onchain_raw_event(
- {
- "source": "nodereal",
- "chain": "ethereum",
- "event_type": "evm_transfer",
- "token_address": "0xabc",
- "title": "NodeReal ERC-20 原始转账",
- "amount": 10,
- "total_amount": 80,
- "importance": 80,
- "mapped_symbol": "ABC/USDT",
- "mapping_status": "mapped",
- "detected_at": datetime.now().isoformat(),
- }
- )
- client = TestClient(web_server.app)
-
- overview = client.get("/api/onchain/overview")
- 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.json()["kpi"]["raw_event_count"] == 1
- assert overview.json()["kpi"]["raw_mapped_count"] == 1
- assert events.status_code == 200
- assert events.json()["items"][0]["mapped_symbol"] == "ABC/USDT"
- assert important.status_code == 200
- assert important.json()["total"] == 1
- assert low.status_code == 200
- assert low.json()["total"] == 0
-
-
-def test_overview_ignores_legacy_signals_and_surfaces_mapped_raw_feed(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- onchain_db.insert_onchain_event(
- {
- "chain": "ethereum",
- "symbol": "OLD/USDT",
- "signal_code": "dex_volume_spike",
- "direction": "positive",
- "value_usd": 1000000,
- "confidence": 80,
- "severity": "A",
- "detected_at": datetime.now().isoformat(),
- "source": "dexscreener",
- }
- )
- onchain_db.insert_onchain_raw_event(
- {
- "source": "nodereal",
- "chain": "bsc",
- "event_type": "evm_transfer",
- "token_address": "0xbeam",
- "title": "NodeReal ERC-20 原始转账",
- "amount": 200,
- "total_amount": 200,
- "importance": 82,
- "mapped_symbol": "BEAM/USDT",
- "mapping_status": "mapped",
- "detected_at": datetime.now().isoformat(),
- }
- )
-
- overview = onchain_db.get_onchain_overview(hours=24)
-
- assert overview["kpi"]["positive_events"] == 0
- assert overview["kpi"]["raw_mapped_count"] == 1
- assert overview["kpi"]["mapped_signal_count"] == 1
- assert overview["hot_tokens"][0]["symbol"] == "BEAM/USDT"
- assert overview["signals"] == []
-
-
-def test_token_detail_includes_mapped_raw_events(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- onchain_db.upsert_token_mapping(
- "BEAM/USDT",
- "bsc",
- "0xbeam",
- source="nodereal_erc20_metadata",
- confidence=90,
- raw={"symbol": "BEAM", "name": "Beam", "decimals": 18},
- )
- onchain_db.insert_onchain_raw_event(
- {
- "source": "nodereal",
- "chain": "bsc",
- "event_type": "evm_transfer",
- "token_address": "0xbeam",
- "title": "NodeReal ERC-20 原始转账",
- "amount": 300 * 10**18,
- "total_amount": 300 * 10**18,
- "importance": 78,
- "mapped_symbol": "BEAM/USDT",
- "mapping_status": "mapped",
- "detected_at": datetime.now().isoformat(),
- }
- )
-
- detail = onchain_db.get_onchain_token_detail("BEAM/USDT", hours=24)
-
- assert detail["events"] == []
- assert detail["raw_event_count"] == 1
- assert detail["raw_events"][0]["mapped_symbol"] == "BEAM/USDT"
- assert detail["raw_events"][0]["display_amount_label"] == "300 BEAM"
- assert "数量约 300 BEAM" in detail["raw_events"][0]["human_summary"]
- assert detail["raw_events"][0]["pipeline_note"] == "已映射,可进入后续链上信号分析。"
-
-
-def test_nodereal_events_generate_metrics_and_normalized_event(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
- contract = "0x0000000000000000000000000000000000000abc"
- onchain_db.upsert_token_mapping("ABC", "ethereum", contract, source="manual", confidence=95)
- onchain_db.insert_token_metric(
- {
- "symbol": "ABC/USDT",
- "chain": "ethereum",
- "contract_address": contract,
- "window": "1h",
- "metric_time": datetime.now().isoformat(),
- "dex_volume_usd": 100000,
- "liquidity_usd": 100000,
- "source": "test",
- "raw": {"price_usd": "2"},
- }
- )
-
- class FakeNodeRealClient:
- def supports_chain(self, chain):
- return chain == "ethereum"
-
- def token_holder_count(self, chain, contract):
- assert chain == "ethereum"
- assert contract == "0x0000000000000000000000000000000000000abc"
- return 120
-
- def block_number(self, chain):
- assert chain == "ethereum"
- return 1000
-
- def get_logs(self, chain, log_filter):
- if "address" not in log_filter:
- return []
- assert log_filter["address"] == "0x0000000000000000000000000000000000000abc"
- return [
- {
- "address": "0x0000000000000000000000000000000000000abc",
- "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 len(result["events"]) == 1
- assert len(result["metrics"]) == 1
- events = onchain_db.list_onchain_events(hours=50000)
- assert events["total"] == 1
- assert events["items"][0]["source"] == "nodereal"
- assert events["items"][0]["signal_code"] == "whale_accumulation"
-
-
-def test_nodereal_no_supported_mapping_error_has_diagnostics(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
- monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum,bsc")
- onchain_db.upsert_token_mapping("SOLX", "solana", "Mint111", source="manual", confidence=95)
-
- class EmptyNodeRealClient:
- def supports_chain(self, chain):
- return chain in {"ethereum", "bsc"}
-
- def block_number(self, chain):
- return 100
-
- def get_logs(self, chain, log_filter):
- return []
-
- monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: EmptyNodeRealClient())
-
- result = onchain_monitor.fetch_nodereal_events(limit=10)
-
- assert result["metrics"] == []
- assert result["events"] == []
- assert result["diagnostics"]["mapping_total"] == 1
- assert result["diagnostics"]["chain_mapping_total"] == 0
- assert result["diagnostics"]["mapping_note"] == "no_enabled_chain_mappings_raw_events_only"
- assert result["errors"] == []
-
-
-def test_nodereal_records_raw_events_without_strategy_mappings(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
- monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum")
-
- class RawNodeRealClient:
- def supports_chain(self, chain):
- return chain == "ethereum"
-
- def block_number(self, chain):
- return 1000
-
- def token_holder_count(self, chain, contract):
- return 0
-
- def get_logs(self, chain, log_filter):
- if "address" in log_filter:
- return []
- return [
- {
- "address": "0xabc",
- "transactionHash": "0xrawtx",
- "data": hex(987654321),
- "topics": [
- onchain_monitor.TRANSFER_TOPIC,
- "0x0000000000000000000000001111111111111111111111111111111111111111",
- "0x0000000000000000000000002222222222222222222222222222222222222222",
- ],
- }
- ]
-
- monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: RawNodeRealClient())
-
- result = onchain_monitor.fetch_nodereal_events(limit=10)
-
- assert result["errors"] == []
- assert result["events"] == []
- assert len(result["raw_events"]) == 1
- assert result["diagnostics"]["mapping_note"] == "no_strategy_mappings_raw_events_only"
- raw = onchain_db.list_onchain_raw_events(hours=50000)
- assert raw["total"] == 1
- assert raw["items"][0]["source"] == "nodereal"
- assert raw["items"][0]["mapping_status"] == "unmapped"
- assert raw["items"][0]["event_type"] == "evm_transfer"
-
-
-def test_alchemy_records_raw_events_without_strategy_mappings(monkeypatch, tmp_path):
- monkeypatch.setenv("ALPHAX_ALCHEMY_API_KEY", "test-key")
- monkeypatch.setenv("ALPHAX_ALCHEMY_ENABLED", "1")
- monkeypatch.setenv("ALPHAX_ALCHEMY_CHAINS", "ethereum")
- _temp_db(monkeypatch, tmp_path)
-
- class RawAlchemyClient:
- def supports_chain(self, chain):
- return chain == "ethereum"
-
- def block_number(self, chain):
- return 1000
-
- def get_logs(self, chain, log_filter):
- if "address" in log_filter:
- return []
- return [
- {
- "address": "0xabc",
- "transactionHash": "0xalchemyrawtx",
- "data": hex(123456789),
- "topics": [
- onchain_monitor.TRANSFER_TOPIC,
- "0x0000000000000000000000001111111111111111111111111111111111111111",
- "0x0000000000000000000000002222222222222222222222222222222222222222",
- ],
- }
- ]
-
- monkeypatch.setattr(onchain_monitor, "_alchemy_client", lambda cfg=None: RawAlchemyClient())
-
- result = onchain_monitor.fetch_alchemy_events(limit=10)
-
- assert result["errors"] == []
- assert result["events"] == []
- assert len(result["raw_events"]) == 1
- raw = onchain_db.list_onchain_raw_events(hours=50000)
- assert raw["total"] == 1
- assert raw["items"][0]["source"] == "alchemy"
- assert raw["items"][0]["mapping_status"] == "unmapped"
- assert raw["items"][0]["event_type"] == "evm_transfer"
-
-
-def test_nodereal_auto_maps_raw_event_from_erc20_metadata(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
- monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum")
-
- def abi_string(value):
- body = value.encode()
- padded = body + (b"\x00" * ((32 - len(body) % 32) % 32))
- return "0x" + (32).to_bytes(32, "big").hex() + len(body).to_bytes(32, "big").hex() + padded.hex()
-
- class MetadataNodeRealClient:
- def supports_chain(self, chain):
- return chain == "ethereum"
-
- def block_number(self, chain):
- return 1000
-
- def token_holder_count(self, chain, contract):
- return 0
-
- def get_logs(self, chain, log_filter):
- if "address" in log_filter:
- return []
- return [
- {
- "address": "0xstorj",
- "transactionHash": "0xrawtx",
- "data": hex(1000 * 10**8),
- "topics": [
- onchain_monitor.TRANSFER_TOPIC,
- "0x0000000000000000000000001111111111111111111111111111111111111111",
- "0x0000000000000000000000002222222222222222222222222222222222222222",
- ],
- }
- ]
-
- def eth_call(self, chain, to_address, data, block="latest"):
- if data == onchain_monitor.ERC20_SYMBOL_SELECTOR:
- return abi_string("STORJ")
- if data == onchain_monitor.ERC20_NAME_SELECTOR:
- return abi_string("Storj")
- if data == onchain_monitor.ERC20_DECIMALS_SELECTOR:
- return hex(8)
- return "0x"
-
- monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: MetadataNodeRealClient())
-
- result = onchain_monitor.fetch_nodereal_events(limit=10)
-
- assert result["errors"] == []
- assert len(result["raw_events"]) == 1
- raw = onchain_db.list_onchain_raw_events(hours=50000)
- assert raw["items"][0]["mapping_status"] == "mapped"
- assert raw["items"][0]["mapped_symbol"] == "STORJ/USDT"
- mappings = onchain_db.get_token_mappings("STORJ/USDT")
- assert len(mappings) == 1
- assert mappings[0]["source"] == "nodereal_erc20_metadata"
-
-
-def test_nodereal_seeds_configured_token_mappings_from_env(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
- monkeypatch.setenv(
- "ALPHAX_ONCHAIN_TOKEN_MAPPINGS",
- '[{"symbol":"ENVX/USDT","chain":"ethereum","contract_address":"0xabc","confidence":96}]',
- )
-
- class EmptyNodeRealClient:
- def supports_chain(self, chain):
- return chain == "ethereum"
-
- def token_holder_count(self, chain, contract):
- return 0
-
- def block_number(self, chain):
- return 100
-
- def get_logs(self, chain, log_filter):
- if "address" not in log_filter:
- return []
- return []
-
- monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: EmptyNodeRealClient())
-
- result = onchain_monitor.fetch_nodereal_events(limit=10)
-
- assert result["errors"] == []
- assert result["diagnostics"]["seeded_mappings"] == 1
- mappings = onchain_db.get_token_mappings("ENVX/USDT")
- assert len(mappings) == 1
- assert mappings[0]["contract_address"] == "0xabc"
-
-
-def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path):
- _temp_db(monkeypatch, tmp_path)
- sched_path = tmp_path / "scheduler_state.db"
- monkeypatch.setattr(scheduler_db, "SCHEDULER_DB_PATH", str(sched_path))
- scheduler_db.init_scheduler_tables()
-
- jobs = {item["job_name"]: item for item in scheduler_db.get_job_configs()}
-
- assert jobs["onchain"]["command"] == "onchain"
- assert jobs["onchain"]["lock_group"] == "onchain_write"
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 4250d97..9a0f7c2 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -58,12 +58,12 @@ def test_operations_dashboard_read_model_shape(pg_conn):
assert isinstance(data["trading"], dict)
-def test_operations_dashboard_sanitizes_provider_errors():
+def test_operations_dashboard_sanitizes_external_provider_errors():
summary = _display_error_summary(
- "ethereum:nodereal_raw_logs:HTTPSConnectionPool(host='eth-mainnet.nodereal.io', port=443): "
+ "market:HTTPSConnectionPool(host='api.binance.com', port=443): "
"Max retries exceeded with url: /v1/secret (Caused by NameResolutionError())"
)
- assert summary == "NodeReal 链上数据源连接异常"
+ assert summary == "外部服务连接异常"
assert "HTTPSConnectionPool" not in summary
assert "/v1/" not in summary
diff --git a/tests/test_runtime_config.py b/tests/test_runtime_config.py
index f7c38e6..2e357a5 100644
--- a/tests/test_runtime_config.py
+++ b/tests/test_runtime_config.py
@@ -9,7 +9,6 @@ from app.db.paper_trading import get_paper_trading_summary
from app.db.runtime_config_db import delete_config, get_event_sources, set_config, set_event_driven_config, set_event_sources, set_strategy_meta
from app.integrations import feishu_push
from app.services.llm_insights import get_llm_module_enabled, get_llm_params
-from app.services.onchain_monitor import get_onchain_params
from app.web import web_server
import docker.scheduler as scheduler
@@ -179,7 +178,7 @@ def test_runtime_config_api_can_manage_system_config():
def test_runtime_config_api_seeds_all_system_defaults_when_listing():
- for key in ["llm", "onchain", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
+ for key in ["llm", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
delete_config("system", key)
client = TestClient(web_server.app)
@@ -187,7 +186,7 @@ def test_runtime_config_api_seeds_all_system_defaults_when_listing():
assert resp.status_code == 200
keys = {x["config_key"] for x in resp.json()["items"]}
- for key in ["llm", "onchain", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
+ for key in ["llm", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
assert key in keys
@@ -214,39 +213,6 @@ def test_llm_system_config_overrides_env_defaults(monkeypatch):
assert get_llm_module_enabled("review") is True
-def test_onchain_system_config_overrides_env(monkeypatch):
- monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "1")
- monkeypatch.setenv("ALPHAX_ONCHAIN_PROVIDER", "nodereal,alchemy")
- monkeypatch.setenv("TEST_NODEREAL_KEY", "nodereal-secret")
- monkeypatch.setenv("TEST_ALCHEMY_KEY", "alchemy-secret")
- set_config("system", "onchain", {
- "enabled": True,
- "provider": "alchemy",
- "chains": ["ethereum", "bsc"],
- "timeout": 9,
- "candidate_min_score": 88,
- "nodereal_api_key_env": "TEST_NODEREAL_KEY",
- "nodereal_raw_max_logs_per_chain": 12,
- "alchemy_enabled": True,
- "alchemy_chains": ["ethereum"],
- "alchemy_api_key_env": "TEST_ALCHEMY_KEY",
- "alchemy_raw_max_logs_per_chain": 9,
- })
-
- params = get_onchain_params()
-
- assert params["enabled"] is True
- assert params["chains"] == ["ethereum", "bsc"]
- assert params["timeout"] == 9
- assert params["candidate_min_score"] == 88
- assert params["nodereal_api_key"] == "nodereal-secret"
- assert params["nodereal_raw_max_logs_per_chain"] == 12
- assert params["provider"] == "nodereal,alchemy"
- assert params["alchemy_api_key"] == "alchemy-secret"
- assert params["alchemy_chains"] == ["ethereum"]
- assert params["alchemy_raw_max_logs_per_chain"] == 9
-
-
def test_paper_trading_system_config_controls_account_model(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "999")
set_config("system", "paper_trading", {
@@ -261,9 +227,9 @@ def test_paper_trading_system_config_controls_account_model(monkeypatch):
summary = get_paper_trading_summary(days=30)
assert summary["account_equity_usdt"] == 30000
- assert summary["notional_usdt"] == 6000
+ assert summary["notional_usdt"] == 999
assert summary["leverage"] == 3
- assert summary["margin_usdt"] == 2000
+ assert summary["margin_usdt"] == 333
def test_notification_system_config_controls_feishu_webhook(monkeypatch):
diff --git a/tests/test_scheduler_control.py b/tests/test_scheduler_control.py
index ced8bd9..45b9b0c 100644
--- a/tests/test_scheduler_control.py
+++ b/tests/test_scheduler_control.py
@@ -29,7 +29,7 @@ def test_scheduler_tables_seed_defaults(monkeypatch, tmp_path):
assert jobs["confirm"]["lock_group"] == "recommendation_write"
assert jobs["tracker"]["every_seconds"] == 180
assert jobs["paper-trader"]["lock_group"] == "paper_trading_write"
- assert jobs["onchain"]["lock_group"] == "onchain_write"
+ assert "onchain" not in jobs
def test_scheduler_control_api_and_page(monkeypatch, tmp_path):