This commit is contained in:
aaron 2026-06-07 20:58:35 +08:00
parent dcd8ee7b45
commit 66f3231489
51 changed files with 85 additions and 4042 deletions

View File

@ -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

View File

@ -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 化运行目录。运行时数据库已经切换为 PostgreSQLSQLite 只作为历史数据导入来源,不再作为应用运行时数据库。后续开发和排查都应以 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 APIAlchemy 通过 `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
```

View File

@ -7,8 +7,7 @@
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190`
- 运行时数据库是 PostgreSQLcompose 内置 `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 解释层配置

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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 强势榜异动属于“发现层”信号,不能因为低分或旧背景被直接打成纯拒绝。

View File

@ -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)

View File

@ -300,7 +300,7 @@ def bootstrap_chat(user_id: int) -> dict:
"分析 BTC/USDT 现在的技术面",
"解释当前看板里这条推荐为什么是等回踩",
"看一下市场总览,今天是偏强还是偏弱",
"这个币的链上异动有哪些",
"这个币的舆情和技术面有没有共振",
"帮我复盘最近一次纸面交易",
]
return {

View File

@ -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"),
}

File diff suppressed because it is too large Load Diff

View File

@ -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:

View File

@ -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),
},
}

View File

@ -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,

View File

@ -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",

View File

@ -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": "复盘",

View File

@ -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)

View File

@ -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

View File

@ -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"],

View File

@ -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_styletechnical/decision/market/news/onchain/review/notice/help/default。",
"根据 intent 选择 answer_styletechnical/decision/market/news/review/notice/help/default。",
"输出严格 JSONsummary, answer, answer_style, evidence[], risk_flags[], related_records[], followups[]。",
],
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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,
},
}

View File

@ -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,
)

View File

@ -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)

View File

@ -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)

View File

@ -35,7 +35,7 @@
- `prerequisite`:先决条件。决定能不能看,例如交易量、交易宇宙、市场环境、是否异常币。不能单独产生交易信号。
- `trigger`触发条件。让策略产生候选例如箱体突破回踩、1H 量价齐飞、短周期启动。
- `confirmation`:确认条件。提高交易可信度,例如板块共振、链上正向、大户偏多、1H 未衰竭。
- `confirmation`:确认条件。提高交易可信度,例如板块共振、舆情共振、大户偏多、1H 未衰竭。
- `entry`:入场条件。决定现在能不能买,例如 15m 承接、离箱体上沿距离、盈亏比。
- `risk`:风控条件。一票否决或降级,例如 risk_off、假突破、止损过宽、账户回撤过大。
- `attribution`:归因条件。用于复盘解释,不一定参与实时决策。
@ -225,7 +225,7 @@
### 策略交易页
- 持仓、挂单、已完成、日志增加 `策略` 列。
- 策略筛选器:全部 / 箱体突破回踩 / 量价加速 / 短周期观察 / 链上确认。
- 策略筛选器:全部 / 箱体突破回踩 / 量价加速 / 短周期观察 / 舆情确认。
- 交易详情展示当时的 `strategy_snapshot`
### 机会总览页

View File

