This commit is contained in:
aaron 2026-04-27 11:47:27 +08:00
parent 918c7f914d
commit dba6e569c6
4 changed files with 316 additions and 10 deletions

View File

@ -3,7 +3,7 @@
"""
import asyncio
import math
from collections import deque
from collections import deque, defaultdict
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
import pandas as pd
@ -291,6 +291,28 @@ class CryptoAgent:
"last_signal_symbol": None,
"last_heartbeat_notified_at": None,
}
self._analysis_funnel_stats: Dict[str, Any] = {
"total_triggers": 0,
"scheduled_triggers": 0,
"event_triggers": 0,
"manual_triggers": 0,
"data_invalid_skips": 0,
"volatility_skips": 0,
"llm_lane_calls": {
"intraday": 0,
"trend": 0,
},
"llm_analyses": 0,
"cache_only_runs": 0,
"pre_regime_trade_signals": 0,
"post_regime_trade_signals": 0,
"regime_filtered_out": 0,
"no_trade_signal_runs": 0,
"threshold_filtered_runs": 0,
"valid_signal_runs": 0,
"valid_signals_total": 0,
"last_updated_at": None,
}
self._lane_analysis_state: Dict[str, Dict[str, Any]] = {}
self._event_analysis_state: Dict[str, Dict[str, Any]] = {}
self._event_analysis_tasks: Dict[str, asyncio.Task] = {}
@ -800,6 +822,171 @@ class CryptoAgent:
def get_recent_analysis_events(self, limit: int = 40) -> List[Dict[str, Any]]:
return list(self._analysis_events)[:limit]
def _bump_analysis_stat(self, key: str, amount: int = 1):
self._analysis_funnel_stats[key] = int(self._analysis_funnel_stats.get(key, 0) or 0) + amount
self._analysis_funnel_stats["last_updated_at"] = datetime.now().isoformat()
def _bump_lane_call(self, lane: str, amount: int = 1):
lane_calls = self._analysis_funnel_stats.setdefault("llm_lane_calls", {})
lane_calls[lane] = int(lane_calls.get(lane, 0) or 0) + amount
self._analysis_funnel_stats["last_updated_at"] = datetime.now().isoformat()
def _summarize_recent_analysis_funnel(self, hours: int = 24) -> Dict[str, Any]:
cutoff = datetime.now() - timedelta(hours=hours)
events = []
for event in self._analysis_events:
timestamp = self._parse_iso_datetime(event.get("timestamp"))
if timestamp and timestamp >= cutoff:
events.append(event)
summary: Dict[str, Any] = {
"window_hours": hours,
"total_events": len(events),
"triggered_symbols": 0,
"llm_runs": 0,
"cache_only_runs": 0,
"data_invalid_skips": 0,
"volatility_skips": 0,
"no_trade_signal_runs": 0,
"threshold_filtered_runs": 0,
"valid_signal_runs": 0,
"valid_signals_total": 0,
"symbols": {},
"lane_calls": {
"intraday": 0,
"trend": 0,
},
"lane_signal_counts": {
"short_term_pre": 0,
"medium_term_pre": 0,
"short_term_post": 0,
"medium_term_post": 0,
},
"blocked_reason_counts": {},
}
symbol_stats: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"triggers": 0,
"llm_runs": 0,
"cache_only_runs": 0,
"data_invalid_skips": 0,
"volatility_skips": 0,
"no_trade_signal_runs": 0,
"threshold_filtered_runs": 0,
"valid_signal_runs": 0,
"valid_signals_total": 0,
"last_status": None,
"last_detail": None,
"last_event_at": None,
"lane_calls": {
"intraday": 0,
"trend": 0,
},
"lane_signal_counts": {
"short_term_pre": 0,
"medium_term_pre": 0,
"short_term_post": 0,
"medium_term_post": 0,
},
"blocked_reason_counts": {},
})
blocked_reason_counts: Dict[str, int] = defaultdict(int)
for event in reversed(events):
event_type = event.get("event_type")
symbol = event.get("symbol") or ""
stats = symbol_stats[symbol] if symbol else None
if event_type == "symbol_analysis_started":
summary["triggered_symbols"] += 1
if stats is not None:
stats["triggers"] += 1
if event_type == "llm_lane_plan":
cache_only = bool(event.get("cache_only"))
lanes_to_run = event.get("lanes_to_run") or []
if cache_only:
summary["cache_only_runs"] += 1
if stats is not None:
stats["cache_only_runs"] += 1
else:
summary["llm_runs"] += 1
if stats is not None:
stats["llm_runs"] += 1
for lane in lanes_to_run:
if lane in summary["lane_calls"]:
summary["lane_calls"][lane] += 1
if stats is not None and lane in stats["lane_calls"]:
stats["lane_calls"][lane] += 1
if event_type == "symbol_analysis_skipped":
detail = str(event.get("detail") or "")
if "数据不完整" in detail:
summary["data_invalid_skips"] += 1
if stats is not None:
stats["data_invalid_skips"] += 1
if "波动率过低" in detail:
summary["volatility_skips"] += 1
if stats is not None:
stats["volatility_skips"] += 1
if event_type == "symbol_analysis_completed":
trade_signals = int(event.get("trade_signals", 0) or 0)
valid_signals = int(event.get("valid_signals", 0) or 0)
detail = str(event.get("detail") or "")
pre_lane_counts = event.get("pre_regime_lane_signal_counts") or {}
post_lane_counts = event.get("post_regime_lane_signal_counts") or {}
summary["lane_signal_counts"]["short_term_pre"] += int(pre_lane_counts.get("short_term", 0) or 0)
summary["lane_signal_counts"]["medium_term_pre"] += int(pre_lane_counts.get("medium_term", 0) or 0)
summary["lane_signal_counts"]["short_term_post"] += int(post_lane_counts.get("short_term", 0) or 0)
summary["lane_signal_counts"]["medium_term_post"] += int(post_lane_counts.get("medium_term", 0) or 0)
if stats is not None:
stats["lane_signal_counts"]["short_term_pre"] += int(pre_lane_counts.get("short_term", 0) or 0)
stats["lane_signal_counts"]["medium_term_pre"] += int(pre_lane_counts.get("medium_term", 0) or 0)
stats["lane_signal_counts"]["short_term_post"] += int(post_lane_counts.get("short_term", 0) or 0)
stats["lane_signal_counts"]["medium_term_post"] += int(post_lane_counts.get("medium_term", 0) or 0)
event_reason_counts = event.get("blocked_reason_counts") or {}
for reason_key, count in event_reason_counts.items():
blocked_reason_counts[str(reason_key)] += int(count or 0)
if stats is not None:
symbol_reason_counts = stats.setdefault("blocked_reason_counts", {})
symbol_reason_counts[str(reason_key)] = int(symbol_reason_counts.get(str(reason_key), 0) or 0) + int(count or 0)
if trade_signals == 0:
summary["no_trade_signal_runs"] += 1
if stats is not None:
stats["no_trade_signal_runs"] += 1
elif valid_signals == 0:
summary["threshold_filtered_runs"] += 1
if stats is not None:
stats["threshold_filtered_runs"] += 1
else:
summary["valid_signal_runs"] += 1
summary["valid_signals_total"] += valid_signals
if stats is not None:
stats["valid_signal_runs"] += 1
stats["valid_signals_total"] += valid_signals
if stats is not None:
stats["last_status"] = event.get("status")
stats["last_detail"] = detail
stats["last_event_at"] = event.get("timestamp")
summary["symbols"] = dict(sorted(
(
(symbol, payload)
for symbol, payload in symbol_stats.items()
if symbol
),
key=lambda item: (
-(item[1]["valid_signal_runs"] or 0),
-(item[1]["triggers"] or 0),
item[0],
)
))
summary["blocked_reason_counts"] = dict(sorted(blocked_reason_counts.items(), key=lambda item: (-item[1], item[0])))
return summary
def _on_price_update(self, symbol: str, price: float):
"""处理实时价格更新(用于模拟交易)"""
if not self.paper_trading:
@ -1262,6 +1449,14 @@ class CryptoAgent:
symbol: 交易对 'BTCUSDT'
"""
try:
self._bump_analysis_stat("total_triggers")
if trigger_source == "schedule":
self._bump_analysis_stat("scheduled_triggers")
elif trigger_source == "event":
self._bump_analysis_stat("event_triggers")
else:
self._bump_analysis_stat("manual_triggers")
# 更新活动时间
monitor = get_system_monitor()
monitor.update_activity("crypto_agent")
@ -1284,6 +1479,7 @@ class CryptoAgent:
data = self.exchange.get_multi_timeframe_data(symbol)
if not self._validate_data(data):
self._bump_analysis_stat("data_invalid_skips")
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
self._analysis_monitor["last_analysis_status"] = "warning"
self._analysis_monitor["last_analysis_detail"] = "数据不完整,跳过分析"
@ -1323,6 +1519,7 @@ class CryptoAgent:
# 1.5. 波动率检查(节省 LLM 调用)
should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data)
if not should_analyze:
self._bump_analysis_stat("volatility_skips")
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
self._analysis_monitor["last_analysis_status"] = "skipped"
self._analysis_monitor["last_analysis_detail"] = volatility_reason
@ -1351,6 +1548,13 @@ class CryptoAgent:
elif not lanes_to_run:
logger.info(" 🧊 LLM 冷却中,使用上一轮 lane 缓存结果")
if lanes_to_run:
self._bump_analysis_stat("llm_analyses")
for lane in lanes_to_run:
self._bump_lane_call(lane)
else:
self._bump_analysis_stat("cache_only_runs")
self._record_analysis_event(
"llm_lane_plan",
symbol=symbol,
@ -1390,8 +1594,15 @@ class CryptoAgent:
# 过滤掉 wait 信号,只保留 buy/sell 信号
signals = market_signal.get('signals', [])
trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']]
pre_regime_trade_signals = int(market_signal.get('pre_regime_trade_signal_count', len(trade_signals)) or 0)
self._bump_analysis_stat("pre_regime_trade_signals", pre_regime_trade_signals)
self._bump_analysis_stat("post_regime_trade_signals", len(trade_signals))
filtered_out = max(0, pre_regime_trade_signals - len(trade_signals))
if filtered_out:
self._bump_analysis_stat("regime_filtered_out", filtered_out)
if not trade_signals:
self._bump_analysis_stat("no_trade_signal_runs")
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
self._analysis_monitor["last_analysis_status"] = "completed"
self._analysis_monitor["last_analysis_detail"] = "完成分析,无交易信号"
@ -1400,7 +1611,13 @@ class CryptoAgent:
symbol=symbol,
status="success",
detail="完成分析,无交易信号",
extra={"trade_signals": 0, "valid_signals": 0},
extra={
"trade_signals": 0,
"valid_signals": 0,
"blocked_reason_counts": market_signal.get("blocked_reason_counts") or {},
"pre_regime_lane_signal_counts": market_signal.get("pre_regime_lane_signal_counts") or {},
"post_regime_lane_signal_counts": market_signal.get("post_regime_lane_signal_counts") or {},
},
)
blocked_reasons = market_signal.get('blocked_reasons') or []
if blocked_reasons:
@ -1414,6 +1631,7 @@ class CryptoAgent:
valid_signals = [s for s in trade_signals if s.get('confidence', 0) >= threshold]
if not valid_signals:
self._bump_analysis_stat("threshold_filtered_runs")
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
self._analysis_monitor["last_analysis_status"] = "completed"
self._analysis_monitor["last_analysis_detail"] = f"完成分析,但无信号达到阈值 {threshold}%"
@ -1422,11 +1640,19 @@ class CryptoAgent:
symbol=symbol,
status="success",
detail=f"完成分析,但无信号达到阈值 {threshold}%",
extra={"trade_signals": len(trade_signals), "valid_signals": 0},
extra={
"trade_signals": len(trade_signals),
"valid_signals": 0,
"blocked_reason_counts": market_signal.get("blocked_reason_counts") or {},
"pre_regime_lane_signal_counts": market_signal.get("pre_regime_lane_signal_counts") or {},
"post_regime_lane_signal_counts": market_signal.get("post_regime_lane_signal_counts") or {},
},
)
logger.info(f"\n⏸️ 结论: 无交易信号达到置信度阈值 ({threshold}%),继续观望")
return
self._bump_analysis_stat("valid_signal_runs")
self._bump_analysis_stat("valid_signals_total", len(valid_signals))
logger.info(f"\n✅ 发现 {len(valid_signals)} 个有效交易信号(达到 {threshold}% 阈值)")
for signal in valid_signals:
logger.info(
@ -1535,7 +1761,13 @@ class CryptoAgent:
symbol=symbol,
status="success",
detail=f"完成分析,产生 {len(valid_signals)} 个有效信号",
extra={"trade_signals": len(trade_signals), "valid_signals": len(valid_signals)},
extra={
"trade_signals": len(trade_signals),
"valid_signals": len(valid_signals),
"blocked_reason_counts": market_signal.get("blocked_reason_counts") or {},
"pre_regime_lane_signal_counts": market_signal.get("pre_regime_lane_signal_counts") or {},
"post_regime_lane_signal_counts": market_signal.get("post_regime_lane_signal_counts") or {},
},
)
except Exception as e:
@ -4388,6 +4620,8 @@ class CryptoAgent:
'target_execution_controls': self.get_target_execution_status(),
'analysis_monitor': self._analysis_monitor,
'analysis_notifications': self._analysis_notification_state,
'analysis_funnel_stats': self._analysis_funnel_stats,
'analysis_funnel_24h': self._summarize_recent_analysis_funnel(hours=24),
'lane_analysis_state': self._lane_analysis_state,
'event_analysis_state': self._event_analysis_state,
'execution_guardian': self.execution_guardian.get_status(),

