diff --git a/.env.example b/.env.example index 39ffda1..f3c0e97 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,21 @@ ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER=0 ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT=12 ALPHAX_PAPER_ORDER_EXPIRE_HOURS=24 +# 策略交易移动止盈。volatility 会按持仓后实际高低价波动动态调整启动阈值和保护距离。 +ALPHAX_PAPER_TRAILING_STOP_ENABLED=1 +ALPHAX_PAPER_TRAILING_MODE=volatility +ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT=3 +ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT=0.5 +ALPHAX_PAPER_TRAILING_DISTANCE_PCT=1.5 +ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT=2.5 +ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT=8 +ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT=0.6 +ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT=1.2 +ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT=8 +ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT=0.7 +ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS=300 +ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT=2 + ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0 ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK= diff --git a/app/config/system_config.py b/app/config/system_config.py index d56667d..4a2c88e 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -104,9 +104,16 @@ def default_paper_trading_config(): "fee_rate": _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001), "slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05), "trailing_stop_enabled": _env_bool("ALPHAX_PAPER_TRAILING_STOP_ENABLED", True), + "trailing_mode": _env_str("ALPHAX_PAPER_TRAILING_MODE", "volatility"), "trailing_activate_pnl_pct": _env_float("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", 3.0), "trailing_min_lock_profit_pct": _env_float("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", 0.5), "trailing_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", 1.5), + "trailing_volatility_min_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT", 2.5), + "trailing_volatility_max_activation_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT", 8.0), + "trailing_volatility_activation_mult": _env_float("ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT", 0.6), + "trailing_volatility_min_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT", 1.2), + "trailing_volatility_max_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT", 8.0), + "trailing_volatility_distance_mult": _env_float("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", 0.7), "trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300), "trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0), "order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True), diff --git a/app/db/onchain_db.py b/app/db/onchain_db.py index ff709db..0c8e387 100644 --- a/app/db/onchain_db.py +++ b/app/db/onchain_db.py @@ -455,7 +455,7 @@ def get_onchain_overview(hours=24): }, "hot_tokens": ([_format_metric_item(row, active) for row in hot] or mapped_feed), "risk_tokens": [_format_metric_item(row, active) for row in risks], - "raw_events": [_format_raw_event(row) for row in raw_latest], + "raw_events": _format_raw_events(raw_latest), "signals": _signal_counts(standard_events), "provider_status": get_onchain_provider_status(hours=hours), } @@ -792,7 +792,7 @@ def get_onchain_token_detail(symbol, hours=72): "hours": int(hours or 72), "mappings": [_with_raw(row) for row in mappings], "events": [_with_raw(row) for row in events], - "raw_events": [_format_raw_event(row) for row in raw_events], + "raw_events": _format_raw_events(raw_events), "raw_event_count": len(raw_events), "metrics": [_with_raw(row) for row in metrics], "recommendation": dict(rec) if rec else None, @@ -871,7 +871,7 @@ def list_onchain_raw_events(limit=50, offset=0, chain="", source="", event_type= ).fetchall() conn.close() return { - "items": [_format_raw_event(row) for row in rows], + "items": _format_raw_events(rows), "total": int(total or 0), "limit": limit, "offset": offset, @@ -900,13 +900,64 @@ def _with_raw(row): return item -def _format_raw_event(row): +def _format_raw_events(rows): + rows = list(rows or []) + metadata = _raw_event_token_metadata(rows) + return [_format_raw_event(row, metadata.get(_raw_event_token_key(row), {})) for row in rows] + + +def _raw_event_token_key(row): + item = dict(row) + return (str(item.get("chain") or "").lower(), str(item.get("token_address") or "").lower()) + + +def _raw_event_token_metadata(rows): + keys = sorted({key for key in (_raw_event_token_key(row) for row in rows or []) if key[0] and key[1]}) + if not keys: + return {} + clauses = [] + params = [] + for chain, contract in keys: + clauses.append("(chain=%s AND lower(contract_address)=lower(%s))") + params.extend([chain, contract]) + conn = get_conn() + try: + found = conn.execute( + f""" + SELECT chain, contract_address, symbol, raw_json + FROM onchain_token_map + WHERE is_active=1 AND ({' OR '.join(clauses)}) + ORDER BY confidence DESC, updated_at DESC + """, + tuple(params), + ).fetchall() + finally: + conn.close() + metadata = {} + for row in found: + key = (str(row["chain"] or "").lower(), str(row["contract_address"] or "").lower()) + if key in metadata: + continue + raw = _load(row["raw_json"], {}) or {} + metadata[key] = { + "symbol": normalize_symbol(row["symbol"]), + "token_symbol": raw.get("symbol") or _symbol_base(row["symbol"]), + "name": raw.get("name") or "", + "decimals": int(raw.get("decimals") or 0), + } + return metadata + + +def _format_raw_event(row, token_meta=None): item = _with_raw(row) + token_meta = token_meta or {} item["event_label"] = raw_event_type_label(item.get("event_type")) explainer = raw_event_explainer(item.get("event_type")) item["plain_summary"] = explainer.get("plain") or "" item["why_matters"] = explainer.get("meaning") or "" item["priority"] = explainer.get("priority") or "medium" + display = _humanize_raw_transfer(item, token_meta) + item.update(display) item["pipeline_note"] = ( "已映射,可进入后续链上信号分析。" if item.get("mapping_status") == "mapped" @@ -916,6 +967,61 @@ def _format_raw_event(row): return item +def _humanize_raw_transfer(item, token_meta): + token_symbol = token_meta.get("token_symbol") or item.get("symbol_guess") or _symbol_base(item.get("mapped_symbol")) or "Token" + decimals = int(token_meta.get("decimals") or 0) + amount = float(item.get("total_amount") or item.get("amount") or 0) + display_amount = amount + if decimals > 0 and amount >= 10**decimals: + display_amount = amount / (10**decimals) + raw = item.get("raw") or {} + topics = raw.get("topics") if isinstance(raw, dict) else [] + from_addr = _topic_address(topics[1]) if isinstance(topics, list) and len(topics) > 1 else "" + to_addr = _topic_address(topics[2]) if isinstance(topics, list) and len(topics) > 2 else "" + mapped = item.get("mapped_symbol") or normalize_symbol(token_symbol) + amount_label = f"{_compact_number(display_amount)} {token_symbol}" if display_amount else f"未知数量 {token_symbol}" + route = "" + if from_addr and to_addr: + route = f"从 {_short_address(from_addr)} 转至 {_short_address(to_addr)}" + elif to_addr: + route = f"转入 {_short_address(to_addr)}" + summary = f"{mapped} 出现一笔 ERC-20 转账,数量约 {amount_label}" + if route: + summary += f",{route}" + return { + "display_amount": round(display_amount, 8) if display_amount else 0, + "display_amount_label": amount_label, + "from_address": from_addr, + "to_address": to_addr, + "from_short": _short_address(from_addr), + "to_short": _short_address(to_addr), + "human_summary": summary, + } + + +def _topic_address(topic): + topic = str(topic or "") + if topic.startswith("0x") and len(topic) >= 42: + return "0x" + topic[-40:] + return "" + + +def _compact_number(value): + value = float(value or 0) + abs_value = abs(value) + if abs_value >= 1_000_000_000: + return f"{value / 1_000_000_000:.2f}B" + if abs_value >= 1_000_000: + return f"{value / 1_000_000:.2f}M" + if abs_value >= 1_000: + return f"{value / 1_000:.2f}K" + if abs_value >= 1: + return f"{value:.2f}".rstrip("0").rstrip(".") + if abs_value > 0: + return f"{value:.6f}".rstrip("0").rstrip(".") + return "0" + + def _short_address(value): value = str(value or "") if len(value) <= 14: diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index c73cb10..4728d81 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -106,9 +106,16 @@ def _trailing_config() -> dict: cfg = paper_trading_config() return { "enabled": bool(cfg.get("trailing_stop_enabled", True)), + "mode": str(cfg.get("trailing_mode") or "volatility").strip().lower(), "activate_pnl_pct": max(0.0, _safe_float(cfg.get("trailing_activate_pnl_pct"), 3.0)), "min_lock_profit_pct": max(0.0, _safe_float(cfg.get("trailing_min_lock_profit_pct"), 0.5)), "distance_pct": max(0.1, _safe_float(cfg.get("trailing_distance_pct"), 1.5)), + "vol_min_activation_pct": max(0.0, _safe_float(cfg.get("trailing_volatility_min_activation_pct"), 2.5)), + "vol_max_activation_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_activation_pct"), 8.0)), + "vol_activation_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_activation_mult"), 0.6)), + "vol_min_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_min_distance_pct"), 1.2)), + "vol_max_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_distance_pct"), 8.0)), + "vol_distance_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_distance_mult"), 0.7)), "move_push_min_interval_seconds": max(0, _safe_int(cfg.get("trailing_move_push_min_interval_seconds"), 300)), "move_push_min_step_pct": max(0.0, _safe_float(cfg.get("trailing_move_push_min_step_pct"), 2.0)), "tiers": cfg.get("trailing_tiers") if isinstance(cfg.get("trailing_tiers"), list) else [], @@ -127,6 +134,58 @@ def _trailing_distance_pct(pnl_pct: float, cfg: dict) -> tuple[float, str]: return distance, label +def _clamp(value: float, min_value: float, max_value: float) -> float: + low = min(min_value, max_value) + high = max(min_value, max_value) + return max(low, min(high, value)) + + +def _trade_observed_volatility_pct(trade: dict, current_price: float) -> float: + entry = _safe_float(trade.get("entry_price")) + if entry <= 0 or current_price <= 0: + return 0.0 + high = max(_safe_float(trade.get("max_price")) or entry, current_price, entry) + low = min(_safe_float(trade.get("min_price")) or entry, current_price, entry) + return round(max(0.0, (high - low) / entry * 100), 6) + + +def _dynamic_trailing_profile(trade: dict, current_price: float, pnl_pct: float, cfg: dict) -> dict: + base_activate = _safe_float(cfg.get("activate_pnl_pct"), 3.0) + base_distance, tier_label = _trailing_distance_pct(pnl_pct, cfg) + volatility_pct = _trade_observed_volatility_pct(trade, current_price) + if str(cfg.get("mode") or "volatility").lower() != "volatility": + return { + "mode": "fixed", + "volatility_pct": volatility_pct, + "activate_pnl_pct": base_activate, + "distance_pct": base_distance, + "tier_label": tier_label, + } + + dynamic_activate = max(base_activate, volatility_pct * _safe_float(cfg.get("vol_activation_mult"), 0.6)) + dynamic_activate = _clamp( + dynamic_activate, + _safe_float(cfg.get("vol_min_activation_pct"), 2.5), + _safe_float(cfg.get("vol_max_activation_pct"), 8.0), + ) + dynamic_distance = max(base_distance, volatility_pct * _safe_float(cfg.get("vol_distance_mult"), 0.7)) + dynamic_distance = _clamp( + dynamic_distance, + _safe_float(cfg.get("vol_min_distance_pct"), 1.2), + _safe_float(cfg.get("vol_max_distance_pct"), 8.0), + ) + label = tier_label or "波动率" + return { + "mode": "volatility", + "volatility_pct": volatility_pct, + "activate_pnl_pct": round(dynamic_activate, 6), + "distance_pct": round(dynamic_distance, 6), + "tier_label": label, + "base_activate_pnl_pct": base_activate, + "base_distance_pct": base_distance, + } + + def _parse_time(value: str) -> datetime | None: if not value: return None @@ -955,14 +1014,26 @@ def _close_trade(conn, trade: dict, current_price: float, reason: str, event_tim def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: float, event_time: str) -> tuple[float, dict]: cfg = _trailing_config() current_trail = _safe_float(trade.get("trailing_stop")) - if not cfg.get("enabled") or pnl_pct < _safe_float(cfg.get("activate_pnl_pct")): + if not cfg.get("enabled"): return current_trail, {"activated": False, "moved": False} entry_price = _safe_float(trade.get("entry_price")) if entry_price <= 0 or current_price <= 0: return current_trail, {"activated": False, "moved": False} - distance_pct, tier_label = _trailing_distance_pct(pnl_pct, cfg) + profile = _dynamic_trailing_profile(trade, current_price, pnl_pct, cfg) + activate_pnl_pct = _safe_float(profile.get("activate_pnl_pct"), cfg.get("activate_pnl_pct")) + if pnl_pct < activate_pnl_pct: + return current_trail, { + "activated": False, + "moved": False, + "trailing_mode": profile.get("mode"), + "volatility_pct": profile.get("volatility_pct"), + "activate_pnl_pct": activate_pnl_pct, + } + + distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct")) + tier_label = str(profile.get("tier_label") or "") protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100) candidate = current_price * (1 - distance_pct / 100) new_trail = round(max(current_trail, protection_floor, candidate), 12) @@ -989,9 +1060,13 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa "current_price": current_price, "previous_trailing_stop": current_trail, "trailing_stop": new_trail, - "activate_pnl_pct": cfg.get("activate_pnl_pct"), + "activate_pnl_pct": activate_pnl_pct, "distance_pct": distance_pct, "tier_label": tier_label, + "trailing_mode": profile.get("mode"), + "volatility_pct": profile.get("volatility_pct"), + "base_activate_pnl_pct": profile.get("base_activate_pnl_pct"), + "base_distance_pct": profile.get("base_distance_pct"), "min_lock_profit_pct": cfg.get("min_lock_profit_pct"), "notification_throttled": False, }, @@ -1004,7 +1079,10 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa "trailing_stop": new_trail, "previous_trailing_stop": current_trail, "distance_pct": distance_pct, + "activate_pnl_pct": activate_pnl_pct, "tier_label": tier_label, + "trailing_mode": profile.get("mode"), + "volatility_pct": profile.get("volatility_pct"), "notification_emitted": should_emit, } diff --git a/static/onchain.html b/static/onchain.html index 8b7d223..78501da 100644 --- a/static/onchain.html +++ b/static/onchain.html @@ -2,7 +2,7 @@ {% block title %}AlphaX Agent — 链上异动{% endblock %} {% block extra_head_css %} {% endblock %} {% block content %} @@ -15,29 +15,34 @@
链上异动不是买入指令。高质量正向信号会进入技术检查;交易所流入、流动性撤出、持仓集中等负向信号只作为风险上下文。
-
加载数据源状态...
-
-
-
重要链上事件
NodeReal 主链路事件优先
-
-
- - - - - -
-
--
-
-
加载中...
-
加载中...
-
-
映射信号流
mapped_signal_feed:可进入分析
加载中...
-
映射风险流
仅作为风控上下文
加载中...
+
+
加载数据源状态...
+
+
+
+
+
重要链上事件
先看发生了什么,再决定是否值得跟踪
+
+
+ + + + + +
+
--
+
+
加载中...
+
+
+
映射信号流
已能关联交易所币种
加载中...
+
映射风险流
仅作为风控上下文
加载中...
+
+
映射资产列表
按币种筛选、翻页和进入单币档案
@@ -60,7 +65,7 @@ function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){retur 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 fmtPct(v){v=Number(v||0);var cls=v>0?'pos-text':v<0?'neg-text':'';return ''+(v>0?'+':'')+v.toFixed(1)+'%'} function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')} -function fmtAmount(v){v=Number(v||0);if(v>=1000)return v.toFixed(0);if(v>0)return v.toFixed(2);return '--'} +function fmtAmount(v){v=Number(v||0);if(!isFinite(v)||v<=0)return'--';if(v>=1e9)return(v/1e9).toFixed(2)+'B';if(v>=1e6)return(v/1e6).toFixed(2)+'M';if(v>=1e3)return(v/1e3).toFixed(2)+'K';if(v>=1)return v.toFixed(2).replace(/\.?0+$/,'');return v.toFixed(6).replace(/\.?0+$/,'')} function recLabel(r){if(!r||!r.has_active)return '未进入';var s=r.execution_status||'';if(s==='buy_now')return '入场窗口';if(s==='wait_pullback')return '等回踩';return ''+esc(r.action_status||'观察中')+''} 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'} @@ -70,12 +75,12 @@ function renderKpis(k){var cells=[['原始流',k.raw_event_count||0,'blue'],[' function tokenRow(t, risk){var cnt=t.mapped_event_count||t.event_count||0;var latest=t.latest_event_at?(' · 最近 '+fmtTime(t.latest_event_at)):'';var sub=esc(t.chain)+' · 映射事件 '+cnt+latest;return '
'+esc(t.symbol)+'
'+sub+'
'+Number(risk?t.risk_score:t.onchain_score||0).toFixed(0)+'
'} function renderSpotlight(d){$('hotTokens').innerHTML=(d.hot_tokens||[]).length?(d.hot_tokens||[]).map(function(t){return tokenRow(t,false)}).join(''):'
暂无映射后的正向链上信号
';$('riskTokens').innerHTML=(d.risk_tokens||[]).length?(d.risk_tokens||[]).map(function(t){return tokenRow(t,true)}).join(''):'
暂无映射后的风险信号
'} async function loadOverview(){try{var r=await fetch(API+'/api/onchain/overview?hours='+$('hoursSel').value);var d=await r.json();renderKpis(d.kpi||{});renderSpotlight(d);renderProviderStatus(d.provider_status||{})}catch(e){$('kpis').innerHTML='
链上总览加载失败
';$('providerStatus').innerHTML='
数据源状态加载失败
'}} -function renderRawCard(e){var mapped=e.mapping_status==='mapped';var desc=e.plain_summary||e.description||e.name||e.token_short||'链上源捕捉到新的 Token 动态';var amount=e.total_amount||e.amount||0;var link=e.url?'查看来源':'';var pr=e.priority||'medium';var prBadge=pr==='low'?'低优先级':pr==='high'?'高优先级':'中优先级';return '
'+esc(e.event_label||e.title||e.event_type)+'
'+(mapped?'已映射':'未映射')+'
'+esc(desc)+'
'+esc(e.why_matters||'需要映射和验证后才可能进入主链路。')+'
'+esc(e.chain||'--')+''+esc(e.mapped_symbol||e.token_short||'未知 Token')+''+prBadge+(amount?'热度 '+fmtAmount(amount)+'':'')+''+fmtTime(e.detected_at)+''+link+'
'+esc(e.pipeline_note||'未完成映射时仅作为原始观察。')+'
'} +function renderRawCard(e){var mapped=e.mapping_status==='mapped';var desc=e.human_summary||e.description||e.plain_summary||'链上源捕捉到一条新动态';var amount=e.display_amount_label||fmtAmount(e.display_amount||e.total_amount||e.amount);var link=e.url?'查看来源':'';var pr=e.priority||'medium';var prBadge=Number(e.importance||0)>=90?'高重要性':Number(e.importance||0)>=70?'重要':'低优先级';var route=(e.from_short&&e.to_short)?''+esc(e.from_short)+' → '+esc(e.to_short)+'':'';return '
'+esc(e.mapped_symbol||e.symbol_guess||e.event_label||'链上事件')+'
'+(mapped?'已映射':'未映射')+'
'+esc(desc)+'
'+esc(e.chain||'--')+''+esc(amount)+''+route+prBadge+''+fmtTime(e.detected_at)+''+link+'
'+esc(e.pipeline_note||'未完成映射时仅作为原始观察。')+'
'} async function reloadRawEvents(offset){state.rawOffset=offset||0;$('rawFeed').innerHTML='
加载中...
';try{var qs='hours='+$('hoursSel').value+'&limit='+state.rawLimit+'&offset='+state.rawOffset+'&chain='+encodeURIComponent($('rawChainSel').value)+'&mapping_status='+encodeURIComponent($('rawMapSel').value)+'&priority='+encodeURIComponent(state.rawPriority||'');var d=await (await fetch(API+'/api/onchain/raw-events?'+qs)).json();state.rawTotal=d.total||0;var items=d.items||[];var mode=state.rawPriority==='low'?'低优先级':state.rawPriority==='important'?'重要事件':'全部原始流';$('rawInfo').textContent=mode+' · 共 '+state.rawTotal+' 条,显示最近 '+items.length+' 条';$('rawFeed').innerHTML=items.length?items.map(renderRawCard).join(''):(state.rawPriority==='important'?'
暂无高价值链上事件。
':'
暂无原始链上流
')}catch(e){$('rawFeed').innerHTML='
原始链上流加载失败
';$('rawInfo').textContent='加载失败'}} async function reloadTokens(offset){state.offset=offset||0;$('tokenTable').innerHTML='加载中...';try{var qs='hours='+$('hoursSel').value+'&limit='+state.limit+'&offset='+state.offset+'&chain='+encodeURIComponent($('chainSel').value)+'&signal='+encodeURIComponent($('signalSel').value);var d=await (await fetch(API+'/api/onchain/tokens?'+qs)).json();state.total=d.total||0;var items=d.items||[];if(!items.length){$('tokenTable').innerHTML='暂无链上异动 token'}else{$('tokenTable').innerHTML=items.map(function(t){return ''+esc(t.symbol)+''+esc(t.chain)+''+Number(t.onchain_score||0).toFixed(0)+''+Number(t.risk_score||0).toFixed(0)+''+Number(t.event_count||t.mapped_event_count||0).toFixed(0)+''+esc(t.latest_event_at?fmtTime(t.latest_event_at):'--')+''+esc(t.source||'nodereal')+''+recLabel(t.recommendation)+''}).join('');if(!state.selected&&items[0])loadDetail(items[0].symbol)}updatePager()}catch(e){$('tokenTable').innerHTML='加载失败'}} function updatePager(){var page=Math.floor(state.offset/state.limit)+1,totalPages=Math.max(1,Math.ceil((state.total||0)/state.limit));$('pageInfo').textContent='第 '+page+' / '+totalPages+' 页,共 '+state.total+' 个';$('prevBtn').disabled=state.offset<=0;$('nextBtn').disabled=state.offset+state.limit>=state.total} function page(step){var next=state.offset+step*state.limit;if(next<0||next>=state.total)return;reloadTokens(next)} -async function loadDetail(symbol){state.selected=symbol;$('detailNote').textContent=symbol;$('detailBody').innerHTML='
加载详情...
';try{var d=await (await fetch(API+'/api/onchain/tokens/'+encodeURIComponent(symbol)+'?hours=168')).json();var latest=(d.metrics||[])[0]||{};var rec=d.recommendation;var rawCount=Number(d.raw_event_count||latest.event_count||latest.mapped_event_count||0);var metrics='
'+[['链上分',Number(latest.onchain_score||0).toFixed(0)],['风险分',Number(latest.risk_score||0).toFixed(0)],['映射事件',rawCount.toFixed(0)],['数据源',latest.source||'nodereal']].map(function(x){return '
'+x[0]+''+x[1]+'
'}).join('')+'
';var recHtml=rec?'
主链路状态:'+esc(rec.action_status||rec.execution_status||'观察')+' · 推荐 #'+esc(rec.id)+'
':'
尚未形成主链路推荐;若链上信号质量足够,会先进入技术检查。
';var standardEvents=(d.events||[]).slice(0,10).map(function(e){var cls=e.direction==='risk'?'risk':e.direction==='positive'?'pos':'blue';return '
'+esc(e.signal_label||e.signal_code)+'
'+esc(e.severity||e.direction)+'
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+fmtUsd(e.value_usd)+'
'+esc(e.wallet_label||e.counterparty_label||e.source||'链上事件')+'
'});var rawEvents=(d.raw_events||[]).slice(0,20).map(function(e){var amount=e.total_amount||e.amount||0;var link=e.url?' · 查看来源':'';return '
'+esc(e.event_label||e.title||e.event_type||'NodeReal 原始事件')+'
已映射
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+esc(e.mapped_symbol||d.symbol)+' · 热度 '+fmtAmount(amount)+link+'
'+esc(e.pipeline_note||'已映射,可进入后续链上信号分析。')+'
'});var events=standardEvents.concat(rawEvents).join('')||'
暂无事件明细
';$('detailBody').innerHTML='
'+esc(d.symbol)+'
'+(d.mappings||[]).length+' 个合约映射 · 近 7 天
'+metrics+recHtml+'
'+events+'
'}catch(e){$('detailBody').innerHTML='
详情加载失败
'}} +async function loadDetail(symbol){state.selected=symbol;$('detailNote').textContent=symbol;$('detailBody').innerHTML='
加载详情...
';try{var d=await (await fetch(API+'/api/onchain/tokens/'+encodeURIComponent(symbol)+'?hours=168')).json();var latest=(d.metrics||[])[0]||{};var rec=d.recommendation;var rawCount=Number(d.raw_event_count||latest.event_count||latest.mapped_event_count||0);var metrics='
'+[['链上分',Number(latest.onchain_score||0).toFixed(0)],['风险分',Number(latest.risk_score||0).toFixed(0)],['映射事件',rawCount.toFixed(0)],['数据源',latest.source||'nodereal']].map(function(x){return '
'+x[0]+''+x[1]+'
'}).join('')+'
';var recHtml=rec?'
主链路状态:'+esc(rec.action_status||rec.execution_status||'观察')+' · 推荐 #'+esc(rec.id)+'
':'
尚未形成主链路推荐;若链上信号质量足够,会先进入技术检查。
';var standardEvents=(d.events||[]).slice(0,10).map(function(e){var cls=e.direction==='risk'?'risk':e.direction==='positive'?'pos':'blue';return '
'+esc(e.signal_label||e.signal_code)+'
'+esc(e.severity||e.direction)+'
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+fmtUsd(e.value_usd)+'
'+esc(e.wallet_label||e.counterparty_label||e.source||'链上事件')+'
'});var rawEvents=(d.raw_events||[]).slice(0,20).map(function(e){var amount=e.display_amount_label||fmtAmount(e.display_amount||e.total_amount||e.amount);var link=e.url?' · 查看来源':'';var route=(e.from_short&&e.to_short)?'
路径:'+esc(e.from_short)+' → '+esc(e.to_short):'';return '
'+esc(e.human_summary||e.event_label||'NodeReal 原始事件')+'
已映射
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+esc(amount)+link+route+'
'+esc(e.pipeline_note||'已映射,可进入后续链上信号分析。')+'
'});var events=standardEvents.concat(rawEvents).join('')||'
暂无事件明细
';$('detailBody').innerHTML='
'+esc(d.symbol)+'
'+(d.mappings||[]).length+' 个合约映射 · 近 7 天
'+metrics+recHtml+'
'+events+'
'}catch(e){$('detailBody').innerHTML='
详情加载失败
'}} function reloadAll(){state.offset=0;state.rawOffset=0;state.selected='';loadOverview();reloadRawEvents(0);reloadTokens(0)} reloadAll(); setInterval(reloadAll,300000); diff --git a/tests/test_onchain_tracking.py b/tests/test_onchain_tracking.py index 67c444f..5490a59 100644 --- a/tests/test_onchain_tracking.py +++ b/tests/test_onchain_tracking.py @@ -234,6 +234,14 @@ def test_overview_ignores_legacy_signals_and_surfaces_mapped_raw_feed(monkeypatc 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", @@ -241,8 +249,8 @@ def test_token_detail_includes_mapped_raw_events(monkeypatch, tmp_path): "event_type": "evm_transfer", "token_address": "0xbeam", "title": "NodeReal ERC-20 原始转账", - "amount": 300, - "total_amount": 300, + "amount": 300 * 10**18, + "total_amount": 300 * 10**18, "importance": 78, "mapped_symbol": "BEAM/USDT", "mapping_status": "mapped", @@ -255,6 +263,8 @@ def test_token_detail_includes_mapped_raw_events(monkeypatch, tmp_path): 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"] == "已映射,可进入后续链上信号分析。" diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 13b3b71..3dbff18 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -535,6 +535,7 @@ def test_trailing_move_push_is_throttled_but_stop_still_updates(monkeypatch, buy "risk_reward_ok": True, } monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed") monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3") monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", "300") monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", "2") @@ -624,6 +625,7 @@ 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_MODE", "fixed") 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") @@ -650,6 +652,58 @@ def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy _assert_no_paper_trading_copy(pushed[-1]) +def test_volatility_trailing_uses_wider_distance_for_choppy_coin(monkeypatch, buy_now_rec): + rec = dict(buy_now_rec) + rec["tp1"] = 200 + rec["tp2"] = 220 + rec["entry_plan"] = { + "entry_action": "可即刻买入", + "entry_price": 100, + "stop_loss": 95, + "tp1": 200, + "tp2": 220, + "entry_trigger_confirmed": True, + "risk_reward_ok": True, + } + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "volatility") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_ACTIVATE_MULT", "0.6") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", "0.7") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MAX_DISTANCE_PCT", "8") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MAX_ACTIVATE_PCT", "8") + + sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + sync_recommendation(rec, 98, event_time="2026-05-16T10:01:00") + result = sync_recommendation(rec, 110, event_time="2026-05-16T10:02:00") + + assert result["activated"] is True + assert result["trailing_mode"] == "volatility" + assert result["volatility_pct"] == pytest.approx(12.0) + assert result["activate_pnl_pct"] == pytest.approx(7.2) + assert result["distance_pct"] == pytest.approx(8.0) + assert result["trailing_stop"] == pytest.approx(101.2) + + +def test_volatility_trailing_stays_tighter_for_smooth_coin(monkeypatch, buy_now_rec): + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "volatility") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT", "0.7") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_VOL_MIN_DISTANCE_PCT", "1.2") + + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + result = sync_recommendation(buy_now_rec, 104, event_time="2026-05-16T10:01:00") + + assert result["activated"] is True + assert result["trailing_mode"] == "volatility" + assert result["volatility_pct"] == pytest.approx(4.0) + assert result["distance_pct"] == pytest.approx(2.8) + assert result["trailing_stop"] == pytest.approx(101.088) + + 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")) @@ -664,6 +718,7 @@ def test_paper_push_failure_is_recorded(monkeypatch, buy_now_rec): def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec): monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed") 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") @@ -680,6 +735,7 @@ def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec): def test_paper_trading_events_capture_open_close_and_trailing(monkeypatch, buy_now_rec): monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed") 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") diff --git a/tests/test_price_streamer.py b/tests/test_price_streamer.py index 7f784aa..09ebb69 100644 --- a/tests/test_price_streamer.py +++ b/tests/test_price_streamer.py @@ -69,6 +69,7 @@ def test_price_streamer_tick_opens_and_closes_paper_trade(buy_now_rec): def test_price_streamer_tick_drives_paper_trailing_stop(monkeypatch, buy_now_rec): monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed") 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")