@ -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` 等长期策略运营表。

View File

@ -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 的页面入口按“普通用户能直接理解”和“管理员/研发
- 先给结论,再给证据。
- 首页和普通用户页面只显示最重要状态,不展示完整工程流水。
- 收益只来自策略交易账本,不把观察样本当收益。
- 链上和舆情是机会发现与风险上下文,不直接表达买入指令。
- 舆情是机会发现与风险上下文,不直接表达买入指令。
- 管理员页面可以保留工程细节,但需要聚合入口,避免侧边栏变成功能清单。

View File

@ -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:

View File

@ -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 '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
aiInsightHtml = '<details class="ai-insight"><summary><span>AI 解读</span><span class="ai-tag">缓存</span></summary><div class="ai-body"><div class="ai-summary">'+cleanDisplayText(aiInsight.summary || aiInsight.why_now_or_not || '暂无摘要')+'</div><div class="ai-grid"><div class="ai-item"><div class="ai-label">为什么现在 / 为什么不现在</div><div class="ai-text">'+cleanDisplayText(aiInsight.why_now_or_not || '--')+'</div></div><div class="ai-item"><div class="ai-label">关键证据</div><div class="ai-list">'+(evidenceHtml || '<span class="ai-pill">--</span>')+'</div></div><div class="ai-item"><div class="ai-label">风险提示</div><div class="ai-list">'+(riskHtml || '<span class="ai-pill">--</span>')+'</div></div><div class="ai-item"><div class="ai-label">观察点</div><div class="ai-list">'+(watchHtml || '<span class="ai-pill">--</span>')+'</div></div></div><div class="ai-item"><div class="ai-label">失效条件</div><div class="ai-list">'+(invalidHtml || '<span class="ai-pill">--</span>')+'</div></div></div></details>';
}
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 = '<div class="onchain-brief '+(ocRisk?'risk':'')+'"><div class="onchain-head"><span>'+ocTitle+'</span><span class="onchain-score">'+ocScore+'</span></div><div class="onchain-meta">'+escHtml(ocMeta)+'</div></div>';
}
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 '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join('');
var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : '';
var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : '';
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score">'+sideBadgeHtml(r)+'<span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?(side === 'short'?'计划反抽':'计划回踩'):(side === 'short'?'计划开空':'计划入场'))+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score">'+sideBadgeHtml(r)+'<span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?(side === 'short'?'计划反抽':'计划回踩'):(side === 'short'?'计划开空':'计划入场'))+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
} catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r);

View File

@ -242,7 +242,6 @@ a { color: inherit; text-decoration: none; }
<symbol id="svg-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/><path d="M8 9h8"/><path d="M8 13h5"/></symbol>
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
<symbol id="svg-onchain" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="7" r="3"/><circle cx="18" cy="7" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.6 8.8 10.7 15"/><path d="M15.4 8.8 13.3 15"/><path d="M9 7h6"/></symbol>
<symbol id="svg-paper" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19V5"/><path d="M4 19h16"/><path d="M7 15l3-4 3 2 5-7"/><path d="M17 6h1v1"/></symbol>
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
<symbol id="svg-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M5.3 20h13.4c1.1 0 2-.9 2-2 0-3.3-2.7-6-6-6H9.3c-3.3 0-6 2.7-6 6 0 1.1.9 2 2 2z"/></symbol>
@ -263,7 +262,6 @@ a { color: inherit; text-decoration: none; }
<a class="sidebar-link {% if active_nav | default('app') == 'app' %}active{% endif %}" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>机会中心</a>
<a class="sidebar-link {% if active_nav == 'market' %}active{% endif %}" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
<a class="sidebar-link {% if active_nav == 'sentiment' %}active{% endif %}" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>消息面</a>
<a class="sidebar-link {% if active_nav == 'onchain' %}active{% endif %}" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上观察</a>
<a class="sidebar-link {% if active_nav == 'chat' %}active{% endif %}" href="/chat"><svg class="link-icon"><use href="#svg-chat"/></svg>AI 助手</a>
<a class="sidebar-link {% if active_nav == 'subscription' %}active{% endif %}" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>邀请</a>

View File

@ -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='<div class="empty">暂无对话</div>';return;}sessionList.innerHTML=state.sessions.map(function(s){var active=s.id===state.sessionId?' active':'';return '<div class="session-row'+active+'" onclick="loadSession('+s.id+')"><b>'+esc(s.title||'新对话')+'</b><span>'+esc(short(s.last_message_text||s.summary||'还没有消息',88))+'</span></div>';}).join('');}
function renderEmpty(){messages.innerHTML='<div class="empty"><h2>问 AlphaX 一个 Crypto 问题</h2><p>直接输入你的问题即可,支持单币技术面、推荐解释、链上异动、舆情影响和复盘结果。</p></div>';}
function renderEmpty(){messages.innerHTML='<div class="empty"><h2>问 AlphaX 一个 Crypto 问题</h2><p>直接输入你的问题即可,支持单币技术面、推荐解释、舆情影响和复盘结果。</p></div>';}
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 '<div class="msg user"><div class="avatar"></div><div class="bubble"><div class="bubble-text">'+esc(m.text)+'</div></div></div>';}return '<div class="msg"><div class="avatar ai">AI</div><div class="bubble">'+renderAnswer(m)+'</div></div>';}
function renderProgress(lines){if(!lines||!lines.length)return'';return '<div class="progress-box">'+lines.map(function(line,idx){return '<div class="progress-row"><span>'+esc(line)+'</span>'+(idx===0?'<span class="progress-dots"><i></i><i></i><i></i></span>':'')+'</div>';}).join('')+'</div>';}
function renderEvidenceList(items){if(!items||!items.length)return '<div class="ev">暂无明确证据,已降级为空态回答。</div>';return items.slice(0,8).map(function(x){if(typeof x==='string')return '<div class="ev">'+esc(x)+'</div>';if(Array.isArray(x))return '<div class="ev">'+esc(x.map(function(v){return typeof v==='string'?v:JSON.stringify(v);}).join(' · '))+'</div>';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 '<div class="ev">'+esc(text||JSON.stringify(x))+'</div>';}return '<div class="ev">'+esc(String(x))+'</div>';}).join('');}
function renderRecords(items){if(!items||!items.length)return '<span class="record">无直接记录</span>';return items.slice(0,8).map(function(r){if(typeof r==='string')return '<span class="record">'+esc(r)+'</span>';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 '<span class="record">'+esc(text||'记录')+'</span>';}return '<span class="record">'+esc(String(r))+'</span>';}).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 '<div class="tf-grid">'+order.map(function(tf){var x=tfs[tf]||{};if(!x.available)return '<div class="tf"><span>'+tf+'</span><b>无数据</b><small>'+esc(x.reason||'Binance 未返回')+'</small></div>';var trend={uptrend:'上行',rebound:'反弹',weak:'偏弱',downtrend:'下行',sideways:'震荡'}[x.trend]||x.trend||'--';var sub='RSI '+(x.rsi14||'--')+' · 量 '+(x.volume_ratio_20||0)+'x';return '<div class="tf"><span>'+tf+'</span><b>'+esc(trend)+' · $'+fmtNum(x.price)+'</b><small>'+esc(sub)+'</small></div>';}).join('')+'</div>';}
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&&current[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 '<di
function renderEvidenceSection(title,items,empty){return '<div class="mini-section"><h3>'+esc(title)+'</h3><div class="ev-list">'+(items&&items.length?renderEvidenceList(items):'<div class="ev">'+esc(empty||'暂无明确数据。')+'</div>')+'</div></div>';}
function renderRecordSection(title,items){if(!items||!items.length)return'';return '<div class="mini-section"><h3>'+esc(title)+'</h3><div class="record-list">'+renderRecords(items)+'</div></div>';}
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 '<div class="brief-grid">'+items.map(function(x){return '<div class="brief"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>';}).join('')+'</div>';}
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='<div class="answer-head"><b>'+esc(c.summary||'研究结论')+'</b><span class="tag">'+esc(tag)+'</span></div>';var head=headOnly+renderPlainTextAnswer(answer);if(style==='notice'||style==='help'||m.intent==='error')return headOnly+renderPlainTextAnswer(answer)+'<div class="notice-box">'+esc(answer)+'</div>';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='<div class="answer-head"><b>'+esc(c.summary||'研究结论')+'</b><span class="tag">'+esc(tag)+'</span></div>';var head=headOnly+renderPlainTextAnswer(answer);if(style==='notice'||style==='help'||m.intent==='error')return headOnly+renderPlainTextAnswer(answer)+'<div class="notice-box">'+esc(answer)+'</div>';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='<div class="empty">聊天助手加载失败</div>';}}
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){
+ '<div class="progress-box">'
+ '<div class="progress-row"><span>读取 AlphaX 当前数据</span><span class="progress-dots"><i></i><i></i><i></i></span></div>'
+ '<div class="progress-row"><span>汇总多周期行情与结构</span></div>'
+ '<div class="progress-row"><span>结合推荐、舆情、链上与复盘</span></div>'
+ '<div class="progress-row"><span>结合推荐、舆情与复盘</span></div>'
+ '</div>'
+ '<div class="notice-box">稍等一下,我正在把不同数据源拼起来,随后会给你结论和依据。</div>';
}

View File

@ -72,7 +72,6 @@ tr:hover td{background:var(--surface)}
<option value="market_overview">市场问答</option>
<option value="recommendation_explain">推荐解释</option>
<option value="sentiment">舆情解读</option>
<option value="onchain">链上异动</option>
<option value="review">复盘查询</option>
<option value="restricted">受限内容</option>
</select>

View File

@ -23,7 +23,7 @@
<button class="btn" onclick="loadConfigs()">刷新</button>
</div>
</div>
<div class="hint">建议新闻源、LLM、链上、调度、策略交易属于系统配置;复盘 meta、learned_rules、策略覆盖属于策略运行时配置。</div>
<div class="hint">建议新闻源、LLM、调度、策略交易属于系统配置复盘 meta、learned_rules、策略覆盖属于策略运行时配置。</div>
<div class="layout">
<section class="panel">
<div class="panel-head"><div class="panel-title">配置列表</div><div class="panel-note" id="countNote">--</div></div>

View File

@ -36,7 +36,7 @@
<div class="item"><b>推荐链路</b><span>recommendation / screening_log / cron_run_log</span></div>
<div class="item"><b>策略交易</b><span>挂单 / 持仓 / 交易事件</span></div>
<div class="item"><b>复盘迭代</b><span>review / missed / strategy rules</span></div>
<div class="item"><b>证据源</b><span>舆情 / 链上 / AI 记录</span></div>
<div class="item"><b>证据源</b><span>舆情 / AI 记录</span></div>
<div class="item"><b>运行配置</b><span>策略与系统配置快照</span></div>
</div>
</aside>

View File

@ -59,7 +59,6 @@ tr:hover td{background:var(--surface)}
</div>
<div class="tabs" id="tabs">
<button class="tab-btn active" data-tab="system" onclick="switchTab('system')">系统错误</button>
<button class="tab-btn" data-tab="onchain" onclick="switchTab('onchain')">链上运行</button>
<button class="tab-btn" data-tab="cron" onclick="switchTab('cron')">调度运行</button>
<button class="tab-btn" data-tab="pipeline" onclick="switchTab('pipeline')">链路批次</button>
<button class="tab-btn" data-tab="chat" onclick="switchTab('chat')">问答日志</button>
@ -94,20 +93,10 @@ tr:hover td{background:var(--surface)}
</div>
</section>
<section class="panel" id="panel-onchain">
<div class="mini-grid" id="onchainMini"><div class="mini"><span>NodeReal</span><b>--</b></div></div>
<div class="toolbar">
<select id="onchainHours" onchange="loadOnchain()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
<button onclick="loadOnchain()">刷新</button>
<a class="badge" href="/onchain">打开链上异动页</a>
</div>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>任务</th><th>运行</th><th>结果</th><th>耗时</th><th>摘要</th><th>错误</th></tr></thead><tbody id="onchainRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
</section>
<section class="panel" id="panel-cron">
<div class="mini-grid" id="cronMini"><div class="mini"><span>调度</span><b>--</b></div></div>
<div class="toolbar">
<select id="cronJob" onchange="loadCron()"><option value="">全部任务</option><option value="链上">链上</option><option value="粗筛">粗筛</option><option value="爆发确认">爆发确认</option><option value="price-streamer">Price Streamer</option><option value="策略交易">策略交易</option></select>
<select id="cronJob" onchange="loadCron()"><option value="">全部任务</option><option value="粗筛">粗筛</option><option value="爆发确认">爆发确认</option><option value="price-streamer">Price Streamer</option><option value="策略交易">策略交易</option></select>
<select id="cronHours" onchange="loadCron()"><option value="24">近 24h</option><option value="168">近 7 天</option></select>
<button onclick="loadCron()">刷新</button>
<a class="badge" href="/cron">打开调度中心</a>
@ -130,7 +119,7 @@ tr:hover td{background:var(--surface)}
<div class="mini-grid" id="chatMini"><div class="mini"><span>问答</span><b>--</b></div></div>
<div class="toolbar">
<input id="chatSearch" placeholder="搜索问题、回答..." onkeydown="if(event.key==='Enter')loadChat(0)">
<select id="chatIntent" onchange="loadChat(0)"><option value="all">全部意图</option><option value="coin_analysis">币种分析</option><option value="onchain">链上异动</option><option value="sentiment">舆情</option><option value="review">复盘</option></select>
<select id="chatIntent" onchange="loadChat(0)"><option value="all">全部意图</option><option value="coin_analysis">币种分析</option><option value="sentiment">舆情</option><option value="review">复盘</option></select>
<select id="chatHours" onchange="loadChat(0)"><option value="24">近 24h</option><option value="168">近 7 天</option></select>
<button onclick="loadChat(0)">查询</button>
<a class="badge" href="/chat-logs">打开原问答日志</a>
@ -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 '<span class="badge '+cls+'">'+esc(s||'--')+'</span>'}
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 '<span class="badge '+cls+'">'+esc(s||'--')+'</span>'}
function summary(x){try{if(typeof x==='string')x=JSON.parse(x||'{}')}catch(e){};return Object.keys(x||{}).slice(0,6).map(function(k){return k+': '+x[k]}).join(' · ')||'--'}
function switchTab(tab){state.tab=tab;document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.toggle('active',b.dataset.tab===tab)});document.querySelectorAll('section.panel').forEach(function(p){p.classList.toggle('active',p.id==='panel-'+tab)});if(tab==='system')loadSystem(state.sysOffset||0);if(tab==='onchain')loadOnchain();if(tab==='cron')loadCron();if(tab==='pipeline')loadPipeline(state.pipeOffset||0);if(tab==='chat')loadChat(state.chatOffset||0)}
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 '<div class="ops-card"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><small>'+esc(x[2]||'')+'</small></div>'}).join('')}
async function loadOps(){try{var s=await(await fetch('/api/admin/system-errors/stats?hours=24')).json();var c=await(await fetch('/api/admin/cron-runs/summary?hours=24')).json();var oc=await(await fetch('/api/onchain/provider-status?hours=24')).json();renderOps([['系统错误',s.total||0,'近 24 小时'],['调度成功率',(c.overall||{}).success_rate+'%','运行 '+((c.overall||{}).total_runs||0)+' 次'],['链上任务',(oc.last_run||{}).result_status||'--',(oc.last_error||'NodeReal 状态正常')],['链上信号',(oc.coverage||{}).signals||0,'当前窗口标准事件']])}catch(e){renderOps([['状态','加载失败','请检查接口权限']])}}
async function 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='<tr><td colspan="6" class="loading">加载中...</td></tr>';try{var d=await(await fetch('/api/admin/system-errors?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+PAGE+'&level='+level+'&source='+source+'&hours='+h)).json();state.sysTotal=d.total||0;sysRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr onclick="loadSystemDetail('+x.id+')" style="cursor:pointer"><td>'+time(x.created_at)+'</td><td>'+badge(x.source||'app')+'</td><td>'+esc(x.error_type||'Error')+'</td><td class="msg">'+esc(short(x.message,140))+'</td><td class="msg">'+esc(short((x.request_path||'--')+(x.user_email?' · '+x.user_email:''),80))+'</td><td>'+badge(x.level||x.status_code)+'</td></tr>'}).join(''):'<tr><td colspan="6" class="empty">暂无系统错误</td></tr>';pager('sysPager',offset,state.sysTotal,'loadSystem')}catch(e){sysRows.innerHTML='<tr><td colspan="6" class="empty">加载失败</td></tr>'}}
async function loadSystemDetail(id){sysDetail.innerHTML='<div class="loading">加载详情...</div>';try{var d=await(await fetch('/api/admin/system-errors/'+id)).json();sysDetail.innerHTML='<h3>#'+esc(d.id)+' · '+esc(d.error_type||'Error')+'</h3><div class="meta"><span>时间</span><span>'+time(d.created_at)+'</span><span>来源</span><span>'+esc(d.source||'app')+' · PID '+esc(d.pid||0)+'</span><span>路径</span><span>'+esc((d.request_method||'')+' '+(d.request_path||'--'))+'</span><span>用户</span><span>'+esc(d.user_email||'--')+'</span><span>指纹</span><span>'+esc(d.fingerprint||'--')+'</span><span>消息</span><span>'+esc(d.message||'--')+'</span></div><div class="codebox">'+esc(d.stack_trace||'无堆栈信息')+'</div>'}catch(e){sysDetail.innerHTML='<div class="empty">详情加载失败</div>'}}
async function loadOnchain(){loadOps();onchainRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var h=onchainHours.value;var st=await(await fetch('/api/onchain/provider-status?hours='+h)).json();var rows=await(await fetch('/api/admin/cron-runs?job_name='+encodeURIComponent('链上')+'&limit=80')).json();var p=(st.providers||[])[0]||{};onchainMini.innerHTML=[['NodeReal',p.status||'--',p.api_key_present?'Key 已配置':'无 Key'],['标准信号',(st.coverage||{}).signals||0,'近 '+h+'h'],['Metrics',(st.coverage||{}).metrics||0,'链上指标'],['最近结果',(st.last_run||{}).result_status||'--',st.last_error||'无错误'],['映射合约',(st.coverage||{}).usable_mappings||0,'可采集合约']].map(mini).join('');renderCronRows('onchainRows',rows.items||[])}catch(e){onchainRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
async function loadCron(){loadOps();cronRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var s=await(await fetch('/api/admin/cron-runs/summary?hours='+cronHours.value)).json();cronMini.innerHTML=[['总运行',(s.overall||{}).total_runs||0,'近 '+cronHours.value+'h'],['成功率',(s.overall||{}).success_rate+'%','调度稳定性'],['失败',(s.overall||{}).error_runs||0,'异常任务'],['平均耗时',dur((s.overall||{}).avg_duration_ms),'单次任务'],['任务数',(s.job_stats||[]).length,'已配置任务']].map(mini).join('');var d=await(await fetch('/api/admin/cron-runs?job_name='+encodeURIComponent(cronJob.value)+'&limit=100')).json();renderCronRows('cronRows',d.items||[])}catch(e){cronRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
function renderCronRows(id,items){var el=document.getElementById(id);el.innerHTML=items.length?items.map(function(x){return '<tr><td>'+time(x.started_at)+'</td><td>'+esc(x.job_name||'--')+'</td><td>'+badge(x.run_status)+'</td><td>'+badge(x.result_status)+'</td><td>'+dur(x.duration_ms)+'</td><td class="msg">'+esc(summary(x.summary_json))+'</td><td class="msg">'+esc(short(x.error_message||'',180))+'</td></tr>'}).join(''):'<tr><td colspan="7" class="empty">暂无运行日志</td></tr>'}
async function loadPipeline(offset){state.pipeOffset=offset;loadOps();pipeRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var d=await(await fetch('/api/admin/pipeline-runs?hours='+pipeHours.value+'&limit=30&offset='+offset)).json();var k=d.kpi||{};pipeMini.innerHTML=[['批次数',k.run_count||0,'粗筛批次'],['宇宙过滤',k.universe_gate_count||0,'候选入口'],['质量通过',k.quality_pass_count||0,'过滤后样本'],['交易确认',k.trade_confirm_count||0,'确认机会'],['推荐转化',(k.recommendation_rate||0)+'%','推荐/合格']].map(mini).join('');var p=d.pagination||{};state.pipeTotal=p.total_count||0;pipeRows.innerHTML=(d.runs||[]).length?(d.runs||[]).map(function(x){return '<tr><td class="mono">#'+esc(x.run_id||x.id)+'</td><td>'+time(x.started_at)+'</td><td>'+esc((x.universe_gate_count||0)+' / '+(x.discovery_count||0)+' / '+(x.quality_pass_count||0))+'</td><td>'+esc(x.recommendations||0)+'</td><td>'+esc((x.recommendation_rate||0)+'%')+'</td><td>'+esc((x.perf_success||0)+' / '+(x.perf_failed||0)+' / '+(x.missed_count||0))+'</td><td>'+badge(x.result_status||x.run_status)+'</td></tr>'}).join(''):'<tr><td colspan="7" class="empty">暂无链路批次</td></tr>';pager('pipePager',offset,state.pipeTotal,'loadPipeline',30)}catch(e){pipeRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
async function loadChat(offset){state.chatOffset=offset;loadOps();chatRows.innerHTML='<tr><td colspan="5" class="loading">加载中...</td></tr>';try{var ov=await(await fetch('/api/admin/chat-logs/overview?hours='+chatHours.value)).json();chatMini.innerHTML=[['提问数',ov.total_questions||0,'近 '+chatHours.value+'h'],['会话数',ov.total_sessions||0,'涉及 '+(ov.total_users||0)+' 位用户'],['链上问题',((ov.top_intents||[]).find(function(x){return x.intent==='onchain'})||{}).n||0,'onchain intent'],['消息数',ov.total_messages||0,'用户与助手消息'],['热门意图',((ov.top_intents||[])[0]||{}).intent||'--','当前最常见']].map(mini).join('');var d=await(await fetch('/api/admin/chat-logs?search='+encodeURIComponent(chatSearch.value.trim())+'&intent='+encodeURIComponent(chatIntent.value)+'&hours='+chatHours.value+'&offset='+offset+'&limit='+PAGE)).json();state.chatTotal=d.total||0;chatRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr><td>'+time(x.created_at)+'</td><td>'+esc(x.user_email||'--')+'</td><td>'+badge(x.intent||'--')+'</td><td class="msg">'+esc(short(x.content_text||'',170))+'</td><td class="msg">'+esc(short((x.symbol?x.symbol+' · ':'')+(x.session_title||('会话 #'+x.session_id)),120))+'</td></tr>'}).join(''):'<tr><td colspan="5" class="empty">暂无问答日志</td></tr>';pager('chatPager',offset,state.chatTotal,'loadChat')}catch(e){chatRows.innerHTML='<tr><td colspan="5" class="empty">加载失败</td></tr>'}}
async function loadChat(offset){state.chatOffset=offset;loadOps();chatRows.innerHTML='<tr><td colspan="5" class="loading">加载中...</td></tr>';try{var ov=await(await fetch('/api/admin/chat-logs/overview?hours='+chatHours.value)).json();chatMini.innerHTML=[['提问数',ov.total_questions||0,'近 '+chatHours.value+'h'],['会话数',ov.total_sessions||0,'涉及 '+(ov.total_users||0)+' 位用户'],['消息数',ov.total_messages||0,'用户与助手消息'],['热门意图',((ov.top_intents||[])[0]||{}).intent||'--','当前最常见']].map(mini).join('');var d=await(await fetch('/api/admin/chat-logs?search='+encodeURIComponent(chatSearch.value.trim())+'&intent='+encodeURIComponent(chatIntent.value)+'&hours='+chatHours.value+'&offset='+offset+'&limit='+PAGE)).json();state.chatTotal=d.total||0;chatRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr><td>'+time(x.created_at)+'</td><td>'+esc(x.user_email||'--')+'</td><td>'+badge(x.intent||'--')+'</td><td class="msg">'+esc(short(x.content_text||'',170))+'</td><td class="msg">'+esc(short((x.symbol?x.symbol+' · ':'')+(x.session_title||('会话 #'+x.session_id)),120))+'</td></tr>'}).join(''):'<tr><td colspan="5" class="empty">暂无问答日志</td></tr>';pager('chatPager',offset,state.chatTotal,'loadChat')}catch(e){chatRows.innerHTML='<tr><td colspan="5" class="empty">加载失败</td></tr>'}}
function mini(x){return '<div class="mini"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><span>'+esc(x[2]||'')+'</span></div>'}
function pager(id,offset,total,fn,size){size=size||PAGE;var cur=Math.floor(offset/size)+1,totalPages=Math.max(1,Math.ceil((total||0)/size));document.getElementById(id).innerHTML='<button '+(offset<=0?'disabled':'')+' onclick="'+fn+'('+(offset-size)+')">上一页</button><span>第 '+cur+' / '+totalPages+' 页 · 共 '+(total||0)+' 条</span><button '+(offset+size>=total?'disabled':'')+' onclick="'+fn+'('+(offset+size)+')">下一页</button>'}
(async function(){await ensureAdmin();loadOps();switchTab('system')})();

View File

@ -10,7 +10,7 @@
<div class="page-head">
<div>
<h1>市场总览</h1>
<p>基于整个加密市场判断今天的大环境BTC/ETH 方向、山寨市场广度、成交额、资金费率,再结合链上和 AI 舆情作为辅助证据。</p>
<p>基于 CEX 行情判断今天的大环境BTC/ETH 方向、山寨市场广度、成交额、资金费率,再结合新闻舆情和 AI 摘要作为辅助证据。</p>
</div>
<div class="head-actions">
<select class="select" id="hoursSel" onchange="reloadAll()">
@ -43,16 +43,12 @@
<div class="panel-head"><div class="panel-title">合约情绪</div><div class="panel-note">Binance USDT 永续</div></div>
<div class="panel-body" id="fundingPanel"><div class="loading">加载中...</div></div>
</section>
<section class="panel">
<div class="panel-head"><div class="panel-title">链上证据</div><div class="panel-note">只突出高价值事件</div></div>
<div class="panel-body" id="onchainPanel"><div class="loading">加载中...</div></div>
</section>
<section class="panel">
<div class="panel-head"><div class="panel-title">AI 舆情</div><div class="panel-note">新闻聚合后的摘要</div></div>
<div class="panel-body" id="aiPanel"><div class="loading">加载中...</div></div>
</section>
<section class="panel full">
<div class="panel-head"><div class="panel-title">重要原始事件</div><div class="panel-note">链上与事件流中的高价值观察</div></div>
<div class="panel-head"><div class="panel-title">重要事件</div><div class="panel-note">CEX 事件与新闻舆情中的高价值观察</div></div>
<div class="panel-body" id="rawPanel"><div class="loading">加载中...</div></div>
</section>
</div>
@ -62,16 +58,15 @@
<script>
var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}function fmtUsd(v){v=Number(v||0);if(Math.abs(v)>=1e9)return '$'+(v/1e9).toFixed(2)+'B';if(Math.abs(v)>=1e6)return '$'+(v/1e6).toFixed(2)+'M';if(Math.abs(v)>=1e3)return '$'+(v/1e3).toFixed(1)+'K';return '$'+v.toFixed(0)}function fmt(v,d){return Number(v||0).toFixed(d==null?1:d)}function pct(v,d){return fmt(v,d==null?2:d)+'%'}function chip(t,c){return '<span class="chip '+(c||'')+'">'+esc(t)+'</span>'}function compact(t){return esc(String(t||'').replace(/\s+/g,' ').trim())}
function rankList(items,mode){items=(items||[]).slice(0,8);if(!items.length)return '<div class="empty">暂无数据</div>';return '<div class="rank-list">'+items.map(function(x,i){var change=Number(x.change_24h||0),val=mode==='volume'?fmtUsd(x.volume_24h):pct(change,2),cls=mode==='volume'?'':(change>=0?'up':'down');return '<div class="rank"><span class="idx">'+(i+1)+'</span><div class="sym"><b>'+esc(x.symbol||'--')+'</b><span>'+esc(fmtUsd(x.volume_24h||0))+' · '+esc(fmt(x.price||0,6))+'</span></div><div class="val '+cls+'">'+esc(val)+'</div></div>'}).join('')+'</div>'}
function renderDecision(m,on,news,ai){var st=m.state||{},btc=(m.benchmarks||{})['BTC/USDT']||{},eth=(m.benchmarks||{})['ETH/USDT']||{},delay=m.snapshot_stale?'<div class="note" style="margin-top:10px">市场数据更新有延迟,系统会自动刷新。当前判断请适当降低权重。</div>':'';$('decisionPanel').innerHTML='<div class="decision-top"><div><div class="eyebrow">当前市场判断</div><h2>'+esc(st.label||'暂无全市场数据')+'</h2></div><span class="stance '+esc(st.tone||'neutral')+'">'+esc(st.label||'等待数据')+'</span></div><p>'+esc(st.summary||'全市场行情暂时不可用,请稍后刷新。')+'</p><div class="evidence"><div class="ev"><span>BTC / ETH 24h</span><b>'+pct(btc.change_24h,2)+' / '+pct(eth.change_24h,2)+'</b></div><div class="ev"><span>山寨涨跌比</span><b>'+fmt(m.advance_decline_ratio,2)+'</b></div><div class="ev"><span>强势 / 大跌币</span><b>'+esc((m.hot_count_5pct||0)+' / '+(m.crash_count_5pct||0))+'</b></div></div>'+delay}
function renderScore(m,on,ai){var f=m.funding||{},k=on.kpi||{};$('scorePanel').innerHTML=[['覆盖币种',m.sample_count||0],['上涨 / 下跌',(m.up_count||0)+' / '+(m.down_count||0)],['24h 总成交额',fmtUsd(m.total_quote_volume_24h||0)],['平均涨跌幅',pct(m.avg_change_24h,2)],['平均资金费率',f.sample_count?pct((f.avg_funding_rate||0)*100,4):'暂无'],['链上高价值信号',k.event_count||0],['AI 状态',(ai&&ai.status)||'暂无']].map(function(x){return '<div class="score-row"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>'}).join('')}
function renderDecision(m,news,ai){var st=m.state||{},btc=(m.benchmarks||{})['BTC/USDT']||{},eth=(m.benchmarks||{})['ETH/USDT']||{},delay=m.snapshot_stale?'<div class="note" style="margin-top:10px">市场数据更新有延迟,系统会自动刷新。当前判断请适当降低权重。</div>':'';$('decisionPanel').innerHTML='<div class="decision-top"><div><div class="eyebrow">当前市场判断</div><h2>'+esc(st.label||'暂无全市场数据')+'</h2></div><span class="stance '+esc(st.tone||'neutral')+'">'+esc(st.label||'等待数据')+'</span></div><p>'+esc(st.summary||'全市场行情暂时不可用,请稍后刷新。')+'</p><div class="evidence"><div class="ev"><span>BTC / ETH 24h</span><b>'+pct(btc.change_24h,2)+' / '+pct(eth.change_24h,2)+'</b></div><div class="ev"><span>山寨涨跌比</span><b>'+fmt(m.advance_decline_ratio,2)+'</b></div><div class="ev"><span>强势 / 大跌币</span><b>'+esc((m.hot_count_5pct||0)+' / '+(m.crash_count_5pct||0))+'</b></div></div>'+delay}
function renderScore(m,ai){var f=m.funding||{};$('scorePanel').innerHTML=[['覆盖币种',m.sample_count||0],['上涨 / 下跌',(m.up_count||0)+' / '+(m.down_count||0)],['24h 总成交额',fmtUsd(m.total_quote_volume_24h||0)],['平均涨跌幅',pct(m.avg_change_24h,2)],['平均资金费率',f.sample_count?pct((f.avg_funding_rate||0)*100,4):'暂无'],['AI 状态',(ai&&ai.status)||'暂无']].map(function(x){return '<div class="score-row"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>'}).join('')}
function renderBreadth(m){$('breadthPanel').innerHTML='<div class="metric-list"><div class="metric"><span>山寨覆盖范围</span><b>'+esc(m.sample_count||0)+' 个</b></div><div class="metric"><span>上涨 / 下跌 / 横盘</span><b>'+esc((m.up_count||0)+' / '+(m.down_count||0)+' / '+(m.flat_count||0))+'</b></div><div class="metric"><span>平均 / 中位涨跌</span><b>'+pct(m.avg_change_24h,2)+' / '+pct(m.median_change_24h,2)+'</b></div><div class="metric"><span>25% / 75% 分位</span><b>'+pct(m.p25_change_24h,2)+' / '+pct(m.p75_change_24h,2)+'</b></div></div><div class="note" style="margin-top:10px">'+esc(m.universe||'全市场口径')+'</div>'}
function renderFunding(m){var f=m.funding||{};$('fundingPanel').innerHTML='<div class="metric-list"><div class="metric"><span>样本数</span><b>'+esc(f.sample_count||0)+'</b></div><div class="metric"><span>平均资金费率</span><b>'+esc(f.sample_count?pct((f.avg_funding_rate||0)*100,4):'暂无')+'</b></div><div class="metric"><span>正费率 / 负费率</span><b>'+esc((f.positive_count||0)+' / '+(f.negative_count||0))+'</b></div><div class="metric"><span>极端多头 / 极端空头</span><b>'+esc((f.extreme_positive_count||0)+' / '+(f.extreme_negative_count||0))+'</b></div></div><div class="note" style="margin-top:10px">资金费率用于判断合约拥挤度。极端正费率越多,追高风险越需要被压低。</div>'}
function renderOnchain(on){var k=on.kpi||{},providers=((on.provider_status||{}).providers)||[],ptxt=providers.slice(0,4).map(function(p){return chip((p.label||p.provider)+' · '+(p.status||'--'),p.provider==='dexscreener'?'blue':'')}).join('');$('onchainPanel').innerHTML='<div class="metric-list"><div class="metric"><span>高价值事件</span><b>'+(k.event_count||0)+'</b></div><div class="metric"><span>正向 / 风险</span><b>'+(k.positive_events||0)+' / '+(k.risk_events||0)+'</b></div><div class="metric"><span>原始流 / 已映射</span><b>'+(k.raw_event_count||0)+' / '+(k.raw_mapped_count||0)+'</b></div></div><div style="margin-top:10px" class="chips">'+(ptxt||chip('暂无 provider 状态'))+'</div><div class="note" style="margin-top:10px">链上只作为市场证据和机会发现来源,不直接替代交易确认。</div>'}
function renderAi(ai,news){var c=(ai&&ai.content)||{},txt=c.summary||c.memo||c.why_now_or_not||'',fg=(news&&news.fear_greed)||{};$('aiPanel').innerHTML='<div class="metric-list"><div class="metric"><span>恐惧贪婪</span><b>'+esc((fg.classification||'--')+' · '+(fg.value||'--'))+'</b></div><div class="metric"><span>AI 生成状态</span><b>'+esc((ai&&ai.status)||'暂无')+'</b></div></div><div class="note" style="margin-top:10px">'+(txt?compact(txt):'暂无 AI 舆情摘要。')+'</div>'}
function renderRaw(on){var items=(on.raw_events||[]).filter(function(e){return e.priority!=='low'}).slice(0,6);if(!items.length){$('rawPanel').innerHTML='<div class="empty">暂无高价值原始事件。低优先级 DEX 曝光流已隐藏</div>';return}$('rawPanel').innerHTML='<div class="raw-list">'+items.map(function(e){return '<div class="raw-item"><h3>'+compact(e.event_label||e.title||e.event_type)+'</h3><div class="sub">'+esc((e.chain||'--')+' · '+(e.mapped_symbol||e.token_short||'未映射')+' · '+(e.pipeline_note||''))+'</div></div>'}).join('')+'</div>'}
function renderRaw(news){var items=((news&&news.items)||[]).slice(0,6);if(!items.length){$('rawPanel').innerHTML='<div class="empty">暂无高价值事件。</div>';return}$('rawPanel').innerHTML='<div class="raw-list">'+items.map(function(e){return '<div class="raw-item"><h3>'+compact(e.title||e.event_type||'事件')+'</h3><div class="sub">'+esc([(e.symbol||''),(e.source||''),(e.importance||''),(e.decision||'')].filter(Boolean).join(' · '))+'</div></div>'}).join('')+'</div>'}
function fmtMarketTime(t){if(!t)return '等待刷新';var d=new Date(t);if(isNaN(d.getTime()))return t;return '行情更新 '+(d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2)}
function renderError(message){['decisionPanel','scorePanel','breadthPanel','gainersPanel','volumePanel','fundingPanel','onchainPanel','aiPanel','rawPanel'].forEach(function(id){$(id).innerHTML='<div class="empty">'+esc(message||'加载失败')+'</div>'})}
async function reloadAll(){try{var h=$('hoursSel').value,d=await(await fetch(API+'/api/market/overview?hours='+h+'&_ts='+Date.now(),{cache:'no-store'})).json(),box=d.market||{},m=box.crypto_market||{},on=box.onchain||{},news=box.newsfeed||{},ai=box.ai_analysis||{};$('marketUpdatedAt').textContent=fmtMarketTime(d.updated_at||m.updated_at);if(box.market_error&&!m.sample_count){renderError('全市场行情获取失败:'+box.market_error);return}renderDecision(m,on,news,ai);renderScore(m,on,ai);renderBreadth(m);$('gainersPanel').innerHTML=rankList(m.top_gainers,'change');$('volumePanel').innerHTML=rankList(m.top_volume,'volume');renderFunding(m);renderOnchain(on);renderAi(ai,news);renderRaw(on)}catch(e){renderError('加载失败')}}
function renderError(message){['decisionPanel','scorePanel','breadthPanel','gainersPanel','volumePanel','fundingPanel','aiPanel','rawPanel'].forEach(function(id){$(id).innerHTML='<div class="empty">'+esc(message||'加载失败')+'</div>'})}
async function reloadAll(){try{var h=$('hoursSel').value,d=await(await fetch(API+'/api/market/overview?hours='+h+'&_ts='+Date.now(),{cache:'no-store'})).json(),box=d.market||{},m=box.crypto_market||{},news=box.newsfeed||{},ai=box.ai_analysis||{};$('marketUpdatedAt').textContent=fmtMarketTime(d.updated_at||m.updated_at);if(box.market_error&&!m.sample_count){renderError('全市场行情获取失败:'+box.market_error);return}renderDecision(m,news,ai);renderScore(m,ai);renderBreadth(m);$('gainersPanel').innerHTML=rankList(m.top_gainers,'change');$('volumePanel').innerHTML=rankList(m.top_volume,'volume');renderFunding(m);renderAi(ai,news);renderRaw(news)}catch(e){renderError('加载失败')}}
reloadAll();
</script>
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -42,7 +42,7 @@
</section>
</div>
<section class="panel" style="margin-top:14px">
<div class="panel-head"><div><div class="panel-title">数据源新鲜度</div><div class="panel-note">市场、价格、舆情、链上和 AI 缓存是否还在更新</div></div></div>
<div class="panel-head"><div><div class="panel-title">数据源新鲜度</div><div class="panel-note">市场、价格、舆情和 AI 缓存是否还在更新</div></div></div>
<div class="source-grid" id="sources"></div>
</section>
</div>

File diff suppressed because one or more lines are too long

View File

@ -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 '<div class="strategy-card"><div class="strategy-top"><div><div class="strategy-name">'+esc(x.strategy_name||x.strategy_code)+'</div><div class="strategy-desc">'+esc(x.description||'暂无策略说明')+'</div></div><div class="score-ring" style="--s:'+score+'">'+score.toFixed(0)+'</div></div><div class="strategy-metrics"><div class="strategy-metric"><span>信号</span><b>'+esc(x.signal_count||0)+'</b></div><div class="strategy-metric"><span>机会</span><b>'+esc(x.opportunity_count||0)+'</b></div><div class="strategy-metric"><span>窗口平仓</span><b>'+esc(x.closed_trade_count||0)+'</b></div><div class="strategy-metric"><span>胜率</span><b>'+num(x.win_rate_pct,1)+'%</b></div><div class="strategy-metric"><span>收益</span><b>'+usd(x.realized_pnl_usdt)+'</b></div><div class="strategy-metric"><span>均值</span><b>'+pct(x.avg_realized_pnl_pct)+'</b></div><div class="strategy-metric"><span>成交</span><b>'+num(x.order_fill_rate_pct,1)+'%</b></div><div class="strategy-metric"><span>转化</span><b>'+num(x.trade_conversion_pct,1)+'%</b></div></div><div class="strategy-actions"><span class="decision-pill '+decisionCls(x.decision)+'">'+esc(x.decision_label||x.decision)+'</span> '+esc((x.reasons||[])[0]||'继续观察')+'<br>'+esc((x.next_actions||[])[0]||'等待更多样本')+'</div></div>'}).join('')||'<div class="digest-empty">暂无策略评价数据</div>';$('strategyBoard').innerHTML='<div class="digest-head"><div><div class="digest-title">多策略优胜劣汰</div><div class="digest-sub">'+esc(se.definition||'按策略独立评价发现、执行、收益和风险。')+'</div></div><span class="decision-pill">策略 '+esc(s.strategy_count||0)+' · 已交易 '+esc(s.traded_strategy_count||0)+' · 待暂停 '+esc(s.pause_count||0)+'</span></div><div class="strategy-list">'+cards+'</div>'}
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']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
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','']])+'<div class="split"><div><div class="mini-title">策略表现</div>'+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)})+'</div><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+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)})+'</div><div><div class="mini-title">因子组表现</div>'+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)+'%'})+'</div><div><div class="mini-title">入场路径表现</div>'+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)+'%'})+'</div><div><div class="mini-title">观察/挂单推进</div>'+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)})+'</div></div>'}
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']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'}
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']])+'<div class="split"><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div><div><div class="mini-title">新闻来源</div>'+rows(e.news_sources,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
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']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
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='<div class="split"><div><div class="mini-title">最近策略交易</div>'+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||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+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||'')})+'</div><div><div class="mini-title">舆情事件</div>'+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||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+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||'')})+'</div></div>'}
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='<div class="split"><div><div class="mini-title">最近策略交易</div>'+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||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+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||'')})+'</div><div><div class="mini-title">舆情事件</div>'+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||'未处理')})+'</div></div>'}
function render(d){renderStrategyDigest(d);renderStrategyBoard(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return '<div class="principle">'+esc(x)+'</div>'}).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='<div class="empty">加载失败</div>'})}}
loadAll();

View File

@ -20,12 +20,12 @@
<div class="flow-step"><b>机会样本</b><span>系统发现并入库的机会,不等于交易。</span></div>
<div class="flow-step"><b>可执行转化</b><span>确认层输出现在可买或等回踩。</span></div>
<div class="flow-step"><b>策略执行</b><span>只有 buy_now 被策略交易开仓才进入收益账本。</span></div>
<div class="flow-step"><b>证据归因</b><span>链上、舆情、技术因子只做贡献分析。</span></div>
<div class="flow-step"><b>证据归因</b><span>舆情、技术因子只做贡献分析。</span></div>
</div>
<div class="grid">
<section class="panel"><div class="panel-head"><div class="panel-title">因子归因</div><div class="panel-note">信号 -> 转化</div></div><div class="panel-body" id="factorPerf"><div class="loading">加载中...</div></div></section>
<section class="panel"><div class="panel-head"><div class="panel-title">市场环境归因</div><div class="panel-note">环境 -> 转化</div></div><div class="panel-body" id="envPerf"><div class="loading">加载中...</div></div></section>
<section class="panel"><div class="panel-head"><div class="panel-title">证据源归因</div><div class="panel-note">链上 / 舆情</div></div><div class="panel-body" id="evidencePerf"><div class="loading">加载中...</div></div></section>
<section class="panel"><div class="panel-head"><div class="panel-title">证据源归因</div><div class="panel-note">舆情 / AI</div></div><div class="panel-body" id="evidencePerf"><div class="loading">加载中...</div></div></section>
<section class="panel"><div class="panel-head"><div class="panel-title">版本归因</div><div class="panel-note">版本 -> 转化</div></div><div class="panel-body" id="versionPerf"><div class="loading">加载中...</div></div></section>
<section class="panel full"><div class="panel-head"><div><div class="panel-title">交易级因子归因</div><div class="panel-note" id="tradeDef">只统计已平仓策略交易,用真实账本收益评价因子、入场路径、退出原因和环境。</div></div></div><div class="panel-body" id="tradePerf"><div class="loading">加载中...</div></div></section>
</div>

View File

@ -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",
]

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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):

View File

@ -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):