This commit is contained in:
aaron 2026-05-21 16:44:48 +08:00
parent 4fa4dcb965
commit 823609db9f
16 changed files with 654 additions and 123 deletions

View File

@ -41,20 +41,28 @@ ALPHAX_LLM_REVIEW_ENABLED=1
# 链上追踪运行时配置。默认关闭;开启后采集结果只作为发现/风控辅助。
ALPHAX_ONCHAIN_ENABLED=0
ALPHAX_ONCHAIN_CHAINS=ethereum,bsc,base,arbitrum,solana
ALPHAX_ONCHAIN_PROVIDER=nodereal
ALPHAX_ONCHAIN_CHAINS=ethereum,bsc
ALPHAX_ONCHAIN_TIMEOUT=15
ALPHAX_NODEREAL_ENABLED=1
ALPHAX_NODEREAL_CHAINS=ethereum,bsc
ALPHAX_NODEREAL_API_KEY=
ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK=120
ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN=25
ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70
ALPHAX_ONCHAIN_CANDIDATE_COOLDOWN_HOURS=6
ALPHAX_ONCHAIN_DEXSCREENER_ENABLED=1
ALPHAX_ONCHAIN_DEXSCREENER_ENABLED=0
ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT=80
ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD=100000
ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD=100000
ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT=25
ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT=-25
ALPHAX_ONCHAIN_WHALE_TX_USD=250000
ALPHAX_ETHERSCAN_ENABLED=0
ALPHAX_ETHERSCAN_API_KEY=
ALPHAX_HELIUS_ENABLED=0
ALPHAX_HELIUS_API_KEY=
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。

View File

@ -98,6 +98,13 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
8. `app/services/review_engine.py`
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
### 4.1.1 链上数据源
- 当前链上主数据源是 NodeReal入口在 `app/services/nodereal_client.py``app/services/onchain_monitor.py`
- 默认只跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`,并通过 `ALPHAX_NODEREAL_API_KEY` 访问 EVM JSON-RPC / Enhanced API。
- DEX Screener、Etherscan、Helius 已从默认链路关闭,只保留历史兼容函数和旧数据展示,不应再作为新增链上逻辑的主入口。
- 新增链上信号优先落到 `onchain_token_metrics` / `onchain_events`,不要直接创建推荐;高质量事件仍通过 `event_news` 进入技术检查。
### 4.2 Web/API
`app/web/web_server.py` 只应负责 FastAPI 应用装配、模板装配、中间件、全局异常处理和 router include。新增业务 API 优先放到对应 route 模块:

View File

@ -7,6 +7,7 @@
- Web 默认暴露到宿主机 `8191`,容器内端口 `8190`
- 运行时数据库是 PostgreSQLcompose 内置 `postgres:16` 服务。
- `DATABASE_URL` 是应用唯一运行时数据库连接入口。
- 链上主数据源是 NodeReal`.env` 中配置 `ALPHAX_NODEREAL_API_KEY` 后,`python -m app.cli onchain` 才会产出 NodeReal 链上事件。
- 调度器以并发子进程运行,并通过业务锁组避免主推荐写入冲突。
- `.dockerignore` 排除了 `data/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。

View File

@ -68,16 +68,22 @@ def default_llm_config():
}
def default_onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum", "solana")):
def default_onchain_config(default_chains=("ethereum", "bsc")):
return {
"enabled": _env_bool("ALPHAX_ONCHAIN_ENABLED", False),
"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",
"nodereal_log_block_lookback": _env_int("ALPHAX_NODEREAL_LOG_BLOCK_LOOKBACK", 120),
"nodereal_max_logs_per_token": _env_int("ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN", 25),
"candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
"candidate_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),
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", True),
"dexscreener_enabled": _env_bool("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", False),
"dex_volume_spike_pct": _env_float("ALPHAX_ONCHAIN_DEX_VOLUME_SPIKE_PCT", 80),
"dex_min_liquidity_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_LIQUIDITY_USD", 100000),
"dex_min_volume_24h_usd": _env_float("ALPHAX_ONCHAIN_DEX_MIN_VOLUME_24H_USD", 100000),
@ -86,9 +92,9 @@ def default_onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum"
"liquidity_add_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_ADD_PCT", 25),
"liquidity_remove_pct": _env_float("ALPHAX_ONCHAIN_LIQUIDITY_REMOVE_PCT", -25),
"whale_tx_usd": _env_float("ALPHAX_ONCHAIN_WHALE_TX_USD", 250000),
"etherscan_enabled": _env_bool("ALPHAX_ETHERSCAN_ENABLED", True),
"etherscan_enabled": _env_bool("ALPHAX_ETHERSCAN_ENABLED", False),
"etherscan_chains": _env_list("ALPHAX_ETHERSCAN_CHAINS", ("ethereum",)),
"helius_enabled": _env_bool("ALPHAX_HELIUS_ENABLED", True),
"helius_enabled": _env_bool("ALPHAX_HELIUS_ENABLED", False),
"etherscan_base_url": _env_str("ALPHAX_ETHERSCAN_BASE_URL", "https://api.etherscan.io/v2/api"),
"helius_base_url": _env_str("ALPHAX_HELIUS_BASE_URL", "https://api.helius.xyz"),
"etherscan_api_key_env": "ALPHAX_ETHERSCAN_API_KEY",
@ -334,7 +340,7 @@ def llm_config():
return cfg or default_llm_config()
def onchain_config(default_chains=("ethereum", "bsc", "base", "arbitrum", "solana")):
def onchain_config(default_chains=("ethereum", "bsc")):
cfg = get_onchain_config(default=None)
if cfg is None:
_seed_one("onchain", default_onchain_config(default_chains), "On-chain provider and signal thresholds; API keys remain in env")

View File