View File

@ -1574,6 +1574,11 @@ class MarketSignalAnalyzer:
)[:2]
result['signals'] = merged_signals
result['pre_regime_trade_signal_count'] = len(merged_signals)
result['pre_regime_lane_signal_counts'] = {
'short_term': len(intraday_signals),
'medium_term': len(trend_signals),
}
result['key_levels'] = {
'support': self._dedupe_levels(
(intraday_result.get('key_levels', {}) or {}).get('support', []) +
@ -1665,12 +1670,17 @@ class MarketSignalAnalyzer:
}
)
filtered_signals, blocked_reasons = self.setup_policy.filter_signals(enriched_signals, regime_profile)
filtered_signals, blocked_reasons, blocked_reason_counts = self.setup_policy.filter_signals(enriched_signals, regime_profile)
normalized = dict(market_signal)
normalized["signals"] = filtered_signals
normalized["regime_profile"] = regime_profile
normalized["blocked_reasons"] = blocked_reasons[:6]
normalized["blocked_reason_counts"] = blocked_reason_counts
normalized["post_regime_lane_signal_counts"] = {
'short_term': len([signal for signal in filtered_signals if (signal.get("timeframe") or signal.get("type")) == "short_term"]),
'medium_term': len([signal for signal in filtered_signals if (signal.get("timeframe") or signal.get("type")) == "medium_term"]),
}
if not filtered_signals and blocked_reasons:
normalized["analysis_summary"] = self._truncate_summary(regime_profile.get("summary") or "当前状态不交易")

