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+'

'+(compactSignals||'暂无明确信号')+onchainChip+orderChip+'
查看详情
'; + 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+'

'+(compactSignals||'暂无明确信号')+orderChip+'
查看详情
'; } 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; } 机会中心 市场总览 消息面 - 链上观察 AI 助手 订阅 邀请 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 '
'+esc(m.text)+'
';}return '
AI
'+renderAnswer(m)+'
';} 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)}
-
-
NodeReal--
-
- - - 打开链上异动页 -
-
时间任务运行结果耗时摘要错误
加载中...
-
-
调度--
- + 打开调度中心 @@ -130,7 +119,7 @@ tr:hover td{background:var(--surface)}
问答--
- + 打开原问答日志 @@ -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)+' 条'} (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 摘要作为辅助证据。

- -
-
-
链上观察不是买入指令。正向资金流会进入机会检查;交易所流入、流动性撤出、持仓集中等负向信号只作为风险提醒。
-
- - - -
-
-
加载中...
-
-
加载监控状态...
-
-
-
-
-
-
重要链上事件
先看发生了什么,再决定是否值得跟踪
-
-
- - - - - -
-
--
-
-
加载中...
-
-
-
-
-
-
正向资金线索
已关联到交易所币种
加载中...
-
风险线索
仅作为风险提醒
加载中...
-
-
-
单币档案
选择币种
-
选择一个币,查看它最近的链上事件和机会状态。
-
-
-
-
-
相关币种列表
按币种筛选、翻页和进入单币档案
-
- - -
-
币种重要性风险分映射事件最近事件数据源策略状态
加载中...
-
--
-
-
-
-
-{% 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?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
加载 K 线...
决策与策略剧本
'+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}})+'
';$('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?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
加载 K 线...
决策与策略剧本
'+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}})+'
';$('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 被策略交易开仓才进入收益账本。
-
证据归因链上、舆情、技术因子只做贡献分析。
+
证据归因舆情、技术因子只做贡献分析。
因子归因
信号 -> 转化
加载中...
市场环境归因
环境 -> 转化
加载中...
-
证据源归因
链上 / 舆情
加载中...
+
证据源归因
舆情 / AI
加载中...
版本归因
版本 -> 转化
加载中...
交易级因子归因
只统计已平仓策略交易,用真实账本收益评价因子、入场路径、退出原因和环境。
加载中...
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):