@ -25,6 +25,7 @@ SIGNAL_LABELS = {
"exchange_outflow": "交易所流出",
"exchange_inflow_risk": "交易所流入风险",
"whale_accumulation": "鲸鱼增持",
"holder_growth": "持有人增长",
"holder_concentration_risk": "持仓集中风险",
"smart_money_buying": "聪明钱买入",
}
@ -53,7 +54,7 @@ RAW_EVENT_EXPLAINERS = {
},
}
POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "smart_money_buying"}
POSITIVE_SIGNALS = {"dex_volume_spike", "liquidity_add", "exchange_outflow", "whale_accumulation", "holder_growth", "smart_money_buying"}
RISK_SIGNALS = {"liquidity_remove_risk", "exchange_inflow_risk", "holder_concentration_risk"}
@ -468,6 +469,7 @@ def get_onchain_provider_status(hours=24):
cfg = onchain_config()
hours = int(hours or 24)
cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
etherscan_env = str(cfg.get("etherscan_api_key_env") or "ALPHAX_ETHERSCAN_API_KEY")
helius_env = str(cfg.get("helius_api_key_env") or "ALPHAX_HELIUS_API_KEY")
etherscan_chains = cfg.get("etherscan_chains") or ["ethereum"]
@ -535,23 +537,45 @@ def get_onchain_provider_status(hours=24):
summary = _load(last_onchain.get("summary_json") if last_onchain else "{}", {}) if last_onchain else {}
last_error = last_onchain.get("error_message") if last_onchain else ""
provider = str(cfg.get("provider") or "nodereal").strip().lower()
dexscreener_enabled = bool(cfg.get("dexscreener_enabled", False)) and provider != "nodereal"
etherscan_enabled = bool(cfg.get("etherscan_enabled", False)) and provider != "nodereal"
helius_enabled = bool(cfg.get("helius_enabled", False)) and provider != "nodereal"
providers = [
{
"provider": "nodereal",
"label": "NodeReal",
"enabled": bool(cfg.get("nodereal_enabled", True)) and provider == "nodereal",
"api_key_present": bool(os.getenv(nodereal_env, "").strip()),
"implemented": True,
"role": "EVM 主链上数据源Transfer 日志、大额转账、holder 变化",
"raw_events": 0,
"metrics": int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal")),
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal")),
"status": _provider_status_label(
bool(cfg.get("nodereal_enabled", True)),
True,
int(sum(row["count"] for row in metric_sources if row["source"] == "nodereal"))
+ int(sum(row["count"] for row in signal_sources if row["source"] == "nodereal")),
last_error if "nodereal" in str(last_error).lower() else "",
),
},
{
"provider": "dexscreener",
"label": "DEX Screener",
"enabled": bool(cfg.get("dexscreener_enabled", True)),
"enabled": dexscreener_enabled,
"api_key_present": True,
"implemented": True,
"role": "低优先级曝光源Token 资料、付费推广、已映射合约的 DEX 成交量与流动性",
"raw_events": int(raw_total or 0),
"metrics": int(metric_total or 0),
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "dexscreener")),
"status": _provider_status_label(bool(cfg.get("dexscreener_enabled", True)), True, raw_total + metric_total, last_error),
"status": _provider_status_label(dexscreener_enabled, True, raw_total + metric_total, last_error),
},
{
"provider": "etherscan",
"label": "Etherscan",
"enabled": bool(cfg.get("etherscan_enabled", True)),
"enabled": etherscan_enabled,
"api_key_present": bool(os.getenv(etherscan_env, "").strip()),
"implemented": True,
"role": "EVM 已映射合约的 ERC20 大额转账,当前链: " + ", ".join(etherscan_chains or ["ethereum"]),
@ -559,7 +583,7 @@ def get_onchain_provider_status(hours=24):
"metrics": 0,
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
"status": _provider_status_label(
bool(cfg.get("etherscan_enabled", True)),
etherscan_enabled,
True,
int(sum(row["count"] for row in signal_sources if row["source"] == "etherscan")),
last_error if "etherscan" in str(last_error).lower() else "",
@ -568,7 +592,7 @@ def get_onchain_provider_status(hours=24):
{
"provider": "helius",
"label": "Helius",
"enabled": bool(cfg.get("helius_enabled", True)),
"enabled": helius_enabled,
"api_key_present": bool(os.getenv(helius_env, "").strip()),
"implemented": True,
"role": "Solana 已映射 mint 的解析交易与大额 token 活动",
@ -576,7 +600,7 @@ def get_onchain_provider_status(hours=24):
"metrics": 0,
"signals": int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
"status": _provider_status_label(
bool(cfg.get("helius_enabled", True)),
helius_enabled,
True,
int(sum(row["count"] for row in signal_sources if row["source"] == "helius")),
last_error if "helius" in str(last_error).lower() else "",

View File

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from app.config.system_config import paper_trading_config
from app.db.schema import get_conn
from app.db.system_logs import record_system_error
from app.integrations.feishu_push import push_card
@ -267,7 +268,7 @@ def _push_paper_card(event_type: str, symbol: str, title: str, template: str, fi
elements.append(_card_note(note))
if event_time:
elements.append(_card_note(f"时间: {event_time}"))
push_card({
ok, result = push_card({
"metadata": {"source": "paper_trading", "event_type": event_type, "symbol": symbol},
"config": {"wide_screen_mode": True},
"header": {
@ -276,8 +277,26 @@ def _push_paper_card(event_type: str, symbol: str, title: str, template: str, fi
},
"elements": elements,
})
except Exception:
pass
if not ok:
record_system_error(
source="paper_trading",
level="warning",
error_type="FeishuPushFailed",
message=f"Feishu push failed for {event_type} {symbol}: {str(result)[:500]}",
status_code=0,
context={"event_type": event_type, "symbol": symbol, "push_result": result},
fingerprint=f"paper_trading_feishu_push_failed:{event_type}:{symbol}",
)
except Exception as exc:
record_system_error(
source="paper_trading",
level="warning",
error_type=exc.__class__.__name__,
message=f"Feishu push exception for {event_type} {symbol}: {str(exc)[:500]}",
status_code=0,
context={"event_type": event_type, "symbol": symbol},
fingerprint=f"paper_trading_feishu_push_exception:{event_type}:{symbol}",
)
def _push_custom_paper_card(card: dict) -> tuple[bool, object]:
@ -306,16 +325,18 @@ def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str
)
return
if event_type == "close":
exit_reason = str(result.get("exit_reason") or "--")
title_prefix = "移动止盈成交平仓" if exit_reason == "trailing_stop" else "交易平仓"
_push_paper_card(
event_type,
symbol,
f"交易平仓 - {short_symbol}",
f"{title_prefix} - {short_symbol}",
"red" if _safe_float(result.get("pnl_usdt")) < 0 else "green",
[
("退出价", _fmt_price(result.get("exit_price"))),
("收益率", _fmt_pct(result.get("pnl_pct"))),
("收益额", f"{_safe_float(result.get('pnl_usdt')):.2f} USDT"),
("原因", result.get("exit_reason") or "--"),
("原因", exit_reason),
],
"收益以交易账本记录为准。",
event_time,

View File

@ -0,0 +1,90 @@
"""Small JSON-RPC client for NodeReal MegaNode.
The on-chain monitor only needs a narrow subset of NodeReal right now:
standard EVM logs plus a few enhanced token APIs. Keeping this adapter small
prevents provider-specific details from leaking into strategy code.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import requests
DEFAULT_CHAIN_ENDPOINTS = {
"ethereum": "https://eth-mainnet.nodereal.io/v1/{api_key}",
"bsc": "https://bsc-mainnet.nodereal.io/v1/{api_key}",
}
@dataclass(frozen=True)
class NodeRealConfig:
api_key: str
timeout: int = 15
endpoints: dict[str, str] | None = None
class NodeRealClient:
def __init__(self, config: NodeRealConfig):
self.config = config
self.endpoints = {**DEFAULT_CHAIN_ENDPOINTS, **(config.endpoints or {})}
def supports_chain(self, chain: str) -> bool:
return bool(self._endpoint(chain))
def call(self, chain: str, method: str, params: list[Any] | None = None) -> Any:
endpoint = self._endpoint(chain)
if not endpoint:
raise ValueError(f"nodereal_chain_not_configured:{chain}")
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params or [],
}
resp = requests.post(
endpoint,
json=payload,
timeout=self.config.timeout,
headers={"Content-Type": "application/json", "User-Agent": "AlphaX-Agent-Crypto/1.0"},
)
if resp.status_code >= 400:
raise RuntimeError(f"nodereal_http_{resp.status_code}:{resp.text[:200]}")
data = resp.json()
if data.get("error"):
raise RuntimeError(f"nodereal_rpc_error:{data['error']}")
return data.get("result")
def block_number(self, chain: str) -> int:
return _hex_to_int(self.call(chain, "eth_blockNumber", []))
def get_logs(self, chain: str, log_filter: dict[str, Any]) -> list[dict[str, Any]]:
result = self.call(chain, "eth_getLogs", [log_filter])
return result if isinstance(result, list) else []
def token_holder_count(self, chain: str, contract_address: str) -> int:
return _hex_to_int(self.call(chain, "nr_getTokenHolderCount", [contract_address]))
def _endpoint(self, chain: str) -> str:
chain_key = str(chain or "").lower().strip()
template = self.endpoints.get(chain_key, "")
if not template or not self.config.api_key:
return ""
return template.format(api_key=self.config.api_key)
def _hex_to_int(value: Any) -> int:
if value is None:
return 0
if isinstance(value, int):
return value
text = str(value).strip()
if not text:
return 0
try:
return int(text, 16) if text.startswith("0x") else int(text)
except Exception:
return 0

View File

@ -15,6 +15,7 @@ import requests
from app.config.system_config import onchain_config
from app.db import onchain_db
from app.db.altcoin_db import get_conn, init_db, log_cron_run
from app.db.tracking_queries import get_latest_price_cache
from app.db.onchain_db import (
MIN_MAPPING_CONFIDENCE,
POSITIVE_SIGNALS,
@ -31,9 +32,10 @@ from app.db.onchain_db import (
)
from app.services.event_driven_screener import _event_hash as event_hash
from app.services.event_driven_screener import _tradable_symbol, init_event_tables
from app.services.nodereal_client import DEFAULT_CHAIN_ENDPOINTS, NodeRealClient, NodeRealConfig
DEFAULT_CHAINS = ("ethereum", "bsc", "base", "arbitrum", "solana")
DEFAULT_CHAINS = ("ethereum", "bsc")
ETHERSCAN_CHAIN_IDS = {
"ethereum": "1",
"bsc": "56",
@ -70,6 +72,7 @@ DEXSCREENER_RAW_ENDPOINTS = (
("token_boost_latest", "https://api.dexscreener.com/token-boosts/latest/v1"),
("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"),
)
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
def _env_bool(name, default=False):
@ -103,6 +106,7 @@ def get_onchain_params():
chains = [str(x).strip().lower() for x in chains_raw if str(x).strip()]
etherscan_env = str(cfg.get("etherscan_api_key_env") or "ALPHAX_ETHERSCAN_API_KEY")
helius_env = str(cfg.get("helius_api_key_env") or "ALPHAX_HELIUS_API_KEY")
nodereal_env = str(cfg.get("nodereal_api_key_env") or "ALPHAX_NODEREAL_API_KEY")
etherscan_chains_raw = cfg.get("etherscan_chains") or ["ethereum"]
if isinstance(etherscan_chains_raw, str):
etherscan_chains = [x.strip().lower() for x in etherscan_chains_raw.split(",") if x.strip()]
@ -110,8 +114,15 @@ def get_onchain_params():
etherscan_chains = [str(x).strip().lower() for x in etherscan_chains_raw if str(x).strip()]
return {
"enabled": bool(cfg.get("enabled", False)),
"provider": str(cfg.get("provider") or "nodereal").strip().lower(),
"chains": chains or list(DEFAULT_CHAINS),
"timeout": int(cfg.get("timeout") or 15),
"nodereal_enabled": bool(cfg.get("nodereal_enabled", True)),
"nodereal_chains": _normalize_chain_list(cfg.get("nodereal_chains") or ("ethereum", "bsc")),
"nodereal_api_key": os.getenv(nodereal_env, "").strip(),
"nodereal_api_key_env": nodereal_env,
"nodereal_log_block_lookback": int(cfg.get("nodereal_log_block_lookback") or 120),
"nodereal_max_logs_per_token": int(cfg.get("nodereal_max_logs_per_token") or 25),
"candidate_enabled": bool(cfg.get("candidate_enabled", True)),
"candidate_min_score": float(cfg.get("candidate_min_score") or 70),
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
@ -135,6 +146,12 @@ def get_onchain_params():
}
def _normalize_chain_list(value):
if isinstance(value, str):
return [x.strip().lower() for x in value.split(",") if x.strip()]
return [str(x).strip().lower() for x in (value or []) if str(x).strip()]
def _now():
return datetime.now()
@ -664,17 +681,209 @@ def _event_from_etherscan_transfer(row, mapping, cfg=None):
def _latest_price_from_metric(mapping):
latest = _latest_metric(
normalize_symbol(mapping.get("symbol")),
str(mapping.get("chain") or "").lower(),
str(mapping.get("contract_address") or ""),
)
raw = {}
symbol = normalize_symbol(mapping.get("symbol"))
chain = str(mapping.get("chain") or "").lower()
contract = str(mapping.get("contract_address") or "")
conn = get_conn()
try:
raw = json.loads(latest.get("raw_json") or "{}") if latest else {}
rows = conn.execute(
"""
SELECT raw_json
FROM onchain_token_metrics
WHERE symbol=%s AND chain=%s AND contract_address=%s
ORDER BY metric_time DESC, id DESC
LIMIT 8
""",
(symbol, chain, contract),
).fetchall()
finally:
conn.close()
for row in rows:
try:
raw = json.loads(row.get("raw_json") or "{}")
except Exception:
raw = {}
price = _safe_float(raw.get("price_usd"))
if price > 0:
return price
cache = get_latest_price_cache([symbol])
item = cache.get(symbol) or {}
return _safe_float(item.get("price"))
def _hex_to_int(value):
text = str(value or "").strip()
if not text:
return 0
try:
return int(text, 16) if text.startswith("0x") else int(text)
except Exception:
raw = {}
return _safe_float(raw.get("price_usd"))
return 0
def _topic_to_address(topic):
topic = str(topic or "").lower()
if topic.startswith("0x") and len(topic) >= 42:
return "0x" + topic[-40:]
return ""
def _nodereal_client(cfg=None):
cfg = cfg or get_onchain_params()
return NodeRealClient(
NodeRealConfig(
api_key=cfg.get("nodereal_api_key") or "",
timeout=int(cfg.get("timeout") or 15),
endpoints=dict(DEFAULT_CHAIN_ENDPOINTS),
)
)
def _event_from_nodereal_transfer(log, mapping, cfg=None):
cfg = cfg or get_onchain_params()
topics = log.get("topics") or []
if len(topics) < 3:
return None
amount_raw = _hex_to_int(log.get("data"))
mapping_raw = {}
try:
mapping_raw = json.loads(mapping.get("raw_json") or "{}")
except Exception:
mapping_raw = {}
decimals = _safe_int(mapping_raw.get("decimals") or mapping_raw.get("tokenDecimal") or 18, 18)
amount = amount_raw / (10 ** decimals if decimals >= 0 else 1)
price_usd = _latest_price_from_metric(mapping)
value_usd = amount * price_usd if price_usd > 0 else 0
threshold = _safe_float(cfg.get("whale_tx_usd"), 250000)
if value_usd <= 0 or value_usd < threshold:
return None
chain = str(mapping.get("chain") or "").lower()
tx_hash = str(log.get("transactionHash") or "").strip()
return {
"chain": chain,
"symbol": mapping.get("symbol"),
"contract_address": mapping.get("contract_address") or "",
"event_type": "token_transfer",
"signal_code": "whale_accumulation",
"signal_label": signal_label("whale_accumulation"),
"direction": "positive",
"value_usd": value_usd,
"amount": amount,
"tx_hash": tx_hash,
"wallet_address": _topic_to_address(topics[2]),
"wallet_label": "EVM 接收地址",
"counterparty_label": "EVM 发送地址 " + _short_addr(_topic_to_address(topics[1])),
"confidence": 76,
"severity": "A",
"detected_at": _now().isoformat(timespec="seconds"),
"source": "nodereal",
"url": _chain_explorer_tx_url(chain, tx_hash),
"raw": log,
}
def _metric_from_nodereal_holder_count(holder_count, mapping):
symbol = normalize_symbol(mapping.get("symbol"))
chain = str(mapping.get("chain") or "").lower()
contract = str(mapping.get("contract_address") or "")
prev = _latest_metric(symbol, chain, contract)
prev_count = 0
if prev:
try:
prev_raw = json.loads(prev.get("raw_json") or "{}")
prev_count = _safe_int(prev_raw.get("holder_count"))
except Exception:
prev_count = 0
holder_delta = holder_count - prev_count if prev_count > 0 else 0
metric = {
"symbol": symbol,
"chain": chain,
"contract_address": contract,
"window": "1h",
"metric_time": _now().isoformat(timespec="seconds"),
"holder_delta": holder_delta,
"smart_money_score": 0,
"source": "nodereal",
"raw": {
"holder_count": holder_count,
"previous_holder_count": prev_count,
},
}
if holder_delta > 0:
metric["onchain_score"] = min(30, holder_delta)
elif holder_delta < 0:
metric["risk_score"] = min(30, abs(holder_delta))
return metric
def _event_from_holder_metric(metric):
holder_delta = _safe_float(metric.get("holder_delta"))
if holder_delta <= 0:
return None
if holder_delta < 20:
return None
return _event_from_metric(metric, "holder_growth", source="nodereal")
def fetch_nodereal_events(limit=60):
cfg = get_onchain_params()
if not cfg.get("nodereal_enabled", True):
return {"metrics": [], "events": [], "errors": ["nodereal_disabled"]}
if not cfg.get("nodereal_api_key"):
return {"metrics": [], "events": [], "errors": ["nodereal_api_key_missing"]}
client = _nodereal_client(cfg)
enabled_chains = set(cfg.get("nodereal_chains") or DEFAULT_CHAINS)
mappings = [
m for m in get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE)
if str(m.get("chain") or "").lower() in enabled_chains and client.supports_chain(str(m.get("chain") or "").lower())
]
metrics = []
events = []
errors = []
lookback = max(1, int(cfg.get("nodereal_log_block_lookback") or 120))
max_logs = max(1, int(cfg.get("nodereal_max_logs_per_token") or 25))
for mapping in mappings[: int(limit or 60)]:
chain = str(mapping.get("chain") or "").lower()
contract = str(mapping.get("contract_address") or "").strip()
if not contract:
continue
try:
holder_count = client.token_holder_count(chain, contract)
if holder_count:
metric = _metric_from_nodereal_holder_count(holder_count, mapping)
insert_token_metric(metric)
metrics.append(metric)
holder_event = _event_from_holder_metric(metric)
if holder_event and insert_onchain_event(holder_event):
events.append(holder_event)
except Exception as exc:
errors.append(f"{mapping.get('symbol')}:nodereal_holder:{str(exc)[:160]}")
try:
latest = client.block_number(chain)
if latest <= 0:
continue
logs = client.get_logs(
chain,
{
"address": contract,
"fromBlock": hex(max(0, latest - lookback)),
"toBlock": hex(latest),
"topics": [TRANSFER_TOPIC],
},
)
for log in logs[:max_logs]:
if not isinstance(log, dict):
continue
event = _event_from_nodereal_transfer(log, mapping, cfg=cfg)
if not event:
continue
if insert_onchain_event(event):
events.append(event)
except Exception as exc:
errors.append(f"{mapping.get('symbol')}:nodereal_logs:{str(exc)[:160]}")
if not mappings:
errors.append("nodereal_no_supported_mappings")
return {"metrics": metrics, "events": events, "errors": errors}
def fetch_etherscan_events(limit=60):
@ -959,32 +1168,17 @@ def run_once(limit=60):
"check_time": _now().isoformat(),
}
if cfg.get("enabled"):
raw = fetch_dexscreener_raw_events(limit=limit)
output["raw_events_count"] = len(raw.get("raw_events") or [])
output["errors"].extend(raw.get("errors") or [])
dex = fetch_dexscreener_metrics(limit=limit)
output["metrics_count"] += len(dex.get("metrics") or [])
output["events_count"] += len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or [])
eth = fetch_etherscan_events(limit=limit)
output["events_count"] += len(eth.get("events") or [])
output["errors"].extend(eth.get("errors") or [])
hel = fetch_helius_events(limit=limit)
output["events_count"] += len(hel.get("events") or [])
output["errors"].extend(hel.get("errors") or [])
output["discovered_mappings"] = discover_token_mappings(limit=limit).get("inserted", 0) if not get_token_mappings(min_confidence=MIN_MAPPING_CONFIDENCE) else 0
node = fetch_nodereal_events(limit=limit)
output["metrics_count"] += len(node.get("metrics") or [])
output["events_count"] += len(node.get("events") or [])
output["errors"].extend(node.get("errors") or [])
output["discovered_mappings"] = 0
if output.get("discovered_mappings"):
output["status"] = "bootstrapped"
dex = fetch_dexscreener_metrics(limit=limit)
output["metrics_count"] = len(dex.get("metrics") or [])
output["events_count"] = len(dex.get("events") or [])
output["errors"].extend(dex.get("errors") or [])
eth = fetch_etherscan_events(limit=limit)
output["events_count"] += len(eth.get("events") or [])
output["errors"].extend(eth.get("errors") or [])
hel = fetch_helius_events(limit=limit)
output["events_count"] += len(hel.get("events") or [])
output["errors"].extend(hel.get("errors") or [])
node = fetch_nodereal_events(limit=limit)
output["metrics_count"] = len(node.get("metrics") or [])
output["events_count"] = len(node.get("events") or [])
output["errors"].extend(node.get("errors") or [])
queued = enqueue_onchain_candidates()
output["candidate_queued"] = queued.get("queued", 0)
output["candidate_symbols"] = queued.get("symbols", [])
@ -1020,6 +1214,7 @@ __all__ = [
"fetch_dexscreener_raw_events",
"fetch_etherscan_events",
"fetch_helius_events",
"fetch_nodereal_events",
"get_onchain_params",
"ingest_normalized_events",
"normalize_dexscreener_pair",

View File

@ -4,6 +4,7 @@ from fastapi.responses import HTMLResponse, Response
from app.config.system_config import seed_runtime_system_defaults
from app.db import auth_db
from app.db import chat_assistant_db
from app.db.analytics import get_cron_run_logs, get_cron_run_summary, get_pipeline_runs
from app.db.data_export import build_data_export_bundle
from app.db.scheduler_db import (
enqueue_manual_trigger,
@ -86,6 +87,21 @@ def build_router(templates):
raise HTTPException(status_code=404, detail="日志不存在")
return item
@router.get("/api/admin/cron-runs")
async def api_admin_cron_runs(limit: int = 80, job_name: str = "", altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
return {"items": get_cron_run_logs(limit=limit, job_name=job_name or None)}
@router.get("/api/admin/cron-runs/summary")
async def api_admin_cron_run_summary(hours: int = 24, altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
return get_cron_run_summary(hours=hours)
@router.get("/api/admin/pipeline-runs")
async def api_admin_pipeline_runs(limit: int = 30, hours: int = 24, offset: int = 0, altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)
return get_pipeline_runs(limit=limit, hours=hours, offset=offset)
@router.get("/api/admin/chat-logs/overview")
async def api_admin_chat_logs_overview(hours: int = 24, altcoin_session: str = Cookie(default="")):
require_admin(altcoin_session)

View File

@ -96,6 +96,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
return render_page("system_logs.html", request, active_nav="system_logs")
@router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
user, redirect = require_page_user(request)
if redirect:
return redirect
try:
require_admin(request.cookies.get("altcoin_session", ""))
except HTTPException as exc:
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
return render_page("logs.html", request, active_nav="logs")
@router.get("/data-export", response_class=HTMLResponse)
async def data_export_page(request: Request):
user, redirect = require_page_user(request)

View File

@ -185,15 +185,13 @@ a { color: inherit; text-decoration: none; }
<div class="sidebar-section-label admin-link" style="display:none">管理员菜单</div>
<a class="sidebar-link admin-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading" style="display:none"><svg class="link-icon"><use href="#svg-paper"/></svg>策略交易</a>
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'pipeline' %}active{% endif %}" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略归因</a>
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>策略迭代</a>
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
<a class="sidebar-link admin-link {% if active_nav == 'chat_logs' %}active{% endif %}" href="/chat-logs" style="display:none"><svg class="link-icon"><use href="#svg-chat"/></svg>问答日志</a>
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
<a class="sidebar-link {% if active_nav == 'system_logs' %}active{% endif %} admin-link" href="/system-logs" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>系统日志</a>
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>用户管理</a>
</nav>
<div class="sidebar-user" onclick="toggleUserMenu()">

161
static/logs.html Normal file
View File

@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}日志中心 · AlphaX Agent{% endblock %}
{% block extra_head_css %}
<style>
main{max-width:1380px;margin:0 auto;width:100%;padding:24px;display:flex;flex-direction:column;gap:14px}
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap}
.page-title{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:-.7px}
.page-sub{margin-top:5px;font-size:13px;color:var(--stone);line-height:1.55;max-width:860px}
.ops-strip{display:grid;grid-template-columns:1.25fr repeat(3,minmax(0,1fr));gap:10px}
.ops-card{position:relative;overflow:hidden;border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);background:linear-gradient(145deg,#fff 0%,#f7f8fa 100%);padding:15px;min-height:92px}
.ops-card::after{content:"";position:absolute;right:-32px;top:-48px;width:120px;height:120px;border-radius:50%;background:rgba(255,208,47,.22)}
.ops-card span{display:block;color:var(--stone);font-size:11px;font-weight:950;letter-spacing:.04em}
.ops-card b{display:block;margin-top:8px;color:var(--ink);font-size:24px;line-height:1;font-weight:950;letter-spacing:-.5px}
.ops-card small{display:block;margin-top:8px;color:var(--slate);font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tabs{display:flex;gap:8px;padding:6px;border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);background:var(--canvas);width:max-content;max-width:100%;overflow:auto}
.tab-btn{height:38px;border:0;background:transparent;border-radius:10px;padding:0 14px;font-size:13px;font-weight:950;color:var(--stone);cursor:pointer;white-space:nowrap}
.tab-btn.active{background:var(--primary);color:var(--on-primary)}
.panel{display:none;border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-lg);overflow:hidden}
.panel.active{display:block}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;padding:14px;border-bottom:1px solid var(--hairline-soft);background:linear-gradient(180deg,#fff,#fafbfc)}
.toolbar input,.toolbar select{height:38px;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);padding:0 12px;font-size:13px;color:var(--ink);outline:none}
.toolbar input{min-width:220px;flex:1}
.toolbar button{height:38px;border:0;border-radius:var(--radius-md);padding:0 14px;background:var(--primary);color:var(--on-primary);font-size:13px;font-weight:900;cursor:pointer}
.table-wrap{overflow:auto}
table{width:100%;border-collapse:collapse;min-width:980px}
th{padding:10px 12px;border-bottom:1px solid var(--hairline-soft);background:var(--surface);color:var(--stone);font-size:11px;font-weight:950;text-align:left;letter-spacing:.04em}
td{padding:11px 12px;border-bottom:1px solid var(--hairline-soft);font-size:12px;color:var(--ink);vertical-align:top}
tr:hover td{background:var(--surface)}
.badge{display:inline-flex;align-items:center;height:23px;border-radius:999px;padding:0 8px;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--stone);font-size:11px;font-weight:950;white-space:nowrap}
.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}
.badge.err{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}
.badge.warn{background:rgba(255,208,47,.16);border-color:rgba(255,208,47,.28);color:var(--yellow-dark)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:850}
.msg{max-width:460px;word-break:break-word;line-height:1.45;color:var(--slate)}
.empty,.loading{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:14px}
.detail{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-lg);padding:15px;min-height:220px;position:sticky;top:18px;max-height:calc(100vh - 40px);overflow:auto}
.detail h3{font-size:15px;margin-bottom:10px;color:var(--ink)}
.meta{display:grid;grid-template-columns:86px minmax(0,1fr);gap:7px 10px;font-size:12px;color:var(--stone);margin-bottom:12px}
.meta span:nth-child(2n){color:var(--ink);overflow:hidden;text-overflow:ellipsis}
.codebox{white-space:pre-wrap;word-break:break-word;background:#15171d;color:#eef0f5;border-radius:var(--radius-md);padding:12px;font-size:12px;line-height:1.55;max-height:520px;overflow:auto}
.mini-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;padding:14px;border-bottom:1px solid var(--hairline-soft)}
.mini{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:12px}
.mini span{display:block;color:var(--stone);font-size:11px;font-weight:900}.mini b{display:block;margin-top:5px;font-size:20px;color:var(--ink);font-weight:950}
.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;color:var(--stone);font-size:12px}
.pagination button{height:34px;border:1px solid var(--hairline);background:var(--surface);border-radius:var(--radius-md);padding:0 12px;color:var(--ink);cursor:pointer}.pagination button:disabled{opacity:.45;cursor:default}
@media(max-width:1050px){.ops-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.layout{grid-template-columns:1fr}.detail{position:static;max-height:none}.mini-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){main{padding:18px}.ops-strip{grid-template-columns:1fr}.page-title{font-size:23px}.tabs{width:100%}.tab-btn{flex:1}.mini-grid{grid-template-columns:1fr}}
</style>
{% endblock %}
{% block content %}
<main>
<div class="page-head">
<div>
<div class="page-title">日志中心</div>
<div class="page-sub">把系统错误、链上运行、调度任务、推荐链路和问答记录收在一个运维视图里,排障时不用在多个页面之间来回跳。</div>
</div>
<div class="tabs" id="tabs">
<button class="tab-btn active" data-tab="system" onclick="switchTab('system')">系统错误</button>
<button class="tab-btn" data-tab="onchain" onclick="switchTab('onchain')">链上运行</button>
<button class="tab-btn" data-tab="cron" onclick="switchTab('cron')">调度运行</button>
<button class="tab-btn" data-tab="pipeline" onclick="switchTab('pipeline')">链路批次</button>
<button class="tab-btn" data-tab="chat" onclick="switchTab('chat')">问答日志</button>
</div>
</div>
<div class="ops-strip" id="opsStrip"><div class="ops-card"><span>状态</span><b>加载中</b><small>正在读取运行日志</small></div></div>
<section class="panel active" id="panel-system">
<div class="layout">
<div class="panel active">
<div class="toolbar">
<input id="sysSearch" placeholder="搜索错误、路径、用户..." onkeydown="if(event.key==='Enter')loadSystem(0)">
<select id="sysLevel" onchange="loadSystem(0)"><option value="all">全部级别</option><option value="error">Error</option><option value="warning">Warning</option></select>
<select id="sysSource" onchange="loadSystem(0)"><option value="all">全部来源</option><option value="web">Web</option><option value="scheduler">Scheduler</option><option value="paper_trading">策略交易</option><option value="price_streamer">Price Streamer</option></select>
<select class="hoursSel" id="sysHours" onchange="loadSystem(0)"><option value="24">近 24h</option><option value="168" selected>近 7 天</option><option value="720">近 30 天</option><option value="0">全部</option></select>
<button onclick="loadSystem(0)">查询</button>
</div>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>来源</th><th>类型</th><th>消息</th><th>路径 / 用户</th><th>状态</th></tr></thead><tbody id="sysRows"><tr><td colspan="6" class="loading">加载中...</td></tr></tbody></table></div>
<div class="pagination" id="sysPager"></div>
</div>
<aside class="detail" id="sysDetail"><div class="empty">选择一条系统错误查看堆栈。</div></aside>
</div>
</section>
<section class="panel" id="panel-onchain">
<div class="mini-grid" id="onchainMini"><div class="mini"><span>NodeReal</span><b>--</b></div></div>
<div class="toolbar">
<select id="onchainHours" onchange="loadOnchain()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
<button onclick="loadOnchain()">刷新</button>
<a class="badge" href="/onchain">打开链上异动页</a>
</div>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>任务</th><th>运行</th><th>结果</th><th>耗时</th><th>摘要</th><th>错误</th></tr></thead><tbody id="onchainRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
</section>
<section class="panel" id="panel-cron">
<div class="mini-grid" id="cronMini"><div class="mini"><span>调度</span><b>--</b></div></div>
<div class="toolbar">
<select id="cronJob" onchange="loadCron()"><option value="">全部任务</option><option value="链上">链上</option><option value="粗筛">粗筛</option><option value="爆发确认">爆发确认</option><option value="price-streamer">Price Streamer</option><option value="策略交易">策略交易</option></select>
<select id="cronHours" onchange="loadCron()"><option value="24">近 24h</option><option value="168">近 7 天</option></select>
<button onclick="loadCron()">刷新</button>
<a class="badge" href="/cron">打开调度中心</a>
</div>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>任务</th><th>运行</th><th>结果</th><th>耗时</th><th>摘要</th><th>错误</th></tr></thead><tbody id="cronRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
</section>
<section class="panel" id="panel-pipeline">
<div class="mini-grid" id="pipeMini"><div class="mini"><span>链路</span><b>--</b></div></div>
<div class="toolbar">
<select id="pipeHours" onchange="loadPipeline(0)"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
<button onclick="loadPipeline(0)">刷新</button>
<a class="badge" href="/pipeline">打开原链路页</a>
</div>
<div class="table-wrap"><table><thead><tr><th>批次</th><th>时间</th><th>漏斗</th><th>推荐</th><th>转化</th><th>复盘</th><th>状态</th></tr></thead><tbody id="pipeRows"><tr><td colspan="7" class="loading">加载中...</td></tr></tbody></table></div>
<div class="pagination" id="pipePager"></div>
</section>
<section class="panel" id="panel-chat">
<div class="mini-grid" id="chatMini"><div class="mini"><span>问答</span><b>--</b></div></div>
<div class="toolbar">
<input id="chatSearch" placeholder="搜索问题、回答..." onkeydown="if(event.key==='Enter')loadChat(0)">
<select id="chatIntent" onchange="loadChat(0)"><option value="all">全部意图</option><option value="coin_analysis">币种分析</option><option value="onchain">链上异动</option><option value="sentiment">舆情</option><option value="review">复盘</option></select>
<select id="chatHours" onchange="loadChat(0)"><option value="24">近 24h</option><option value="168">近 7 天</option></select>
<button onclick="loadChat(0)">查询</button>
<a class="badge" href="/chat-logs">打开原问答日志</a>
</div>
<div class="table-wrap"><table><thead><tr><th>时间</th><th>用户</th><th>意图</th><th>问题</th><th>回答摘要</th></tr></thead><tbody id="chatRows"><tr><td colspan="5" class="loading">加载中...</td></tr></tbody></table></div>
<div class="pagination" id="chatPager"></div>
</section>
</main>
{% endblock %}
{% block password_modal %}{% endblock %}
{% block extra_script %}
<script>
var API='',PAGE=50,state={tab:'system',sysOffset:0,sysTotal:0,pipeOffset:0,pipeTotal:0,chatOffset:0,chatTotal:0};
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function short(s,n){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
function time(ts){if(!ts)return'--';var d=new Date(ts);if(isNaN(d.getTime()))return String(ts).slice(0,19).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0')}
function dur(ms){ms=Number(ms||0);if(ms>=1000)return (ms/1000).toFixed(1)+'s';return ms+'ms'}
function badge(v){var s=String(v||'');var cls=s==='success'||s==='processed'||s==='ok'?'ok':(s==='error'||s.indexOf('fail')>=0?'err':(s==='no_onchain_data'||s==='warning'?'warn':''));return '<span class="badge '+cls+'">'+esc(s||'--')+'</span>'}
function summary(x){try{if(typeof x==='string')x=JSON.parse(x||'{}')}catch(e){};return Object.keys(x||{}).slice(0,6).map(function(k){return k+': '+x[k]}).join(' · ')||'--'}
function switchTab(tab){state.tab=tab;document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.toggle('active',b.dataset.tab===tab)});document.querySelectorAll('section.panel').forEach(function(p){p.classList.toggle('active',p.id==='panel-'+tab)});if(tab==='system')loadSystem(state.sysOffset||0);if(tab==='onchain')loadOnchain();if(tab==='cron')loadCron();if(tab==='pipeline')loadPipeline(state.pipeOffset||0);if(tab==='chat')loadChat(state.chatOffset||0)}
async function ensureAdmin(){try{var r=await fetch('/api/admin/check');var d=await r.json();if(!d.is_admin)location.href='/subscription'}catch(e){location.href='/subscription'}}
function renderOps(data){document.getElementById('opsStrip').innerHTML=data.map(function(x){return '<div class="ops-card"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><small>'+esc(x[2]||'')+'</small></div>'}).join('')}
async function loadOps(){try{var s=await(await fetch('/api/admin/system-errors/stats?hours=24')).json();var c=await(await fetch('/api/admin/cron-runs/summary?hours=24')).json();var oc=await(await fetch('/api/onchain/provider-status?hours=24')).json();renderOps([['系统错误',s.total||0,'近 24 小时'],['调度成功率',(c.overall||{}).success_rate+'%','运行 '+((c.overall||{}).total_runs||0)+' 次'],['链上任务',(oc.last_run||{}).result_status||'--',(oc.last_error||'NodeReal 状态正常')],['链上信号',(oc.coverage||{}).signals||0,'当前窗口标准事件']])}catch(e){renderOps([['状态','加载失败','请检查接口权限']])}}
async function loadSystem(offset){state.sysOffset=offset;loadOps();var q=sysSearch.value.trim(),level=sysLevel.value,source=sysSource.value,h=sysHours.value;sysRows.innerHTML='<tr><td colspan="6" class="loading">加载中...</td></tr>';try{var d=await(await fetch('/api/admin/system-errors?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+PAGE+'&level='+level+'&source='+source+'&hours='+h)).json();state.sysTotal=d.total||0;sysRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr onclick="loadSystemDetail('+x.id+')" style="cursor:pointer"><td>'+time(x.created_at)+'</td><td>'+badge(x.source||'app')+'</td><td>'+esc(x.error_type||'Error')+'</td><td class="msg">'+esc(short(x.message,140))+'</td><td class="msg">'+esc(short((x.request_path||'--')+(x.user_email?' · '+x.user_email:''),80))+'</td><td>'+badge(x.level||x.status_code)+'</td></tr>'}).join(''):'<tr><td colspan="6" class="empty">暂无系统错误</td></tr>';pager('sysPager',offset,state.sysTotal,'loadSystem')}catch(e){sysRows.innerHTML='<tr><td colspan="6" class="empty">加载失败</td></tr>'}}
async function loadSystemDetail(id){sysDetail.innerHTML='<div class="loading">加载详情...</div>';try{var d=await(await fetch('/api/admin/system-errors/'+id)).json();sysDetail.innerHTML='<h3>#'+esc(d.id)+' · '+esc(d.error_type||'Error')+'</h3><div class="meta"><span>时间</span><span>'+time(d.created_at)+'</span><span>来源</span><span>'+esc(d.source||'app')+' · PID '+esc(d.pid||0)+'</span><span>路径</span><span>'+esc((d.request_method||'')+' '+(d.request_path||'--'))+'</span><span>用户</span><span>'+esc(d.user_email||'--')+'</span><span>指纹</span><span>'+esc(d.fingerprint||'--')+'</span><span>消息</span><span>'+esc(d.message||'--')+'</span></div><div class="codebox">'+esc(d.stack_trace||'无堆栈信息')+'</div>'}catch(e){sysDetail.innerHTML='<div class="empty">详情加载失败</div>'}}
async function loadOnchain(){loadOps();onchainRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var h=onchainHours.value;var st=await(await fetch('/api/onchain/provider-status?hours='+h)).json();var rows=await(await fetch('/api/admin/cron-runs?job_name='+encodeURIComponent('链上')+'&limit=80')).json();var p=(st.providers||[])[0]||{};onchainMini.innerHTML=[['NodeReal',p.status||'--',p.api_key_present?'Key 已配置':'无 Key'],['标准信号',(st.coverage||{}).signals||0,'近 '+h+'h'],['Metrics',(st.coverage||{}).metrics||0,'链上指标'],['最近结果',(st.last_run||{}).result_status||'--',st.last_error||'无错误'],['映射合约',(st.coverage||{}).usable_mappings||0,'可采集合约']].map(mini).join('');renderCronRows('onchainRows',rows.items||[])}catch(e){onchainRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
async function loadCron(){loadOps();cronRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var s=await(await fetch('/api/admin/cron-runs/summary?hours='+cronHours.value)).json();cronMini.innerHTML=[['总运行',(s.overall||{}).total_runs||0,'近 '+cronHours.value+'h'],['成功率',(s.overall||{}).success_rate+'%','调度稳定性'],['失败',(s.overall||{}).error_runs||0,'异常任务'],['平均耗时',dur((s.overall||{}).avg_duration_ms),'单次任务'],['任务数',(s.job_stats||[]).length,'已配置任务']].map(mini).join('');var d=await(await fetch('/api/admin/cron-runs?job_name='+encodeURIComponent(cronJob.value)+'&limit=100')).json();renderCronRows('cronRows',d.items||[])}catch(e){cronRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
function renderCronRows(id,items){var el=document.getElementById(id);el.innerHTML=items.length?items.map(function(x){return '<tr><td>'+time(x.started_at)+'</td><td>'+esc(x.job_name||'--')+'</td><td>'+badge(x.run_status)+'</td><td>'+badge(x.result_status)+'</td><td>'+dur(x.duration_ms)+'</td><td class="msg">'+esc(summary(x.summary_json))+'</td><td class="msg">'+esc(short(x.error_message||'',180))+'</td></tr>'}).join(''):'<tr><td colspan="7" class="empty">暂无运行日志</td></tr>'}
async function loadPipeline(offset){state.pipeOffset=offset;loadOps();pipeRows.innerHTML='<tr><td colspan="7" class="loading">加载中...</td></tr>';try{var d=await(await fetch('/api/admin/pipeline-runs?hours='+pipeHours.value+'&limit=30&offset='+offset)).json();var k=d.kpi||{};pipeMini.innerHTML=[['批次数',k.run_count||0,'粗筛批次'],['宇宙过滤',k.universe_gate_count||0,'候选入口'],['质量通过',k.quality_pass_count||0,'过滤后样本'],['交易确认',k.trade_confirm_count||0,'确认机会'],['推荐转化',(k.recommendation_rate||0)+'%','推荐/合格']].map(mini).join('');var p=d.pagination||{};state.pipeTotal=p.total_count||0;pipeRows.innerHTML=(d.runs||[]).length?(d.runs||[]).map(function(x){return '<tr><td class="mono">#'+esc(x.run_id||x.id)+'</td><td>'+time(x.started_at)+'</td><td>'+esc((x.universe_gate_count||0)+' / '+(x.discovery_count||0)+' / '+(x.quality_pass_count||0))+'</td><td>'+esc(x.recommendations||0)+'</td><td>'+esc((x.recommendation_rate||0)+'%')+'</td><td>'+esc((x.perf_success||0)+' / '+(x.perf_failed||0)+' / '+(x.missed_count||0))+'</td><td>'+badge(x.result_status||x.run_status)+'</td></tr>'}).join(''):'<tr><td colspan="7" class="empty">暂无链路批次</td></tr>';pager('pipePager',offset,state.pipeTotal,'loadPipeline',30)}catch(e){pipeRows.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>'}}
async function loadChat(offset){state.chatOffset=offset;loadOps();chatRows.innerHTML='<tr><td colspan="5" class="loading">加载中...</td></tr>';try{var ov=await(await fetch('/api/admin/chat-logs/overview?hours='+chatHours.value)).json();chatMini.innerHTML=[['提问数',ov.total_questions||0,'近 '+chatHours.value+'h'],['会话数',ov.total_sessions||0,'涉及 '+(ov.total_users||0)+' 位用户'],['链上问题',((ov.top_intents||[]).find(function(x){return x.intent==='onchain'})||{}).n||0,'onchain intent'],['消息数',ov.total_messages||0,'用户与助手消息'],['热门意图',((ov.top_intents||[])[0]||{}).intent||'--','当前最常见']].map(mini).join('');var d=await(await fetch('/api/admin/chat-logs?search='+encodeURIComponent(chatSearch.value.trim())+'&intent='+encodeURIComponent(chatIntent.value)+'&hours='+chatHours.value+'&offset='+offset+'&limit='+PAGE)).json();state.chatTotal=d.total||0;chatRows.innerHTML=(d.items||[]).length?(d.items||[]).map(function(x){return '<tr><td>'+time(x.created_at)+'</td><td>'+esc(x.user_email||'--')+'</td><td>'+badge(x.intent||'--')+'</td><td class="msg">'+esc(short(x.content_text||'',170))+'</td><td class="msg">'+esc(short((x.symbol?x.symbol+' · ':'')+(x.session_title||('会话 #'+x.session_id)),120))+'</td></tr>'}).join(''):'<tr><td colspan="5" class="empty">暂无问答日志</td></tr>';pager('chatPager',offset,state.chatTotal,'loadChat')}catch(e){chatRows.innerHTML='<tr><td colspan="5" class="empty">加载失败</td></tr>'}}
function mini(x){return '<div class="mini"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><span>'+esc(x[2]||'')+'</span></div>'}
function pager(id,offset,total,fn,size){size=size||PAGE;var cur=Math.floor(offset/size)+1,totalPages=Math.max(1,Math.ceil((total||0)/size));document.getElementById(id).innerHTML='<button '+(offset<=0?'disabled':'')+' onclick="'+fn+'('+(offset-size)+')">上一页</button><span>第 '+cur+' / '+totalPages+' 页 · 共 '+(total||0)+' 条</span><button '+(offset+size>=total?'disabled':'')+' onclick="'+fn+'('+(offset+size)+')">下一页</button>'}
(async function(){await ensureAdmin();loadOps();switchTab('system')})();
</script>
{% endblock %}

View File

@ -8,7 +8,7 @@
{% block content %}
<div class="shell">
<div class="page-head">
<div><h1>链上异动</h1><p>跟踪 DEX 放量、流动性变化、资金流和鲸鱼行为。链上信号只负责发现线索,最终仍交给技术确认。</p></div>
<div><h1>链上异动</h1><p>以 NodeReal 为主数据源,跟踪 EVM 大额转账、holder 变化和鲸鱼行为。链上信号只负责发现线索,最终仍交给技术确认。</p></div>
<div class="head-actions">
<select class="select" id="hoursSel" onchange="reloadAll()"><option value="24">近 24h</option><option value="72">近 3 天</option><option value="168">近 7 天</option></select>
<button class="btn" onclick="reloadAll()">刷新</button>
@ -18,7 +18,7 @@
<div class="source-strip" id="providerStatus"><div class="loading">加载数据源状态...</div></div>
<div class="flow-strip" id="flowStatus"></div>
<section class="panel raw-panel">
<div class="panel-head"><div class="panel-title">重要链上事件</div><div class="panel-note">默认隐藏低优先级曝光源</div></div>
<div class="panel-head"><div class="panel-title">重要链上事件</div><div class="panel-note">NodeReal 主链路事件优先</div></div>
<div class="raw-toolbar">
<div class="head-actions">
<button class="btn" onclick="setRawPriority('important')">重要事件</button>
@ -64,7 +64,7 @@ function fmtAmount(v){v=Number(v||0);if(v>=1000)return v.toFixed(0);if(v>0)retur
function recLabel(r){if(!r||!r.has_active)return '<span class="badge">未进入</span>';var s=r.execution_status||'';if(s==='buy_now')return '<span class="badge pos">入场窗口</span>';if(s==='wait_pullback')return '<span class="badge blue">等回踩</span>';return '<span class="badge blue">'+esc(r.action_status||'观察中')+'</span>'}
function setRawPriority(v){state.rawPriority=v;reloadRawEvents(0)}
function statusBadgeClass(s){s=String(s||'');if(s.indexOf('正常')>=0)return 'pos';if(s.indexOf('失败')>=0||s.indexOf('未接入')>=0)return 'warn';if(s.indexOf('关闭')>=0)return '';return 'blue'}
function providerCard(p){var stats=[['原始',p.raw_events||0],['指标',p.metrics||0],['信号',p.signals||0]].map(function(x){return '<span class="badge">'+x[0]+' '+x[1]+'</span>'}).join('');var key=p.api_key_present?'<span class="badge mapped">Key 已配置</span>':'<span class="badge unmapped">无 Key</span>';var impl=p.implemented?'<span class="badge blue">已接入</span>':'<span class="badge warn">待接入</span>';var foot=p.provider==='dexscreener'?'低优先级曝光源:适合看市场营销热度,不适合单独作为链上机会。':(p.provider==='etherscan'?'高价值资金流源:当前抓取 EVM 大额 ERC20 转账。':'高价值 Solana 源:当前抓取已映射 mint 的解析交易。');return '<div class="source-card"><div class="source-top"><div class="source-name">'+esc(p.label||p.provider)+'</div><span class="badge '+statusBadgeClass(p.status)+'">'+esc(p.status||'--')+'</span></div><div class="source-role">'+esc(p.role||'--')+'</div><div class="source-stats">'+stats+key+impl+'</div><div class="source-foot">'+foot+'</div></div>'}
function providerCard(p){var stats=[['原始',p.raw_events||0],['指标',p.metrics||0],['信号',p.signals||0]].map(function(x){return '<span class="badge">'+x[0]+' '+x[1]+'</span>'}).join('');var key=p.api_key_present?'<span class="badge mapped">Key 已配置</span>':'<span class="badge unmapped">无 Key</span>';var impl=p.implemented?'<span class="badge blue">已接入</span>':'<span class="badge warn">待接入</span>';var foot=p.provider==='nodereal'?'主链上数据源:当前负责 EVM Transfer 日志、大额转账和 holder 变化。':(p.status==='已关闭'?'已从主链路移除,保留仅用于兼容和历史数据展示。':'辅助来源:不再作为默认链上主链路。');return '<div class="source-card"><div class="source-top"><div class="source-name">'+esc(p.label||p.provider)+'</div><span class="badge '+statusBadgeClass(p.status)+'">'+esc(p.status||'--')+'</span></div><div class="source-role">'+esc(p.role||'--')+'</div><div class="source-stats">'+stats+key+impl+'</div><div class="source-foot">'+foot+'</div></div>'}
function renderProviderStatus(s){var providers=s.providers||[];$('providerStatus').innerHTML=providers.length?providers.map(providerCard).join(''):'<div class="empty">暂无数据源状态</div>';var c=s.coverage||{};var steps=[['原始流',c.raw_events||0],['币种映射',c.usable_mappings||0],['标准信号',c.signals||0],['技术检查候选',c.queued_candidates||0]];$('flowStatus').innerHTML=steps.map(function(x){return '<div class="flow-step"><span>'+x[0]+'</span><b>'+x[1]+'</b></div>'}).join('')}
function renderKpis(k){var cells=[['原始流',k.raw_event_count||0,'blue'],['已映射原始流',k.raw_mapped_count||0,'green'],['映射币种',k.token_count||0,'blue'],['标准正向信号',k.positive_events||0,'green'],['标准风险信号',k.risk_events||0,'red'],['DEX 成交',fmtUsd(k.dex_volume_usd||0),'blue']];$('kpis').innerHTML=cells.map(function(x){return '<div class="kpi"><span>'+x[0]+'</span><b class="'+x[2]+'">'+x[1]+'</b></div>'}).join('')}
function tokenRow(t, risk){return '<div class="token-row" onclick="loadDetail(\''+esc(t.symbol)+'\')"><div class="token-main"><div class="sym">'+esc(t.symbol)+'</div><div class="sub">'+esc(t.chain)+' · DEX '+fmtUsd(t.dex_volume_usd)+' · 流动性 '+fmtUsd(t.liquidity_usd)+'</div></div><div class="score '+(risk?'risk':'')+'">'+Number(risk?t.risk_score:t.onchain_score||0).toFixed(0)+'</div></div>'}

View File

@ -46,7 +46,8 @@ tr:hover td { background:var(--surface); }
<div class="page-head">
<div>
<div class="page-title">系统日志</div>
<div class="page-sub">集中查看 Web、CLI、Scheduler 的内部错误、上下文和堆栈信息。</div>
<div class="page-sub">集中查看 Web、CLI、Scheduler 的内部错误、上下文和堆栈信息。新版入口见 <a href="/logs">日志中心</a></div>
<a class="active admin-link" href="/system-logs" style="display:none">系统日志兼容入口</a>
</div>
</div>

View File

@ -61,6 +61,7 @@ def test_dex_signal_codes_from_metric(monkeypatch, tmp_path):
def test_auto_mapping_rejects_non_target_native_and_wrapped_tokens(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
cfg = onchain_monitor.get_onchain_params()
chains = set(cfg.get("chains") or [])
@ -198,7 +199,7 @@ def test_onchain_api_and_page(monkeypatch, tmp_path):
overview = client.get("/api/onchain/overview")
assert overview.status_code == 200
assert overview.json()["kpi"]["token_count"] == 1
assert overview.json()["provider_status"]["providers"][0]["provider"] == "dexscreener"
assert overview.json()["provider_status"]["providers"][0]["provider"] == "nodereal"
tokens = client.get("/api/onchain/tokens")
assert tokens.status_code == 200
@ -213,6 +214,7 @@ def test_raw_dexscreener_events_store_without_mapping(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ONCHAIN_ENABLED", "1")
monkeypatch.setenv("ALPHAX_ONCHAIN_CHAINS", "ethereum,solana")
monkeypatch.setenv("ALPHAX_ONCHAIN_DEXSCREENER_ENABLED", "1")
def fake_request(url, params=None, timeout=15):
if "token-profiles" in url:
@ -286,9 +288,9 @@ def test_raw_event_api_and_overview_counts(monkeypatch, tmp_path):
assert low.json()["total"] == 1
def test_etherscan_events_generate_normalized_onchain_event(monkeypatch, tmp_path):
def test_nodereal_events_generate_metrics_and_normalized_event(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_ETHERSCAN_API_KEY", "test-key")
monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
onchain_db.upsert_token_mapping("ABC", "ethereum", "0xabc", source="manual", confidence=95)
onchain_db.insert_token_metric(
{
@ -304,80 +306,53 @@ def test_etherscan_events_generate_normalized_onchain_event(monkeypatch, tmp_pat
}
)
def fake_request(url, params=None, timeout=15):
assert params["chainid"] == "1"
assert params["contractaddress"] == "0xabc"
return {
"status": "1",
"message": "OK",
"result": [
{
"hash": "0xtx",
"timeStamp": "1700000000",
"from": "0xfrom",
"to": "0xto",
"value": "200000000000000000000000",
"tokenDecimal": "18",
}
],
}
class FakeNodeRealClient:
def supports_chain(self, chain):
return chain == "ethereum"
monkeypatch.setattr(onchain_monitor, "_request_json", fake_request)
result = onchain_monitor.fetch_etherscan_events(limit=10)
def token_holder_count(self, chain, contract):
assert chain == "ethereum"
assert contract == "0xabc"
return 120
def block_number(self, chain):
assert chain == "ethereum"
return 1000
def get_logs(self, chain, log_filter):
assert log_filter["address"] == "0xabc"
return [
{
"transactionHash": "0xtx",
"data": hex(200000 * 10**18),
"topics": [
onchain_monitor.TRANSFER_TOPIC,
"0x0000000000000000000000001111111111111111111111111111111111111111",
"0x0000000000000000000000002222222222222222222222222222222222222222",
],
}
]
monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: FakeNodeRealClient())
result = onchain_monitor.fetch_nodereal_events(limit=10)
assert result["errors"] == []
assert 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"] == "etherscan"
assert events["items"][0]["source"] == "nodereal"
assert events["items"][0]["signal_code"] == "whale_accumulation"
def test_helius_events_generate_normalized_onchain_event(monkeypatch, tmp_path):
def test_legacy_helius_is_disabled_by_default(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_HELIUS_API_KEY", "test-key")
onchain_db.upsert_token_mapping("SOLX", "solana", "Mint111", source="manual", confidence=95)
onchain_db.insert_token_metric(
{
"symbol": "SOLX/USDT",
"chain": "solana",
"contract_address": "Mint111",
"window": "1h",
"metric_time": datetime.now().isoformat(),
"dex_volume_usd": 100000,
"liquidity_usd": 100000,
"source": "test",
"raw": {"price_usd": "5"},
}
)
def fake_request(url, params=None, timeout=15):
assert "api-key=test-key" in url
return [
{
"signature": "sig111",
"timestamp": 1700000000,
"tokenTransfers": [
{
"mint": "Mint111",
"tokenAmount": 60000,
"fromUserAccount": "fromSol",
"toUserAccount": "toSol",
}
],
"nativeTransfers": [],
}
]
monkeypatch.setattr(onchain_monitor, "_request_json", fake_request)
result = onchain_monitor.fetch_helius_events(limit=10)
assert result["errors"] == []
assert len(result["events"]) == 1
events = onchain_db.list_onchain_events(hours=50000)
assert events["total"] == 1
assert events["items"][0]["source"] == "helius"
assert events["items"][0]["signal_code"] == "whale_accumulation"
assert result["events"] == []
assert result["errors"] == ["helius_disabled"]
def test_scheduler_seeds_onchain_job(monkeypatch, tmp_path):

View File

@ -561,10 +561,12 @@ def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec):
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
pushed = []
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
activated = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
@ -582,6 +584,21 @@ def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy
assert trade["status"] == "closed"
assert trade["exit_reason"] == "trailing_stop"
assert trade["exit_price"] > trade["entry_price"]
assert pushed[-1]["metadata"]["event_type"] == "close"
assert "移动止盈成交平仓" in pushed[-1]["header"]["title"]["content"]
_assert_no_paper_trading_copy(pushed[-1])
def test_paper_push_failure_is_recorded(monkeypatch, buy_now_rec):
errors = []
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: (False, "webhook failed"))
monkeypatch.setattr("app.db.paper_trading.record_system_error", lambda **kwargs: errors.append(kwargs) or 1)
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
assert errors
assert errors[0]["error_type"] == "FeishuPushFailed"
assert errors[0]["context"]["event_type"] == "open"
def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):