View File

@ -1,6 +1,7 @@
"""
市场状态到交易行为的硬约束策略
"""
from collections import defaultdict
from typing import Any, Dict, List, Tuple
@ -15,15 +16,18 @@ class SetupPolicy:
self,
signals: List[Dict[str, Any]],
regime_profile: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], List[str]]:
) -> Tuple[List[Dict[str, Any]], List[str], Dict[str, int]]:
allowed_lanes = set(regime_profile.get("allowed_lanes") or [])
allowed_setups = set(regime_profile.get("allowed_setups") or [])
tradability = regime_profile.get("tradability", "avoid")
reasons: List[str] = []
reason_counts: Dict[str, int] = defaultdict(int)
if tradability == "avoid" or not allowed_lanes or not allowed_setups:
reasons.extend(regime_profile.get("no_trade_reasons") or ["当前市场状态不允许交易"])
return [], reasons
base_reasons = regime_profile.get("no_trade_reasons") or ["当前市场状态不允许交易"]
reasons.extend(base_reasons)
reason_counts["tradability_avoid"] += max(1, len(signals or []))
return [], reasons, dict(reason_counts)
kept: List[Dict[str, Any]] = []
for signal in signals or []:
@ -34,10 +38,12 @@ class SetupPolicy:
if lane not in allowed_lanes:
reasons.append(f"{lane} 不在允许交易周期内")
reason_counts[f"lane_blocked:{lane}"] += 1
continue
if setup_type not in allowed_setups:
reasons.append(f"{setup_type} 不在允许 setup 内")
reason_counts[f"setup_blocked:{setup_type}"] += 1
continue
kept.append({
@ -47,7 +53,7 @@ class SetupPolicy:
"entry_basis": entry_basis,
})
return kept, reasons
return kept, reasons, dict(reason_counts)
def _infer_setup_type(self, signal: Dict[str, Any]) -> str:
lane = signal.get("timeframe") or signal.get("type") or "medium_term"

