From 3a6aeedef0e41068a2ca6bb9c6a48ca7d5ab9b1d Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 23 May 2026 12:13:31 +0800 Subject: [PATCH] update --- .env.example | 6 ++ AGENTS.md | 9 ++ app/config/system_config.py | 6 ++ app/core/factor_scoring.py | 84 +++++++++++++++- app/core/global_risk.py | 118 ++++++++++++++++++++++ app/core/market_regime.py | 137 +++++++++++++++++++++++++ app/db/paper_trading.py | 30 ++++++ app/services/altcoin_confirm.py | 106 +++++++++++++++++--- docs/OPTIMIZATION_TODO.md | 145 +++++++++++++++++++++++++++ static/app.html | 69 ++++++++++++- static/paper_trading.html | 5 +- tests/test_factor_scoring.py | 24 +++++ tests/test_market_regime.py | 47 +++++++++ tests/test_onchain_factor_scoring.py | 26 +++++ tests/test_paper_trading.py | 83 +++++++++++++++ 15 files changed, 879 insertions(+), 16 deletions(-) create mode 100644 app/core/global_risk.py create mode 100644 app/core/market_regime.py create mode 100644 docs/OPTIMIZATION_TODO.md create mode 100644 tests/test_market_regime.py diff --git a/.env.example b/.env.example index 600e4bf..f82037d 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,12 @@ ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT=3 ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES=3 ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS=6 ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT=1 +ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED=1 +ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL=1 +ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE=70 +ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT=3 +ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT=6 +ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS=0 ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50 ALPHAX_PAPER_ORDER_MIN_RR=1.8 ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1 diff --git a/AGENTS.md b/AGENTS.md index 4bca291..9b4231c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,10 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 稳定因子代码来自 `app/core/signal_taxonomy.py`,例如 `vp_fly_1h_current`、`volume_consecutive_1h`、`ignition_d1_current`、`sector_rotation`、`sentiment_resonance`、`top_trader_long`、`risk_reward_bad`。 - `signal_performance` 是复盘后动态权重来源;`review_engine.py` 更新信号绩效后,`config_loader.get_signal_weights()` 会让下一轮筛选/确认读取生效权重。 - 当前确认层已把核心技术因子、资金面因子、板块因子、舆情因子和买点风险因子接入 `FactorScorer`,并在 `market_context.factor_score_breakdown` / `entry_plan.factor_score_breakdown` 中保留因子明细。 +- `FactorScorer` 已加入因子组去相关,同一类 `momentum` / `structure` / `entry_quality` / `onchain_flow` / `narrative` 信号会受 group cap 限制,避免同一根行情被重复加分。 +- 扣分因子应传负数,例如 `FactorScorer.delta("false_breakout", -5, ...)`,不要再外部 `score -= delta`,否则 `factor_score_breakdown` 会把风险误记成正向贡献。 +- 确认层会输出 `score_components`:`opportunity_score` 表示机会质量,`entry_score` 表示买点质量,`risk_score` 表示扣分风险;后续策略不要再只看单一 `rec_score`。 +- `market_context.decision_log` / `entry_plan.decision_log` 是结构化决策解释;paper trading 开仓事件也会记录当时 `market_regime`、`global_risk` 和 `score_components`。 - NodeReal 链上因子通过 `app/db/onchain_db.py#get_onchain_factor_context()` 进入确认层,正向事件如 `whale_accumulation`、`smart_money_buying`、`exchange_outflow` 会加分,风险事件如 `exchange_inflow_risk`、`liquidity_remove_risk`、`holder_concentration_risk` 会扣分;这些因子同样受 `signal_performance.weight` 复盘权重约束。 - 后续新增链上、资金、事件、舆情等非 K 线因子时,必须给出稳定 `factor_code`、默认基准权重、证据字段和复盘归因口径,避免只做展示标签而不参与策略进化。 @@ -154,6 +158,10 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 机会级别、持有周期、入场/止损/止盈模型等结构化口径。 - `app/core/signal_taxonomy.py` - 信号分类与信号语义口径。 +- `app/core/market_regime.py` + - 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。 +- `app/core/global_risk.py` + - paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。 ## 5. 数据与状态中心 @@ -266,6 +274,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 后端读取的 HTML 模板资源 - `/docs` - 项目结构、迁移、专题审计、参考 schema 等文档 + - `OPTIMIZATION_TODO.md` 记录筛选、风控、因子、复盘进化的后续优化路线;继续做策略优化前应先阅读并更新。 - `/data` - 本地挂载数据目录,主要用于历史导入源或运行产物,不是 PostgreSQL 主存储 - `/logs` diff --git a/app/config/system_config.py b/app/config/system_config.py index 912afc7..936973c 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -126,6 +126,12 @@ def default_paper_trading_config(): "pause_after_weak_entries": _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3), "weak_entry_window_hours": _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0), "weak_entry_min_max_pnl_pct": _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0), + "global_risk_gate_enabled": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True), + "global_risk_block_critical": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", True), + "global_risk_high_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE", 70.0), + "global_risk_high_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT", 3.0), + "global_risk_critical_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT", 6.0), + "global_risk_max_open_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS", 0), "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0), "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.8), "order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True), diff --git a/app/core/factor_scoring.py b/app/core/factor_scoring.py index ed32a4b..69a7eed 100644 --- a/app/core/factor_scoring.py +++ b/app/core/factor_scoring.py @@ -51,6 +51,54 @@ DEFAULT_FACTOR_WEIGHTS = { "risk_reward_bad": 2.0, } +FACTOR_GROUPS = { + "vp_fly_1h_current": "momentum", + "volume_consecutive_1h": "participation", + "cex_top_gainer_24h": "momentum", + "volume_divergence_1h": "risk", + "static_accum_4h": "structure", + "higher_lows_4h": "structure", + "compression_surge_4h": "structure", + "ignition_1h_current": "momentum", + "ignition_4h_current": "momentum", + "ignition_d1_current": "momentum", + "dynamic_k_1h_bull": "momentum", + "dynamic_k_d1_bull": "momentum", + "breakout_pullback_d1": "structure", + "breakout_pullback_w1": "structure", + "breakout_15m_current": "entry_quality", + "pullback_15m_confirm": "entry_quality", + "strong_resonance_bypass": "structure", + "entry_quality_gate": "entry_quality", + "top_trader_long": "positioning", + "sector_rotation": "narrative", + "sentiment_resonance": "narrative", + "dex_volume_spike": "onchain_flow", + "liquidity_add": "onchain_flow", + "exchange_outflow": "onchain_flow", + "whale_accumulation": "onchain_flow", + "smart_money_buying": "onchain_flow", + "liquidity_remove_risk": "risk", + "exchange_inflow_risk": "risk", + "holder_concentration_risk": "risk", + "funding_extreme": "risk", + "trend_exhaustion": "risk", + "false_breakout": "risk", + "high_position_reject": "risk", + "risk_reward_bad": "risk", +} + +GROUP_CAPS = { + "momentum": 16.0, + "participation": 6.0, + "structure": 16.0, + "positioning": 4.0, + "narrative": 5.0, + "onchain_flow": 6.0, + "entry_quality": 7.0, + "risk": 12.0, +} + WEIGHT_ALIASES = { "vp_fly_1h_current": ("量价齐飞", "1H当前量价齐飞"), "volume_consecutive_1h": ("连续3x放量", "连续3x放量(≥3根)", "1H连续放量"), @@ -99,6 +147,7 @@ class FactorScorer: weights: dict[str, Any] = field(default_factory=dict) breakdown: list[dict[str, Any]] = field(default_factory=list) + group_totals: dict[str, float] = field(default_factory=dict) @classmethod def from_runtime(cls) -> "FactorScorer": @@ -118,6 +167,17 @@ class FactorScorer: return max(0.0, _safe_float(value)) return None + def _apply_group_cap(self, code: str, adjusted: float) -> tuple[float, str, float, float]: + group = FACTOR_GROUPS.get(code, "other") + cap = _safe_float(GROUP_CAPS.get(group), 99.0) + if cap <= 0: + return adjusted, group, cap, 0.0 + used = abs(_safe_float(self.group_totals.get(group))) + remaining = max(0.0, cap - used) + capped = max(-remaining, min(remaining, adjusted)) + self.group_totals[group] = round(_safe_float(self.group_totals.get(group)) + capped, 6) + return capped, group, cap, remaining + def delta(self, code: str, base: float, *, evidence: str = "", value: Any = None) -> float: """Return the review-aware score delta for one factor. @@ -137,16 +197,20 @@ class FactorScorer: multiplier = _clamp(runtime_weight / baseline, 0.0, 1.8) adjusted = base * multiplier source = "review_weight" + adjusted, group, group_cap, group_remaining = self._apply_group_cap(code, adjusted) adjusted = round(adjusted, 3) self.breakdown.append( { "factor_code": code, "factor_name": signal_label_for_code(code), + "factor_group": group, "base_delta": base, "score_delta": adjusted, "runtime_weight": runtime_weight, "baseline_weight": baseline, "multiplier": round(multiplier, 4), + "group_cap": group_cap, + "group_remaining_before": round(group_remaining, 3), "source": source, "evidence": evidence, "value": value, @@ -162,4 +226,22 @@ class FactorScorer: def summary(self) -> dict[str, Any]: total = round(sum(_safe_float(item.get("score_delta")) for item in self.breakdown), 3) - return {"total_delta": total, "items": self.breakdown} + groups = {} + for item in self.breakdown: + group = item.get("factor_group") or "other" + bucket = groups.setdefault(group, {"score_delta": 0.0, "items": 0}) + bucket["score_delta"] = round(bucket["score_delta"] + _safe_float(item.get("score_delta")), 3) + bucket["items"] += 1 + opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative", "onchain_flow"} + opportunity_score = round(sum(_safe_float(v.get("score_delta")) for k, v in groups.items() if k in opportunity_groups), 3) + entry_score = round(_safe_float(groups.get("entry_quality", {}).get("score_delta")), 3) + risk_score = round(abs(min(0.0, _safe_float(groups.get("risk", {}).get("score_delta")))), 3) + return { + "total_delta": total, + "opportunity_score": opportunity_score, + "entry_score": entry_score, + "risk_score": risk_score, + "groups": groups, + "group_caps": GROUP_CAPS, + "items": self.breakdown, + } diff --git a/app/core/global_risk.py b/app/core/global_risk.py new file mode 100644 index 0000000..9043696 --- /dev/null +++ b/app/core/global_risk.py @@ -0,0 +1,118 @@ +"""Global risk gate for paper trading entries.""" + +from __future__ import annotations + +from app.core.market_regime import classify_market_regime +from app.services.market_overview import get_crypto_market_overview + + +def _safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _safe_int(value, default: int = 0) -> int: + try: + return int(value or 0) + except Exception: + return default + + +def _portfolio_snapshot(conn, account_equity: float, additional_notional: float) -> dict: + open_rows = conn.execute("SELECT notional_usdt, pnl_pct FROM paper_trades WHERE status='open'").fetchall() + pending_notional = _safe_float(conn.execute("SELECT COALESCE(SUM(notional_usdt),0) FROM paper_orders WHERE status='pending'").fetchone()[0]) + open_notional = 0.0 + unrealized = 0.0 + for row in open_rows: + notional = _safe_float(row["notional_usdt"]) + open_notional += notional + unrealized += notional * _safe_float(row["pnl_pct"]) / 100 + projected_notional = open_notional + pending_notional + max(0.0, _safe_float(additional_notional)) + current_equity = account_equity + unrealized + return { + "open_count": len(open_rows), + "open_notional_usdt": round(open_notional, 8), + "pending_notional_usdt": round(pending_notional, 8), + "additional_notional_usdt": round(max(0.0, _safe_float(additional_notional)), 8), + "projected_notional_usdt": round(projected_notional, 8), + "unrealized_pnl_usdt": round(unrealized, 8), + "current_equity_usdt": round(current_equity, 8), + "unrealized_drawdown_pct": round(abs(min(0.0, unrealized)) / account_equity * 100, 6) if account_equity > 0 else 0, + "projected_cumulative_leverage": round(projected_notional / max(1.0, current_equity), 6), + } + + +def evaluate_global_risk( + *, + conn, + config: dict, + rec: dict | None = None, + additional_notional: float = 0.0, + overview: dict | None = None, +) -> dict: + """Evaluate whether the system should allow a new paper-trading entry.""" + cfg = config if isinstance(config, dict) else {} + if not bool(cfg.get("global_risk_gate_enabled", True)): + return { + "enabled": False, + "allow_new_entries": True, + "risk_level": "disabled", + "reasons": ["全局风控门禁已关闭"], + } + + if overview is None: + overview = get_crypto_market_overview(allow_live_fallback=False) + regime = classify_market_regime(overview) + account_equity = max(1.0, _safe_float(cfg.get("account_equity_usdt"), 20000.0)) + portfolio = _portfolio_snapshot(conn, account_equity, additional_notional) + rec_score = _safe_float((rec or {}).get("rec_score") or (rec or {}).get("score")) + min_score_high = max(0.0, _safe_float(cfg.get("global_risk_high_min_rec_score"), 70.0)) + max_drawdown_critical = max(0.0, _safe_float(cfg.get("global_risk_critical_drawdown_pct"), 6.0)) + max_drawdown_high = max(0.0, _safe_float(cfg.get("global_risk_high_drawdown_pct"), 3.0)) + reasons = list(regime.get("reasons") or []) + risk_level = str(regime.get("risk_level") or "medium") + allow = True + decision = "allow" + + drawdown = _safe_float(portfolio.get("unrealized_drawdown_pct")) + if max_drawdown_critical > 0 and drawdown >= max_drawdown_critical: + risk_level = "critical" + reasons.append("账户浮亏已进入 critical 区间,暂停所有新开仓") + elif max_drawdown_high > 0 and drawdown >= max_drawdown_high and risk_level not in {"critical"}: + risk_level = "high" + reasons.append("账户浮亏偏高,只允许高质量机会") + + if risk_level == "critical" and bool(cfg.get("global_risk_block_critical", True)): + allow = False + decision = "block_critical" + elif risk_level == "high" and rec_score < min_score_high: + allow = False + decision = "block_high_weak_score" + reasons.append(f"高风险环境下推荐分 {rec_score:.1f} 低于 {min_score_high:.1f}") + + max_open_positions = max(0, _safe_int(cfg.get("global_risk_max_open_positions"), 0)) + if allow and max_open_positions > 0 and int(portfolio.get("open_count") or 0) >= max_open_positions: + allow = False + decision = "block_max_open_positions" + risk_level = "high" if risk_level not in {"critical"} else risk_level + reasons.append(f"持仓数量已达到上限 {max_open_positions}") + + return { + "enabled": True, + "allow_new_entries": allow, + "decision": decision, + "risk_level": risk_level, + "position_multiplier": regime.get("position_multiplier", 1.0), + "max_open_positions": max_open_positions, + "min_score_when_high_risk": min_score_high, + "reasons": reasons, + "market_regime": regime, + "portfolio": portfolio, + } + + +__all__ = ["evaluate_global_risk"] diff --git a/app/core/market_regime.py b/app/core/market_regime.py new file mode 100644 index 0000000..796b6a5 --- /dev/null +++ b/app/core/market_regime.py @@ -0,0 +1,137 @@ +"""Market regime classification for strategy risk controls.""" + +from __future__ import annotations + + +def _safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _benchmark_change(overview: dict, symbol: str) -> float: + benchmarks = overview.get("benchmarks") if isinstance(overview, dict) else {} + item = benchmarks.get(symbol) if isinstance(benchmarks, dict) else {} + return _safe_float((item or {}).get("change_24h")) + + +def classify_market_regime(overview: dict | None) -> dict: + """Return a plain-language market regime from the latest market snapshot. + + The first version intentionally uses broad market facts that already exist + in `market_overview`: BTC/ETH 24h change, alt breadth, hot/crash counts and + funding heat. It is meant to be a guardrail, not a prediction model. + """ + data = overview if isinstance(overview, dict) else {} + if not data or data.get("snapshot_missing") or _safe_float(data.get("sample_count")) <= 0: + return { + "regime": "unknown", + "label": "数据不足", + "confidence": 0.2, + "risk_level": "medium", + "position_multiplier": 0.75, + "reasons": ["市场快照不足,保守运行但不直接停止交易"], + "metrics": {}, + } + + btc_change = _benchmark_change(data, "BTC/USDT") + eth_change = _benchmark_change(data, "ETH/USDT") + adv_dec = _safe_float(data.get("advance_decline_ratio")) + avg_change = _safe_float(data.get("avg_change_24h")) + hot_count = int(_safe_float(data.get("hot_count_5pct"))) + crash_count = int(_safe_float(data.get("crash_count_5pct"))) + funding = data.get("funding") if isinstance(data.get("funding"), dict) else {} + extreme_positive = int(_safe_float(funding.get("extreme_positive_count"))) + avg_funding = _safe_float(funding.get("avg_funding_rate")) + metrics = { + "btc_change_24h": btc_change, + "eth_change_24h": eth_change, + "advance_decline_ratio": adv_dec, + "avg_change_24h": avg_change, + "hot_count_5pct": hot_count, + "crash_count_5pct": crash_count, + "extreme_positive_funding_count": extreme_positive, + "avg_funding_rate": avg_funding, + } + + if btc_change <= -3 or eth_change <= -4 or adv_dec < 0.55 or crash_count >= 30: + return { + "regime": "risk_off", + "label": "风险释放期", + "confidence": 0.88, + "risk_level": "critical", + "position_multiplier": 0.0, + "reasons": ["主流币或山寨广度明显走弱,新开山寨仓位容易变成接飞刀"], + "metrics": metrics, + } + + if crash_count >= 18 or adv_dec < 0.75 or (btc_change <= -1.5 and avg_change < 0): + return { + "regime": "risk_off", + "label": "偏风险释放", + "confidence": 0.75, + "risk_level": "high", + "position_multiplier": 0.35, + "reasons": ["市场下跌覆盖面较大,只允许特别高质量的机会"], + "metrics": metrics, + } + + if hot_count >= 35 and extreme_positive >= 20 and avg_funding >= 0.00045: + return { + "regime": "meme_frenzy", + "label": "情绪过热期", + "confidence": 0.72, + "risk_level": "high", + "position_multiplier": 0.5, + "reasons": ["强势币和正 funding 同时过热,追高回撤风险升高"], + "metrics": metrics, + } + + if avg_change >= 0.8 and adv_dec >= 1.2 and hot_count >= 15 and btc_change > -1: + return { + "regime": "altcoin_rotation", + "label": "山寨轮动期", + "confidence": 0.78, + "risk_level": "medium", + "position_multiplier": 1.0, + "reasons": ["上涨覆盖面和强势币数量支持继续寻找精选机会"], + "metrics": metrics, + } + + if btc_change >= 1.2 and eth_change >= 0.8 and adv_dec >= 0.85: + return { + "regime": "btc_main_uptrend", + "label": "主流带动期", + "confidence": 0.7, + "risk_level": "medium", + "position_multiplier": 0.8, + "reasons": ["BTC/ETH 提供方向支撑,但山寨仍要精选"], + "metrics": metrics, + } + + if abs(avg_change) <= 0.6 and 0.75 <= adv_dec <= 1.25: + return { + "regime": "sideways_chop", + "label": "横盘震荡期", + "confidence": 0.65, + "risk_level": "medium", + "position_multiplier": 0.65, + "reasons": ["市场没有单边方向,追突破容易来回挨打,偏向等回踩"], + "metrics": metrics, + } + + return { + "regime": "unknown", + "label": "结构性行情", + "confidence": 0.45, + "risk_level": "medium", + "position_multiplier": 0.75, + "reasons": ["市场环境不够清晰,保持精选和轻仓"], + "metrics": metrics, + } + + +__all__ = ["classify_market_regime"] diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 99ed1aa..5d393b8 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -7,6 +7,7 @@ import os from datetime import datetime, timedelta from app.config.system_config import paper_trading_config +from app.core.global_risk import evaluate_global_risk from app.db.schema import get_conn from app.db.system_logs import record_system_error from app.integrations.feishu_push import push_card @@ -166,6 +167,16 @@ def _portfolio_entry_pause_check(conn, additional_notional: float, event_time: s return True, "", {"drawdown": drawdown, "weak_entries": weak} +def _global_risk_entry_check(conn, rec: dict, additional_notional: float, config: dict | None = None) -> tuple[bool, dict]: + detail = evaluate_global_risk( + conn=conn, + config=_paper_cfg(config), + rec=rec, + additional_notional=additional_notional, + ) + return bool(detail.get("allow_new_entries", True)), detail + + def _trailing_config() -> dict: cfg = paper_trading_config() return { @@ -625,6 +636,14 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, notional, event_time, cfg) if not pause_ok: return {"opened": False, "skipped": True, "reason": pause_reason, "risk_detail": pause_detail} + global_ok, global_detail = _global_risk_entry_check(conn, rec, notional, cfg) + if not global_ok: + return { + "opened": False, + "skipped": True, + "reason": "global_risk_rejected", + "risk_detail": global_detail, + } leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id) if not leverage_ok: return { @@ -693,6 +712,10 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: "slippage_pct": default_slippage_pct(cfg), "source_status": rec.get("execution_status") or "", "source_action": rec.get("action_status") or "", + "market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {}, + "global_risk": global_detail, + "score_components": _entry_plan(rec).get("score_components") or {}, + "decision_log": _entry_plan(rec).get("decision_log") or {}, }, now, ) @@ -704,6 +727,8 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: "notional_usdt": notional, "margin_usdt": margin, "leverage": leverage, + "global_risk": global_detail, + "market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {}, } if push_open_card: _push_event_card("open", {"symbol": symbol}, result, now) @@ -943,6 +968,11 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_ pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, default_notional_usdt(cfg), event_time, cfg) if not pause_ok: return _cancel_paper_order(conn, order, pause_reason, event_time) + global_ok, global_detail = _global_risk_entry_check(conn, rec, default_notional_usdt(cfg), cfg) + if not global_ok: + result = _cancel_paper_order(conn, order, "global_risk_rejected", event_time) + result["risk_detail"] = global_detail + return result trade_rec = dict(rec) plan = _entry_plan(trade_rec) plan.setdefault("entry_price", fill_price) diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index ab926e6..b5c6a0f 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -49,7 +49,9 @@ from app.core.opportunity_level import ( ) from app.core.opportunity_funnel import build_screening_detail from app.core.factor_scoring import FactorScorer +from app.core.market_regime import classify_market_regime from app.db.onchain_db import get_onchain_factor_context +from app.services.market_overview import get_crypto_market_overview from app.config.config_loader import _get_section as _get_cfg_section from app.core.pa_engine import ( classify_candles, calc_atr, find_supply_demand_zones, @@ -450,11 +452,11 @@ def _apply_onchain_factor_score(symbol, factor_scorer): label = event.get("signal_label") or code delta = factor_scorer.delta( code, - _onchain_base_delta(event), + -_onchain_base_delta(event), evidence=f"NodeReal风险链上事件: {label}", value={"value_usd": event.get("value_usd"), "confidence": event.get("confidence")}, ) - score_delta -= delta + score_delta += delta signals.append(f"⚠️ 链上风险: {label}(置信{event.get('confidence') or 0}, ${float(event.get('value_usd') or 0):.0f})") if onchain_score >= 75 and not positive_events: @@ -469,16 +471,59 @@ def _apply_onchain_factor_score(symbol, factor_scorer): if risk_score >= 50 and not risk_events: delta = factor_scorer.delta( "holder_concentration_risk", - 1.5, + -1.5, evidence="NodeReal综合链上风险分>=50", value=risk_score, ) - score_delta -= delta + score_delta += delta signals.append(f"⚠️ 链上综合风险高({risk_score:.0f})") ctx["score_delta"] = round(score_delta, 3) return score_delta, signals, ctx +def _current_market_regime_context(): + """Read latest market snapshot and classify regime without live network fallback.""" + try: + overview = get_crypto_market_overview(allow_live_fallback=False) + regime = classify_market_regime(overview) + return { + "market_regime": regime, + "market_snapshot": { + "updated_at": overview.get("updated_at", ""), + "snapshot_source": overview.get("snapshot_source", ""), + "snapshot_missing": bool(overview.get("snapshot_missing")), + "sample_count": overview.get("sample_count", 0), + "advance_decline_ratio": overview.get("advance_decline_ratio", 0), + "avg_change_24h": overview.get("avg_change_24h", 0), + "hot_count_5pct": overview.get("hot_count_5pct", 0), + "crash_count_5pct": overview.get("crash_count_5pct", 0), + }, + } + except Exception as exc: + return { + "market_regime": { + "regime": "unknown", + "label": "市场环境读取失败", + "risk_level": "medium", + "position_multiplier": 0.75, + "reasons": [str(exc)[:160]], + }, + "market_snapshot": {"snapshot_missing": True}, + } + + +def _decision_log(module: str, decision: str, *, score: float = 0.0, reasons=None, evidence=None, risk_flags=None) -> dict: + return { + "module": module, + "decision": decision, + "score": round(float(score or 0), 3), + "reasons": reasons or [], + "risk_flags": risk_flags or [], + "evidence": evidence or {}, + "created_at": datetime.now().isoformat(timespec="seconds"), + } + + # ==================== 确认逻辑 ==================== def detect_volume_price_fly_1h(df_1h): @@ -909,7 +954,7 @@ def confirm_burst(symbol, cand): score += factor_scorer.delta("ignition_d1_current", 6, evidence="日线当前多头起爆点", value=ig.get("strength_ratio")) elif ig["direction"] == -1: signals.append(f"日线 {ig['signal_type']}(强度{ig['strength_ratio']}×)") - score += factor_scorer.delta("trend_exhaustion", 3, evidence="日线空头起爆风险", value=ig.get("strength_ratio")) + score += factor_scorer.delta("trend_exhaustion", -3, evidence="日线空头起爆风险", value=ig.get("strength_ratio")) for ig in recent_d1_ignitions: if ig.get("direction") == 1: t = _event_time_from_age(d1_df, ig.get("age_bars")) @@ -968,7 +1013,7 @@ def confirm_burst(symbol, cand): ignition_confirmed = True elif ig["direction"] == -1: signals.append(f"1H {ig['signal_type']}(强度{ig['strength_ratio']}×)") - score += factor_scorer.delta("trend_exhaustion", 2, evidence="1H空头起爆风险", value=ig.get("strength_ratio")) + score += factor_scorer.delta("trend_exhaustion", -2, evidence="1H空头起爆风险", value=ig.get("strength_ratio")) for ig in recent_1h_ignitions: if ig.get("direction") == 1: t = _event_time_from_age(h1_df, ig.get("age_bars")) @@ -1002,9 +1047,9 @@ def confirm_burst(symbol, cand): for es in pa_1h_exhaustion.get("signals", []): signals.append(f"⚠️ {es}") if pa_1h_exhaustion["severity"] == "high": - score -= factor_scorer.delta("trend_exhaustion", 3, evidence="1H高强度趋势衰竭", value=pa_1h_exhaustion.get("severity")) + score += factor_scorer.delta("trend_exhaustion", -3, evidence="1H高强度趋势衰竭", value=pa_1h_exhaustion.get("severity")) elif pa_1h_exhaustion["severity"] == "medium": - score -= factor_scorer.delta("trend_exhaustion", 1, evidence="1H中等趋势衰竭", value=pa_1h_exhaustion.get("severity")) + score += factor_scorer.delta("trend_exhaustion", -1, evidence="1H中等趋势衰竭", value=pa_1h_exhaustion.get("severity")) # ---- v1.7.7: 30min 桥接(填补 1H→15min 缺口)---- # 30min 是中间周期:确认 1H 趋势在中等周期是否有结构支撑 @@ -1028,7 +1073,7 @@ def confirm_burst(symbol, cand): m30_aligned = True # 不扣分,视为中性偏多 elif sum(1 for c in recent_30 if c["type"] == "dynamic" and c["direction"] == -1) >= 3: signals.append("⚠️ 30min 阴动K≥3(与1H背离)") - score -= factor_scorer.delta("trend_exhaustion", 2, evidence="30min阴动K与1H背离", value="30m_bear_dynamic") + score += factor_scorer.delta("trend_exhaustion", -2, evidence="30min阴动K与1H背离", value="30m_bear_dynamic") # else: 无明确信号,不干预 # ---- PA引擎:15min入场点分析 ---- @@ -1065,7 +1110,7 @@ def confirm_burst(symbol, cand): if pa_15min_result.get("false_breakout"): signals.append("⚠️ 15min假突破!排除") - score -= factor_scorer.delta("false_breakout", 5, evidence="15min假突破", value=True) + score += factor_scorer.delta("false_breakout", -5, evidence="15min假突破", value=True) if entry_action == "即刻买入": signals.append("🟢 15min即刻入场信号") @@ -1222,7 +1267,7 @@ def confirm_burst(symbol, cand): pullback_cfg = confirm_cfg.get("pullback_penalty", {}) if pullback_cfg.get("enabled", True) and entry_action == "等回踩": penalty = pullback_cfg.get("score_deduction", 3) - score -= factor_scorer.delta("risk_reward_bad", penalty, evidence="等回踩历史失败率较高,降低确认分", value=entry_action) + score += factor_scorer.delta("risk_reward_bad", -penalty, evidence="等回踩历史失败率较高,降低确认分", value=entry_action) signals.append(f"⚠️ 等回踩降权(-{penalty}分)") confirmed = confirmed and score >= confirm_min_score() # 假突破排除 @@ -1371,7 +1416,7 @@ def confirm_burst(symbol, cand): if gate_reasons: signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3])) if gated_action == "观察": - score -= factor_scorer.delta("entry_quality_gate", 2, evidence="买点质量闸门降为观察", value=gate_reasons[:3]) + score += factor_scorer.delta("entry_quality_gate", -2, evidence="买点质量闸门降为观察", value=gate_reasons[:3]) # 周线突破回踩(需独立拉取) bp_weekly = {"detected": False} @@ -1390,7 +1435,12 @@ def confirm_burst(symbol, cand): market_context = compute_market_context(h1_df, price) derivatives_context = fetch_derivatives_context(symbol) sector_context = compute_sector_context(symbol, cand_detail) + regime_context = _current_market_regime_context() + market_regime = regime_context.get("market_regime") or {} factor_score_breakdown = factor_scorer.summary() + opportunity_score = round(float(factor_score_breakdown.get("opportunity_score") or 0), 3) + entry_score = round(float(factor_score_breakdown.get("entry_score") or 0), 3) + risk_score = round(float(factor_score_breakdown.get("risk_score") or 0), 3) trigger_context = _build_trigger_context( fresh_reason if 'fresh_reason' in locals() else "", fresh_events if 'fresh_events' in locals() else [], @@ -1404,13 +1454,43 @@ def confirm_burst(symbol, cand): market_context["trigger_context"] = trigger_context market_context["factor_score_breakdown"] = factor_score_breakdown market_context["onchain_context"] = onchain_context + market_context["market_regime"] = market_regime + market_context["market_snapshot"] = regime_context.get("market_snapshot") or {} + market_context["score_components"] = { + "total_score": round(float(score), 3), + "opportunity_score": opportunity_score, + "entry_score": entry_score, + "risk_score": risk_score, + } + market_context["decision_log"] = _decision_log( + "confirm_burst", + "confirmed" if confirmed else "rejected", + score=score, + reasons=[] if confirmed else signals[-5:], + risk_flags=[ + f"market_regime:{market_regime.get('regime', 'unknown')}", + f"market_risk:{market_regime.get('risk_level', 'medium')}", + ], + evidence={ + "opportunity_score": opportunity_score, + "entry_score": entry_score, + "risk_score": risk_score, + "trigger_status": trigger_context.get("trigger_status"), + }, + ) if entry_plan: entry_plan["factor_score_breakdown"] = factor_score_breakdown entry_plan["onchain_context"] = onchain_context + entry_plan["market_regime"] = market_regime + entry_plan["score_components"] = market_context["score_components"] + entry_plan["decision_log"] = market_context["decision_log"] return { "confirmed": confirmed, "score": round(float(score), 3), + "opportunity_score": opportunity_score, + "entry_score": entry_score, + "risk_score": risk_score, "signals": signals, "entry_plan": entry_plan if confirmed else {}, "price": round(float(price), 6), @@ -1423,7 +1503,9 @@ def confirm_burst(symbol, cand): "derivatives_context": derivatives_context, "sector_context": sector_context, "onchain_context": onchain_context, + "market_regime": market_regime, "factor_score_breakdown": factor_score_breakdown, + "decision_log": market_context["decision_log"], "fresh_reason": fresh_reason if 'fresh_reason' in locals() else "", "fresh_events": fresh_events if 'fresh_events' in locals() else [], "trigger_context": trigger_context if 'trigger_context' in locals() else {}, diff --git a/docs/OPTIMIZATION_TODO.md b/docs/OPTIMIZATION_TODO.md new file mode 100644 index 0000000..19668be --- /dev/null +++ b/docs/OPTIMIZATION_TODO.md @@ -0,0 +1,145 @@ +# AlphaX Optimization Todo + +本文件记录 AlphaX 筛选、确认、交易、复盘闭环的中长期优化路线,避免优化点只留在对话里。后续 Agent 接手时,应先看本文件和 `AGENTS.md`,再决定下一步开发。 + +## 当前原则 + +- 不盲目增加指标,优先降低回撤、减少无效开仓、提升可解释性。 +- 先做状态、日志、闸门,再做复杂模型。 +- 高分机会和好买点必须拆开。 +- 观察池、挂单池、交易账本不能混在一起。 +- 所有能影响策略的因子都必须可复盘、可提权、可降权、可淘汰。 + +## 已完成 + +- PostgreSQL 成为唯一运行时数据库,SQLite 已废弃。 +- 根目录和代码目录已做基础整理。 +- `altcoin_db.py` 已拆出多组 DB 查询/命令模块,剩余为兼容门面。 +- 推荐状态、展示桶、买点质量闸门已中心化到生命周期相关模块。 +- Paper trading 已拆分持仓、挂单、已完成、操作日志 tab。 +- Paper trading 已支持模拟挂单、移动止盈、策略交易报告、账本维护、删除/重置数据。 +- Paper trading 已加入更严格风控:推荐分、盈亏比、止损杠杆风险、账户回撤暂停、弱入场暂停。 +- `Market Regime Engine` 第一版已建立,可识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。 +- `Global Risk Engine` 第一版已接入 paper trading 开仓和挂单成交前,critical 禁止新开仓,high 只允许高质量机会。 +- 确认层已把 `market_regime` 写入 `market_context` / `entry_plan`,paper trading 开仓事件也会记录当时市场环境和全局风控结果。 +- `FactorScorer` 已加入因子组去相关,限制同类动量/结构/链上/叙事信号重复叠加导致虚高分。 +- 确认层已输出 `opportunity_score`、`entry_score`、`risk_score` 三分制,并写入 `score_components`。 +- 确认层已写入结构化 `decision_log`,用于解释确认/拒绝、分数、风险标记和核心证据。 +- 实盘控制台已建立多账号配置、账户读取、订单/历史读取、Binance API 基础执行能力。 +- 策略交易到 live trading 的自动同步链路已具备 demo 环境验证能力。 +- NodeReal 已作为当前链上主数据源,DEX Screener / Etherscan / Helius 运行链路已移除。 +- `FactorScorer` 已建立,确认层核心技术因子、板块、舆情、大户、链上因子已接入复盘权重。 +- `factor_score_breakdown` 已进入确认上下文,复盘可追踪因子贡献。 + +## P0:现在优先做 + +### 1. Global Risk Engine + +目标:单币再好,也要先看账户和大盘能不能开新仓。 + +已完成: + +- 输出 `globalRiskLevel`、`allowNewEntries`、`maxOpenPositions`、`maxLeverage`、`positionMultiplier`、`reasons`。 +- 接入 paper trading 开仓/挂单前。 +- critical 时禁止新开仓;high 时只允许高质量机会。 + +待增强: + +- 风控结果写入返回结果和操作日志,方便复盘。 +- 引入更丰富的组合风险:相关性、同板块集中度、同方向拥挤度。 + +### 2. Market Regime Engine + +目标:先判断现在是什么市场,再决定用什么策略。 + +第一版只需要白话状态: + +- `risk_off`:风险释放期,禁止或大幅减少新山寨开仓。 +- `btc_main_uptrend`:主流带动,山寨机会精选。 +- `altcoin_rotation`:山寨轮动,可正常寻找机会。 +- `sideways_chop`:横盘震荡,偏向等回踩,减少追突破。 +- `meme_frenzy`:MEME 情绪高涨,提高 MEME 门槛。 +- `unknown`:数据不足,保守运行。 + +已完成: + +- 使用现有 `market_overview` 快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量、funding 概览。 + +待增强: + +- 每次确认/交易记录当时 regime。 +- 后续再接入 BTC Dominance、TOTAL3、稳定币净流。 + +### 3. Factor Group 去相关 + +目标:避免同一根大阳线被“量价、突破、动K、强势榜”重复奖励。 + +已完成: + +- 给因子增加大类:`momentum`、`participation`、`structure`、`positioning`、`narrative`、`onchain_flow`、`risk`、`entry_quality`。 +- 每个大类内部优先取最强信号,不简单全部累加。 +- 每个大类设置分数上限。 +- `factor_score_breakdown` 增加 group 视角。 + +待增强: + +- 让分组上限按 market regime 动态调整。 +- 做因子组级别交易归因,确认哪些组在不同市场下真正贡献收益。 + +### 4. Entry Quality Score + +目标:机会分高不等于现在可以买。 + +已完成: + +- 输出 `opportunityScore`、`entryScore`、`riskScore`。 +- 记录降级原因:追高、RR 不足、止损太宽、离支撑太远、24h 涨幅过热。 + +待增强: + +- 把三分制进一步接入 `apply_entry_quality_gate`,明确用 `entryScore` 决定 `buy_now` / `wait_pullback` / `watch`。 + +### 5. 完整结构化决策日志 + +目标:任何通过、拒绝、降级都能解释清楚。 + +已完成: + +- 每个候选/确认/开仓/拒绝都记录 module、decision、score、state、reasons、riskFlags、evidence。 + +待增强: + +- 优先复用现有 `screening_log`、`cron_run_log`、`paper_trade_events`,必要时新增决策日志表。 +- 如果后续前端要集中展示策略决策,应考虑新增 `strategy_decision_log` 表,而不是长期只塞在 JSON 里。 + +## P1:第二阶段 + +- Continuation Quality Engine:突破后是否真的延续。 +- Fake Breakout Risk:假突破高风险时禁止 immediate buy。 +- Opportunity TTL:旧信号自动过期,不反复污染新机会。 +- Paper Trade Attribution:按因子组、regime、entryAction 归因交易结果。 +- Regime-based Scoring:不同市场状态下使用不同因子权重。 +- Watchlist / Trade Ledger 进一步分离:观察样本、挂单、持仓收益完全分开统计。 + +## P2:第三阶段 + +- Narrative Graph 简化版:板块、龙头、二线、跟风、假相关。 +- Sector Leader / Laggard 识别:热门板块不等于所有成员都加分。 +- Onchain Flow 增强:链上事件按钱包角色、金额、方向、持续性细分。 +- 舆情质量过滤:只在有稳定数据源时做 bot ratio / smart KOL。 +- 策略分 regime 回测。 + +## 暂缓 + +- 多 Agent 投票系统:当前不是优先项,先把状态、因子、风控和日志做好。 +- 复杂 Narrative Graph:数据质量不足前不要重投入。 +- 180 天表现对比:等结构化样本足够后再做。 +- 真实资金自动大规模跟单:paper trading 和 demo 稳定后再扩大。 + +## 下一步执行建议 + +1. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups` 是否能解释真实回撤。 +2. 做 Paper Trade Attribution,把收益按因子组和 regime 归因。 +3. 把三分制进一步接入买点质量闸门,明确高机会分但低买点分时只能挂单或观察。 +4. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。 +5. 按线上样本继续调整 group cap 和 high-risk 门槛。 diff --git a/static/app.html b/static/app.html index 5f92160..0901775 100644 --- a/static/app.html +++ b/static/app.html @@ -193,6 +193,22 @@ .onchain-meta { color:var(--stone); font-size:11px; line-height:1.45; } .onchain-score { color:var(--blue); font-weight:950; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; } .onchain-brief.risk .onchain-score { color:var(--red); } +.strategy-diagnostics { margin: 0 18px 8px; display: grid; gap: 8px; } +.score-split { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } +.score-part { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 9px; min-width: 0; } +.score-part span { display: block; color: var(--stone); font-size: 10px; font-weight: 900; line-height: 1.2; } +.score-part b { display: block; margin-top: 4px; font-size: 16px; line-height: 1; font-weight: 950; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; color: var(--ink); } +.score-part.opportunity b { color: var(--blue); } +.score-part.entry b { color: var(--green); } +.score-part.risk b { color: var(--red); } +.regime-brief { border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.regime-brief.risk_off,.regime-brief.critical { border-color: rgba(229,62,62,.18); background: var(--red-light); } +.regime-brief.altcoin_rotation { border-color: rgba(0,180,115,.18); background: var(--green-light); } +.regime-brief.sideways_chop,.regime-brief.meme_frenzy { border-color: rgba(252,185,0,.24); background: var(--yellow-light); } +.regime-name { color: var(--ink); font-size: 12px; font-weight: 950; white-space: nowrap; } +.regime-reason { color: var(--stone); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } +.decision-log-brief { border: 1px dashed var(--hairline-strong); border-radius: var(--radius-lg); background: var(--canvas); padding: 8px 10px; color: var(--slate); font-size: 11px; line-height: 1.45; } +.decision-log-brief b { color: var(--ink); font-weight: 950; } /* ===== K-LINE ===== */ .kline-wrap { padding: 0 8px 4px; } @@ -270,6 +286,10 @@ .entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; } .decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); } .onchain-brief { margin: 0 14px 8px; } + .strategy-diagnostics { margin: 0 14px 8px; } + .score-split { grid-template-columns: 1fr; } + .regime-brief { display: block; } + .regime-reason { margin-top: 4px; text-align: left; white-space: normal; } } @media(max-width:360px) { @@ -426,6 +446,50 @@ function fmtCompactNumber(v) { if (abs >= 1e3) return (v / 1e3).toFixed(1) + 'K'; return v.toFixed(abs >= 100 ? 0 : abs >= 10 ? 1 : 2); } +function scoreComponentsFrom(r) { + var ep = (r && r.entry_plan) || {}; + var mc = (r && r.market_context) || {}; + return ep.score_components || mc.score_components || null; +} +function marketRegimeFrom(r) { + var ep = (r && r.entry_plan) || {}; + var mc = (r && r.market_context) || {}; + return ep.market_regime || mc.market_regime || null; +} +function decisionLogFrom(r) { + var ep = (r && r.entry_plan) || {}; + var mc = (r && r.market_context) || {}; + return ep.decision_log || mc.decision_log || null; +} +function renderScoreComponents(r) { + var sc = scoreComponentsFrom(r); + if (!sc) return ''; + var opp = Number(sc.opportunity_score || 0); + var entry = Number(sc.entry_score || 0); + var risk = Number(sc.risk_score || 0); + return '
'+ + '
机会'+fmtCompactNumber(opp)+'
'+ + '
买点'+fmtCompactNumber(entry)+'
'+ + '
风险'+fmtCompactNumber(risk)+'
'+ + '
'; +} +function renderRegimeBrief(r) { + var rg = marketRegimeFrom(r); + if (!rg || !rg.regime) return ''; + var reasons = Array.isArray(rg.reasons) ? rg.reasons : []; + var cls = String(rg.regime || '') + ' ' + String(rg.risk_level || ''); + return '
'+esc(rg.label || rg.regime || '市场环境')+' · '+esc(rg.risk_level || 'medium')+''+esc(reasons[0] || '市场环境已记录到策略上下文')+'
'; +} +function renderDecisionLogBrief(r) { + var log = decisionLogFrom(r); + if (!log || !log.decision) return ''; + var flags = Array.isArray(log.risk_flags) ? log.risk_flags.slice(0,2).join(' · ') : ''; + return '
'+esc(log.decision)+' · '+esc(log.module || 'strategy')+(flags ? ' · '+esc(flags) : '')+'
'; +} +function renderStrategyDiagnostics(r) { + var html = renderScoreComponents(r) + renderRegimeBrief(r) + renderDecisionLogBrief(r); + return html ? '
'+html+'
' : ''; +} function normalizeTriggerCause(s) { return cleanDisplayText(s) .replace(/^15min入场窗口/, '15min 触发') @@ -750,7 +814,7 @@ function renderRecCard(r) { } else { entryPlanHtml = '
当前参考'+fmtP(price)+'不是入场价
观察重点待触发'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'
绩效口径不计入未形成交易机会
观察阶段'+cleanDisplayText(horizon || '观察池候选')+''+cleanDisplayText(levelLabel)+'
'; } - return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+score+'机会分
$'+priceFmt+''+changeHtml+'
'+decisionHtml+signalLevelHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; + return '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+score+'总分
$'+priceFmt+''+changeHtml+'
'+decisionHtml+renderStrategyDiagnostics(r)+signalLevelHtml+onchainHtml+aiInsightHtml+'
'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'
'+sigHtml+'
':'')+'
'; } catch (e) { console.error('renderRecCard hard fail', r && r.symbol, e); return renderLiveFallbackCard(r); @@ -1000,8 +1064,9 @@ async function loadHistoryRecommendations(reset) { var outcomeDetail = outcome.detail; return '
'+ '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+outcomeText+'
'+ - '
'+(hasPaper ? '$'+fmtN(entryP)+''+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'' : '未执行失效/归档')+'机会分 '+score+''+duration+'
'+ + '
'+(hasPaper ? '$'+fmtN(entryP)+''+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'' : '未执行失效/归档')+'总分 '+score+''+duration+'
'+ '
交易阶段'+(hasPaper ? (paper.status === 'closed' ? '已完成策略交易' : '策略交易持有中') : '未执行归档')+'
结果说明'+outcomeDetail+'
执行状态'+execText+'
'+ + renderStrategyDiagnostics(r)+ '
'+ (sigHtml?'
'+sigHtml+'
':'')+ '
'; diff --git a/static/paper_trading.html b/static/paper_trading.html index b585146..9ad66c7 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -3,6 +3,7 @@ {% block extra_head_css %} {% endblock %} {% block content %} @@ -204,10 +205,12 @@ function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Ma async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='
加载中...
';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ));eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='
'+esc(e.message)+'
'}} function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'} function eventCls(t){if(t==='open')return'event-open';if(t==='close')return'event-close';if(String(t).indexOf('trailing')===0)return'event-trailing';return''} +function contextPill(text,cls){return ''+esc(text)+''} +function renderStrategyContext(d){var bits=[];var rg=d.market_regime||{};var gr=d.global_risk||{};var sc=d.score_components||{};if(rg.regime){var rc=rg.risk_level==='critical'||rg.risk_level==='high'?'risk':(rg.regime==='altcoin_rotation'?'good':'warn');bits.push(contextPill((rg.label||rg.regime)+' · '+(rg.risk_level||'medium'),rc))}if(gr.decision){bits.push(contextPill('风控 '+gr.decision,(gr.allow_new_entries===false?'risk':'good')))}if(sc.opportunity_score!=null||sc.entry_score!=null||sc.risk_score!=null){bits.push(contextPill('机会 '+fmt(sc.opportunity_score||0,1)+' / 买点 '+fmt(sc.entry_score||0,1)+' / 风险 '+fmt(sc.risk_score||0,1),'warn'))}var dl=d.decision_log||{};if(dl.decision){bits.push(contextPill('决策 '+dl.decision,''))}return bits.length?'
'+bits.join('')+'
':''} function renderEvents(items){if(!items.length){$('eventRows').innerHTML='
暂无操作日志
';return}$('eventRows').innerHTML=items.map(function(e){var d=e.detail||{};var detail=[];if(d.notional_usdt)detail.push('名义仓位 '+fmt(d.notional_usdt,0)+'U');if(d.margin_usdt)detail.push('保证金 '+fmt(d.margin_usdt,0)+'U');if(d.leverage)detail.push(fmt(d.leverage,1)+'x');if(d.trailing_stop)detail.push('保护价 $'+fmt(d.trailing_stop,6));if(d.previous_trailing_stop)detail.push('原保护 $'+fmt(d.previous_trailing_stop,6));if(d.distance_pct)detail.push('回撤距离 '+fmt(d.distance_pct,2)+'%');if(d.realized_pnl_usdt!=null)detail.push('实现盈亏 '+fmt(d.realized_pnl_usdt,2)+'U');return '
'+ '
'+time(e.event_time)+'
#'+esc(e.trade_id)+'
'+ '
'+esc(e.symbol)+'
'+esc(eventLabel(e.event_type))+'
'+ - '
'+esc(e.message||eventLabel(e.event_type))+'
交易状态 '+esc(e.trade_status||'--')+' · 来源推荐 '+esc(e.recommendation_id||'--')+'
'+esc(detail.join(' · ')||'无附加参数')+'
'+ + '
'+esc(e.message||eventLabel(e.event_type))+'
交易状态 '+esc(e.trade_status||'--')+' · 来源推荐 '+esc(e.recommendation_id||'--')+'
'+esc(detail.join(' · ')||'无附加参数')+'
'+renderStrategyContext(d)+'
'+ '
$'+fmt(e.price,6)+''+pct(e.pnl_pct)+'
'+ '
'}).join('')} function renderEventPager(){var page=Math.floor(eventOffset/EVENT_LIMIT)+1,totalPages=Math.max(1,Math.ceil(eventTotal/EVENT_LIMIT));$('eventPager').innerHTML='第 '+page+' / '+totalPages+' 页 · 共 '+eventTotal+' 条'} diff --git a/tests/test_factor_scoring.py b/tests/test_factor_scoring.py index addcca8..19e49a1 100644 --- a/tests/test_factor_scoring.py +++ b/tests/test_factor_scoring.py @@ -20,6 +20,30 @@ def test_factor_scorer_promotes_reviewed_factor(): assert scorer.summary()["items"][0]["multiplier"] == 1.5 +def test_factor_scorer_caps_repeated_same_group_signals(): + scorer = FactorScorer(weights={}) + + first = scorer.delta("vp_fly_1h_current", 10, evidence="first momentum") + second = scorer.delta("ignition_1h_current", 10, evidence="second momentum") + summary = scorer.summary() + + assert first == 10 + assert second == 6 + assert summary["groups"]["momentum"]["score_delta"] == 16 + assert summary["opportunity_score"] == 16 + + +def test_factor_scorer_records_negative_risk_as_risk_score(): + scorer = FactorScorer(weights={}) + + delta = scorer.delta("false_breakout", -5, evidence="unit risk") + summary = scorer.summary() + + assert delta == -5 + assert summary["groups"]["risk"]["score_delta"] == -5 + assert summary["risk_score"] == 5 + + def test_signal_weight_alias_keeps_legacy_chinese_keys_available(monkeypatch): monkeypatch.setattr("app.config.config_loader.load_rules", lambda: {"signal_weights": {"量价齐飞": 5}}) monkeypatch.setattr("app.db.altcoin_db.get_signal_weights", lambda: {}) diff --git a/tests/test_market_regime.py b/tests/test_market_regime.py new file mode 100644 index 0000000..9b72441 --- /dev/null +++ b/tests/test_market_regime.py @@ -0,0 +1,47 @@ +from app.core.market_regime import classify_market_regime + + +def _overview(**overrides): + data = { + "sample_count": 200, + "benchmarks": { + "BTC/USDT": {"change_24h": 0.5}, + "ETH/USDT": {"change_24h": 0.4}, + }, + "advance_decline_ratio": 1.0, + "avg_change_24h": 0.1, + "hot_count_5pct": 8, + "crash_count_5pct": 4, + "funding": {"avg_funding_rate": 0.0001, "extreme_positive_count": 2}, + } + data.update(overrides) + return data + + +def test_market_regime_blocks_clear_risk_off(): + result = classify_market_regime( + _overview( + benchmarks={"BTC/USDT": {"change_24h": -3.4}, "ETH/USDT": {"change_24h": -4.2}}, + advance_decline_ratio=0.4, + crash_count_5pct=35, + ) + ) + + assert result["regime"] == "risk_off" + assert result["risk_level"] == "critical" + assert result["position_multiplier"] == 0 + + +def test_market_regime_detects_altcoin_rotation(): + result = classify_market_regime( + _overview( + benchmarks={"BTC/USDT": {"change_24h": 0.6}, "ETH/USDT": {"change_24h": 1.1}}, + advance_decline_ratio=1.4, + avg_change_24h=1.2, + hot_count_5pct=22, + crash_count_5pct=3, + ) + ) + + assert result["regime"] == "altcoin_rotation" + assert result["risk_level"] == "medium" diff --git a/tests/test_onchain_factor_scoring.py b/tests/test_onchain_factor_scoring.py index 1715fb8..4f4837d 100644 --- a/tests/test_onchain_factor_scoring.py +++ b/tests/test_onchain_factor_scoring.py @@ -77,3 +77,29 @@ def test_onchain_factor_score_uses_review_weights(): assert delta == 0 assert scorer.summary()["items"][0]["factor_code"] == "smart_money_buying" assert scorer.summary()["items"][0]["score_delta"] == 0 + + +def test_onchain_risk_factor_is_recorded_as_negative_score(): + now = datetime.now().isoformat() + insert_onchain_event( + { + "symbol": "RISKY/USDT", + "chain": "ethereum", + "signal_code": "exchange_inflow_risk", + "direction": "risk", + "value_usd": 900_000, + "confidence": 82, + "detected_at": now, + "source": "nodereal", + } + ) + scorer = FactorScorer(weights={}) + + delta, signals, ctx = _apply_onchain_factor_score("RISKY/USDT", scorer) + summary = scorer.summary() + + assert ctx["has_data"] is True + assert signals + assert delta < 0 + assert summary["items"][0]["score_delta"] < 0 + assert summary["risk_score"] > 0 diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 693ef33..6fc3f7f 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -55,6 +55,7 @@ def legacy_paper_trade_thresholds(monkeypatch): monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "0") monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0") monkeypatch.setenv("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", "0") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "0") @pytest.fixture @@ -476,6 +477,88 @@ def test_buy_now_rejects_when_cumulative_leverage_exceeded(monkeypatch): assert list_paper_trades(status="open")["total"] == 1 +def test_buy_now_rejects_when_global_market_risk_is_critical(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0") + monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0") + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: { + "sample_count": 200, + "benchmarks": {"BTC/USDT": {"change_24h": -3.5}, "ETH/USDT": {"change_24h": -4.1}}, + "advance_decline_ratio": 0.45, + "avg_change_24h": -2.2, + "hot_count_5pct": 2, + "crash_count_5pct": 36, + "funding": {"avg_funding_rate": -0.0001, "extreme_positive_count": 0}, + }, + ) + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="RISKOFF/USDT", + rec_state="爆发", + rec_score=95, + entry_price=100, + stop_loss=95, + tp1=112, + signals=["当前15min即刻入场信号"], + entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True, "rr1": 2.4}, + ) + rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id) + + result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + + assert result["reason"] == "global_risk_rejected" + assert result["risk_detail"]["risk_level"] == "critical" + assert result["risk_detail"]["market_regime"]["regime"] == "risk_off" + assert list_paper_trades()["total"] == 0 + + +def test_open_event_records_market_regime_and_score_components(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0") + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: { + "sample_count": 200, + "benchmarks": {"BTC/USDT": {"change_24h": 0.8}, "ETH/USDT": {"change_24h": 1.2}}, + "advance_decline_ratio": 1.5, + "avg_change_24h": 1.4, + "hot_count_5pct": 25, + "crash_count_5pct": 2, + "funding": {"avg_funding_rate": 0.0001, "extreme_positive_count": 3}, + }, + ) + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="REGIME/USDT", + rec_state="爆发", + rec_score=95, + entry_price=100, + stop_loss=95, + tp1=112, + signals=["当前15min即刻入场信号"], + entry_plan={ + "entry_action": "可即刻买入", + "entry_trigger_confirmed": True, + "risk_reward_ok": True, + "rr1": 2.4, + "score_components": {"opportunity_score": 14, "entry_score": 4, "risk_score": 1}, + }, + ) + rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id) + + result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + events = list_paper_trade_events(symbol="REGIME/USDT")["items"] + + assert result["opened"] is True + assert events[0]["event_type"] == "open" + assert events[0]["detail"]["market_regime"]["regime"] == "altcoin_rotation" + assert events[0]["detail"]["score_components"]["opportunity_score"] == 14 + + def test_observe_only_wait_pullback_does_not_create_order(monkeypatch): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") altcoin_db.init_db()