View File

@ -3164,6 +3164,7 @@
const attentionItems = management.attention_items || [];
const haltedCount = countHalted(cryptoAgent.platform_halts || {});
const latestSignals = data.signals?.latest || [];
const funnel = cryptoAgent.analysis_funnel_stats || {};
const heartbeatHeadline = monitor.last_heartbeat_at ? relativeTime(monitor.last_heartbeat_at) : '无心跳';
const heartbeatTone = toneClassForHealth(cryptoAgent.running ? (monitor.last_cycle_status || monitor.last_analysis_status) : 'stopped');
const exposureNotional = positions.reduce((sum, item) => {
@ -3184,7 +3185,7 @@
<div class="focus-summary-card">
<div class="kicker">最近信号</div>
<div class="headline ${signalTone}">${latestSignals.length}</div>
<div class="detail">最新 ${latestSignals[0]?.symbol || '-'} / ${latestSignals[0]?.signal_type || '-'}</div>
<div class="detail">有效轮次 ${funnel.valid_signal_runs || 0} / 无信号 ${funnel.no_trade_signal_runs || 0}</div>
</div>
<div class="focus-summary-card">
<div class="kicker">持仓暴露</div>
@ -3279,6 +3280,29 @@
return 'good';
}
function formatBlockedReason(reasonKey) {
const key = String(reasonKey || '');
if (key === 'tradability_avoid') return '市场状态禁止交易';
if (key.startsWith('lane_blocked:')) {
const lane = key.split(':')[1] || '';
return lane === 'short_term' ? '日内 lane 被禁止' : lane === 'medium_term' ? '趋势 lane 被禁止' : `lane 被禁止: ${lane}`;
}
if (key.startsWith('setup_blocked:')) {
const setup = key.split(':')[1] || '';
const setupMap = {
range_reversal: '区间反转 setup 被禁止',
trend_continuation_pullback: '趋势回调延续 setup 被禁止',
deep_pullback_continuation: '深回踩延续 setup 被禁止',
trend_reversal: '趋势反转 setup 被禁止',
breakout_confirmation: '突破确认 setup 被禁止',
breakout_pullback: '突破回踩 setup 被禁止',
unknown: '未识别 setup 被禁止',
};
return setupMap[setup] || `setup 被禁止: ${setup}`;
}
return key || '-';
}
function renderHealthRibbon(data) {
const container = document.getElementById('healthRibbon');
const cryptoAgent = data.crypto_agent || {};
@ -3641,9 +3665,14 @@
const monitor = analysisMonitor || {};
const notifications = cachedConsoleData?.crypto_agent?.analysis_notifications || {};
const schedule = cachedConsoleData?.crypto_agent?.llm_schedule || {};
const funnel = cachedConsoleData?.crypto_agent?.analysis_funnel_stats || {};
const funnel24h = cachedConsoleData?.crypto_agent?.analysis_funnel_24h || {};
const cycleStatus = monitor.last_cycle_status || 'idle';
const lastSignalAt = notifications.last_signal_at;
const lastSignalSymbol = notifications.last_signal_symbol || '-';
const topSymbols = Object.entries(funnel24h.symbols || {}).slice(0, 3);
const topBlockedReasons = Object.entries(funnel24h.blocked_reason_counts || {}).slice(0, 3);
const laneSignalCounts = funnel24h.lane_signal_counts || {};
if (!heartbeat || !summaryCard) return;
@ -3674,6 +3703,33 @@
<div class="runtime-summary-row"><span>最近完成轮次</span><strong>${monitor.last_cycle_completed_at ? `${relativeTime(monitor.last_cycle_completed_at)} / ${formatTime(monitor.last_cycle_completed_at)}` : '暂无'}</strong></div>
<div class="runtime-summary-row"><span>日内 / 趋势冷却</span><strong>${schedule.intraday_cooldown_minutes || '-'}m / ${schedule.trend_cooldown_minutes || '-'}m</strong></div>
<div class="runtime-summary-row"><span>事件分析</span><strong>${schedule.event_analysis_enabled ? '开启' : '关闭'}</strong></div>
<div class="runtime-summary-row"><span>触发 / LLM</span><strong>${funnel.total_triggers || 0} / ${funnel.llm_analyses || 0}</strong></div>
<div class="runtime-summary-row"><span>波动率跳过 / 数据异常</span><strong>${funnel.volatility_skips || 0} / ${funnel.data_invalid_skips || 0}</strong></div>
<div class="runtime-summary-row"><span>硬规则过滤 / 阈值过滤</span><strong>${funnel.regime_filtered_out || 0} / ${funnel.threshold_filtered_runs || 0}</strong></div>
<div class="runtime-summary-row"><span>24h 触发 / LLM</span><strong>${funnel24h.triggered_symbols || 0} / ${funnel24h.llm_runs || 0}</strong></div>
<div class="runtime-summary-row"><span>24h 日内 / 趋势</span><strong>${funnel24h.lane_calls?.intraday || 0} / ${funnel24h.lane_calls?.trend || 0}</strong></div>
<div class="runtime-summary-row"><span>24h 日内前后</span><strong>${laneSignalCounts.short_term_pre || 0} / ${laneSignalCounts.short_term_post || 0}</strong></div>
<div class="runtime-summary-row"><span>24h 趋势前后</span><strong>${laneSignalCounts.medium_term_pre || 0} / ${laneSignalCounts.medium_term_post || 0}</strong></div>
</div>
<div class="lane-state-list">
${topSymbols.length ? topSymbols.map(([symbol, stats]) => `
<div class="lane-state-item">
<div class="lane-state-symbol">${symbol}</div>
<div class="lane-state-detail">
24h 触发 ${stats.triggers || 0} / LLM ${stats.llm_runs || 0} / 有效 ${stats.valid_signal_runs || 0}
<br>日内前后 ${stats.lane_signal_counts?.short_term_pre || 0}/${stats.lane_signal_counts?.short_term_post || 0} / 趋势前后 ${stats.lane_signal_counts?.medium_term_pre || 0}/${stats.lane_signal_counts?.medium_term_post || 0}
<br>波动率跳过 ${stats.volatility_skips || 0} / 阈值过滤 ${stats.threshold_filtered_runs || 0}
</div>
</div>
`).join('') : '<div class="analysis-log-detail">近 24 小时还没有形成 symbol 级分析统计。</div>'}
</div>
<div class="lane-state-list" style="margin-top: 10px;">
${topBlockedReasons.length ? topBlockedReasons.map(([reason, count]) => `
<div class="lane-state-item">
<div class="lane-state-symbol">${count}</div>
<div class="lane-state-detail">${formatBlockedReason(reason)}</div>
</div>
`).join('') : '<div class="analysis-log-detail">近 24 小时没有记录到明确的硬规则过滤原因。</div>'}
</div>
`;
}