From dcd8ee7b454bf545b5c6751299ce319dc15ed4d6 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 7 Jun 2026 20:29:45 +0800 Subject: [PATCH] 1 --- .env.example | 3 +- AGENTS.md | 5 +- app/config/system_config.py | 4 +- app/core/factor_roles.py | 4 + app/core/signal_taxonomy.py | 8 ++ app/core/strategy_registry.py | 133 +++++++++++++++--- app/db/intraday_frequency.py | 30 +++- ...0020_intraday_long_short_strategy_pool.sql | 30 ++++ app/db/paper_trading.py | 55 ++++++++ app/db/scheduler_db.py | 2 +- app/services/altcoin_confirm.py | 70 +++++++-- app/strategies/altcoin_breakout.py | 101 ++++++++++++- app/strategies/orchestrator.py | 62 +++++++- app/strategies/short_breakdown.py | 64 ++++++++- docs/MULTI_STRATEGY_ARCHITECTURE.md | 46 +++--- rules.yaml | 9 +- static/app.html | 47 ++++++- static/opportunity_detail.html | 2 +- static/paper_trading.html | 2 +- tests/test_intraday_frequency.py | 43 ++++++ tests/test_intraday_strategy_builders.py | 99 +++++++++++++ tests/test_multi_strategy_infra.py | 94 +++++++++++-- tests/test_opportunity_lifecycle.py | 29 ++++ tests/test_paper_trading.py | 66 ++++++++- 24 files changed, 925 insertions(+), 83 deletions(-) create mode 100644 app/db/migrations/0020_intraday_long_short_strategy_pool.sql create mode 100644 tests/test_intraday_frequency.py create mode 100644 tests/test_intraday_strategy_builders.py diff --git a/.env.example b/.env.example index 5978696..3fbf29b 100644 --- a/.env.example +++ b/.env.example @@ -87,7 +87,8 @@ ALPHAX_PAPER_MIN_RR=1.25 ALPHAX_PAPER_ENTRY_MIN_RR=1.25 ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT=20 ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED=1 -ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN=3 +ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN=1 +ALPHAX_PAPER_GATE_REJECT_DEDUP_MINUTES=30 ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT=3 ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES=3 ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS=6 diff --git a/AGENTS.md b/AGENTS.md index 8f86f67..5d58052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 目标架构是:统一交易宇宙 -> 多个独立策略并行扫描 -> 标准策略信号 -> 冲突/重复仲裁 -> 推荐/观察/挂单 -> paper trading 保留策略血缘 -> 按策略独立复盘。 - `strategy_version` 只表示版本,不应替代策略身份;后续推荐、挂单和交易账本都应补充 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json` 和 `factor_roles_json`。 - 现有综合确认策略在迁移期标记为 `main_composite_v1`,它只是平等策略之一,用于避免无策略来源的推荐继续进入 paper trading。 -- 当前已拆出的独立策略包括:`box_retest_1h_v1` / `box_retest_4h_v1` 箱体突破回踩、`volume_ignition_1h_v1` 1H放量突破启动、`compression_breakout_4h_v1` 4H压缩蓄力突破、`intraday_momentum_15m_v1` 15m日内动量延续。它们可以共享交易宇宙和行情数据,但必须保留各自的触发、入场、失效和 paper trading 门禁。 +- 当前日内山寨币多空策略池包括:`long_intraday_momentum_15m_1h_v1` 多头日内动量启动、`long_second_wave_pullback_1h_v1` 多头二波回踩、`long_compression_breakout_1h_4h_v1` 多头压缩突破、`long_box_retest_4h_v1` 多头4H箱体回踩、`short_breakdown_retest_1h_v1` 空头破位反抽、`short_weak_bounce_failure_15m_1h_v1` 空头弱反弹失败。它们可以共享交易宇宙和行情数据,但必须保留各自的触发、入场、失效和 paper trading 门禁。 - 空头策略不能简单反转多头策略。当前第一版空头机会是 `breakdown_retest_short_1h_v1`,核心剧本是“1H箱体下破 -> 反抽箱体下沿/均线 -> 反抽失败 -> 等反抽或开空”,并使用独立 `strategy_code`、`factor_roles`、RR/止损几何和复盘口径。 - 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。 @@ -194,7 +194,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 sizing、订单触发、账户风控都应有可被 paper trading 和 live trading 复用的核心模块。不要把这些规则直接写死在 `app/db/paper_trading.py`、`routes_live_trading.py` 或页面 JS 中。 - 交易执行层已支持 `side=long/short`:paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前发现/确认层已接入第一版独立空头策略 `breakdown_retest_short_1h_v1`;后续新增做空策略也必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。 - 多空交易数学必须走共享核心模块:`app/core/trade_math.py`、`app/core/order_lifecycle.py`、`app/core/trade_direction.py`。不要在页面、route、paper/live 适配层里重新手写“多头涨了盈利/空头跌了盈利”的判断,避免后续实盘同步和复盘口径分裂。 -- 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`、`breakdown_retest_short_1h_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition`、`breakdown_retest_1h_short` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 +- 多策略基础设施当前内置 `long_intraday_momentum_15m_1h_v1`、`long_second_wave_pullback_1h_v1`、`long_compression_breakout_1h_4h_v1`、`long_box_retest_4h_v1`、`short_breakdown_retest_1h_v1`、`short_weak_bounce_failure_15m_1h_v1`。旧 code 如 `main_composite_v1`、`long_momentum_breakout_15m_1h_v1`、`box_retest_4h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1` 只作为兼容别名。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition`、`breakdown_retest_1h_short` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 + - `paper_gate_reject` 是复盘诊断事件,不能无限重复刷屏。同一推荐、同一策略、同一拒绝原因、同一动作状态默认 30 分钟内只记录一次;原因变化时才记录新事件,避免污染策略优胜劣汰统计。 - 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。 ## 5. 数据与状态中心 diff --git a/app/config/system_config.py b/app/config/system_config.py index 2e8bb77..beb1d9b 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -185,7 +185,7 @@ def default_paper_trading_config(): "entry_min_rr": _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", paper_min_rr), "max_stop_loss_leverage_risk_pct": _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0), "dynamic_leverage_enabled": _env_bool("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", True), - "dynamic_leverage_min": _env_float("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", 3.0), + "dynamic_leverage_min": _env_float("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", 1.0), "max_account_drawdown_pause_pct": _env_float("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", 3.0), "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), @@ -257,7 +257,7 @@ def _paper_trading_env_overrides(): "ALPHAX_PAPER_ENTRY_MIN_RR": ("entry_min_rr", lambda: _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", overrides.get("min_rr", 1.25))), "ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT": ("max_stop_loss_leverage_risk_pct", lambda: _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0)), "ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED": ("dynamic_leverage_enabled", lambda: _env_bool("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", True)), - "ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN": ("dynamic_leverage_min", lambda: _env_float("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", 3.0)), + "ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN": ("dynamic_leverage_min", lambda: _env_float("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", 1.0)), "ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT": ("max_account_drawdown_pause_pct", lambda: _env_float("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", 3.0)), "ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES": ("pause_after_weak_entries", lambda: _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3)), "ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS": ("weak_entry_window_hours", lambda: _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0)), diff --git a/app/core/factor_roles.py b/app/core/factor_roles.py index c47c00c..645e5f5 100644 --- a/app/core/factor_roles.py +++ b/app/core/factor_roles.py @@ -37,6 +37,10 @@ DEFAULT_FACTOR_ROLES: dict[str, str] = { "vp_fly_1h_current": TRIGGER, "volume_consecutive_1h": CONFIRMATION, "volume_divergence_1h": RISK, + "momentum_breakout_15m_1h": TRIGGER, + "compression_breakout_1h_4h": TRIGGER, + "box_retest_4h": TRIGGER, + "weak_bounce_failure_15m_1h_short": TRIGGER, "short_tf_15m_ignition": TRIGGER, "short_tf_5m_ignition": PREREQUISITE, "short_tf_resonance": CONFIRMATION, diff --git a/app/core/signal_taxonomy.py b/app/core/signal_taxonomy.py index 878d6e2..97eb1e8 100644 --- a/app/core/signal_taxonomy.py +++ b/app/core/signal_taxonomy.py @@ -19,6 +19,10 @@ SIGNAL_CODE_LABELS = { "short_tf_5m_ignition": "5min极早期启动", "short_tf_resonance": "短周期共振", "volume_divergence_1h": "1H量价背离", + "momentum_breakout_15m_1h": "15m突破叠加1H放量", + "compression_breakout_1h_4h": "1H/4H压缩突破", + "box_retest_4h": "4H箱体回踩", + "weak_bounce_failure_15m_1h_short": "弱反弹失败做空", "static_accum_4h": "4H静K蓄力", "higher_lows_4h": "4H底部抬高", "compression_surge_4h": "4H压缩放量", @@ -89,6 +93,10 @@ _PATTERNS = [ ("short_tf_15m_ignition", ("15min短周期启动", "15m短周期启动", "15min 早期启动")), ("short_tf_5m_ignition", ("5min极早期启动", "5m极早期启动", "5min 早期启动")), ("volume_divergence_1h", ("量价背离", "放量但无量价齐飞")), + ("momentum_breakout_15m_1h", ("15m突破", "1H放量")), + ("compression_breakout_1h_4h", ("压缩突破",)), + ("box_retest_4h", ("4H箱体回踩",)), + ("weak_bounce_failure_15m_1h_short", ("弱反弹失败",)), ("static_accum_4h", ("静K蓄力", "静K旁路")), ("box_breakout_pullback_1h", ("1H", "箱体", "突破", "回踩")), ("box_breakout_pullback_4h", ("4H", "箱体", "突破", "回踩")), diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py index 389a6ec..177abff 100644 --- a/app/core/strategy_registry.py +++ b/app/core/strategy_registry.py @@ -5,18 +5,22 @@ from __future__ import annotations from dataclasses import dataclass, field -LONG_MOMENTUM_BREAKOUT_STRATEGY = "long_momentum_breakout_15m_1h_v1" +LONG_INTRADAY_MOMENTUM_STRATEGY = "long_intraday_momentum_15m_1h_v1" +LONG_MOMENTUM_BREAKOUT_STRATEGY = LONG_INTRADAY_MOMENTUM_STRATEGY LONG_SECOND_WAVE_PULLBACK_STRATEGY = "long_second_wave_pullback_1h_v1" +LONG_COMPRESSION_BREAKOUT_STRATEGY = "long_compression_breakout_1h_4h_v1" +LONG_BOX_RETEST_4H_STRATEGY = "long_box_retest_4h_v1" SHORT_BREAKDOWN_RETEST_STRATEGY = "short_breakdown_retest_1h_v1" +SHORT_WEAK_BOUNCE_FAILURE_STRATEGY = "short_weak_bounce_failure_15m_1h_v1" # Compatibility aliases for old imports. These aliases intentionally map old # names to the new active strategy pool so new data never emits retired codes. MAIN_COMPOSITE_STRATEGY = LONG_MOMENTUM_BREAKOUT_STRATEGY BOX_RETEST_1H_STRATEGY = LONG_SECOND_WAVE_PULLBACK_STRATEGY -BOX_RETEST_4H_STRATEGY = LONG_SECOND_WAVE_PULLBACK_STRATEGY -VOLUME_IGNITION_1H_STRATEGY = LONG_MOMENTUM_BREAKOUT_STRATEGY -COMPRESSION_BREAKOUT_4H_STRATEGY = LONG_SECOND_WAVE_PULLBACK_STRATEGY -INTRADAY_MOMENTUM_15M_STRATEGY = LONG_MOMENTUM_BREAKOUT_STRATEGY +BOX_RETEST_4H_STRATEGY = LONG_BOX_RETEST_4H_STRATEGY +VOLUME_IGNITION_1H_STRATEGY = LONG_INTRADAY_MOMENTUM_STRATEGY +COMPRESSION_BREAKOUT_4H_STRATEGY = LONG_COMPRESSION_BREAKOUT_STRATEGY +INTRADAY_MOMENTUM_15M_STRATEGY = LONG_INTRADAY_MOMENTUM_STRATEGY BREAKDOWN_RETEST_SHORT_1H_STRATEGY = SHORT_BREAKDOWN_RETEST_STRATEGY @@ -34,9 +38,9 @@ class StrategyDefinition: STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { - LONG_MOMENTUM_BREAKOUT_STRATEGY: StrategyDefinition( - strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, - strategy_name="多头动量启动", + LONG_INTRADAY_MOMENTUM_STRATEGY: StrategyDefinition( + strategy_code=LONG_INTRADAY_MOMENTUM_STRATEGY, + strategy_name="多头日内动量启动", description="15m当前突破叠加1H成交量/波动增强,捕捉山寨币日内到1-3天启动段。", direction="long", frequency_profile="intraday", @@ -62,7 +66,7 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { "trailing_activate_pnl_pct": 2.0, "trailing_volatility_min_activation_pct": 1.8, "dynamic_leverage_enabled": True, - "dynamic_leverage_min": 3, + "dynamic_leverage_min": 1, }, ), LONG_SECOND_WAVE_PULLBACK_STRATEGY: StrategyDefinition( @@ -94,7 +98,70 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { "trailing_activate_pnl_pct": 2.0, "trailing_volatility_min_activation_pct": 1.8, "dynamic_leverage_enabled": True, - "dynamic_leverage_min": 3, + "dynamic_leverage_min": 1, + }, + ), + LONG_COMPRESSION_BREAKOUT_STRATEGY: StrategyDefinition( + strategy_code=LONG_COMPRESSION_BREAKOUT_STRATEGY, + strategy_name="多头压缩突破", + description="1H/4H低波动压缩后突然放量突破,捕捉启动前后第一段。", + direction="long", + frequency_profile="intraday", + mode="paper_enabled", + entry_gate_config={ + "min_entry_score_buy_now": 2, + "min_entry_score_wait_pullback": 1, + "min_rr_buy_now": 1.25, + "breakout_distance_wait_pct": 10, + "gain_24h_wait_pct": 12, + }, + paper_config={ + "frequency_profile": "intraday_trading", + "target_trades_per_day_min": 2, + "target_trades_per_day_max": 4, + "entry_min_rec_score": 24, + "order_min_rec_score": 24, + "entry_min_rr": 1.25, + "order_min_rr": 1.25, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": True, + "order_expire_hours": 8, + "trailing_activate_pnl_pct": 2.0, + "trailing_volatility_min_activation_pct": 1.8, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 1, + }, + ), + LONG_BOX_RETEST_4H_STRATEGY: StrategyDefinition( + strategy_code=LONG_BOX_RETEST_4H_STRATEGY, + strategy_name="多头4H箱体回踩", + description="4H箱体突破后第一次或第二次回踩箱体上沿/均线承接。", + direction="long", + frequency_profile="intraday", + mode="paper_enabled", + entry_gate_config={ + "min_entry_score_buy_now": 2, + "min_entry_score_wait_pullback": 0, + "min_rr_buy_now": 1.3, + "max_wait_pullback_deviation_pct": 12, + "breakout_distance_wait_pct": 14, + "gain_24h_wait_pct": 18, + }, + paper_config={ + "frequency_profile": "intraday_trading", + "target_trades_per_day_min": 1, + "target_trades_per_day_max": 3, + "entry_min_rec_score": 24, + "order_min_rec_score": 24, + "entry_min_rr": 1.3, + "order_min_rr": 1.3, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": False, + "order_expire_hours": 12, + "trailing_activate_pnl_pct": 2.2, + "trailing_volatility_min_activation_pct": 2.0, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 1, }, ), SHORT_BREAKDOWN_RETEST_STRATEGY: StrategyDefinition( @@ -126,7 +193,38 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { "trailing_activate_pnl_pct": 2.0, "trailing_volatility_min_activation_pct": 1.8, "dynamic_leverage_enabled": True, - "dynamic_leverage_min": 2, + "dynamic_leverage_min": 1, + }, + ), + SHORT_WEAK_BOUNCE_FAILURE_STRATEGY: StrategyDefinition( + strategy_code=SHORT_WEAK_BOUNCE_FAILURE_STRATEGY, + strategy_name="空头弱反弹失败", + description="弱势环境下15m/1H反弹无量,反抽均线或前支撑后再次转弱。", + direction="short", + frequency_profile="intraday", + mode="paper_enabled", + entry_gate_config={ + "direction": "short", + "min_entry_score_buy_now": 2, + "min_entry_score_wait_pullback": 1, + "min_rr_buy_now": 1.3, + "max_retest_deviation_pct": 8, + }, + paper_config={ + "frequency_profile": "intraday_trading", + "target_trades_per_day_min": 1, + "target_trades_per_day_max": 3, + "entry_min_rec_score": 18, + "order_min_rec_score": 18, + "entry_min_rr": 1.3, + "order_min_rr": 1.3, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": True, + "order_expire_hours": 6, + "trailing_activate_pnl_pct": 2.0, + "trailing_volatility_min_activation_pct": 1.8, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 1, }, ), } @@ -135,17 +233,18 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { def normalize_strategy_code(strategy_code: str | None) -> str: code = str(strategy_code or "").strip() legacy_map = { - "main_composite_v1": LONG_MOMENTUM_BREAKOUT_STRATEGY, - "volume_ignition_1h_v1": LONG_MOMENTUM_BREAKOUT_STRATEGY, - "intraday_momentum_15m_v1": LONG_MOMENTUM_BREAKOUT_STRATEGY, + "main_composite_v1": LONG_INTRADAY_MOMENTUM_STRATEGY, + "long_momentum_breakout_15m_1h_v1": LONG_INTRADAY_MOMENTUM_STRATEGY, + "volume_ignition_1h_v1": LONG_INTRADAY_MOMENTUM_STRATEGY, + "intraday_momentum_15m_v1": LONG_INTRADAY_MOMENTUM_STRATEGY, "box_retest_1h_v1": LONG_SECOND_WAVE_PULLBACK_STRATEGY, - "box_retest_4h_v1": LONG_SECOND_WAVE_PULLBACK_STRATEGY, - "compression_breakout_4h_v1": LONG_SECOND_WAVE_PULLBACK_STRATEGY, + "box_retest_4h_v1": LONG_BOX_RETEST_4H_STRATEGY, + "compression_breakout_4h_v1": LONG_COMPRESSION_BREAKOUT_STRATEGY, "breakdown_retest_short_1h_v1": SHORT_BREAKDOWN_RETEST_STRATEGY, } if code in legacy_map: return legacy_map[code] - return code or LONG_MOMENTUM_BREAKOUT_STRATEGY + return code or LONG_INTRADAY_MOMENTUM_STRATEGY def strategy_definition(strategy_code: str | None) -> StrategyDefinition: diff --git a/app/db/intraday_frequency.py b/app/db/intraday_frequency.py index 3299b27..9b33cf1 100644 --- a/app/db/intraday_frequency.py +++ b/app/db/intraday_frequency.py @@ -47,11 +47,39 @@ def _counter_items(counter: Counter, limit=10): return [{"reason": key, "count": count} for key, count in counter.most_common(limit)] +def _compact_detail(detail: dict) -> dict: + gate_detail = detail.get("gate_detail") if isinstance(detail.get("gate_detail"), dict) else {} + return { + "reason": detail.get("reason") or "", + "gate_reasons": detail.get("gate_reasons") if isinstance(detail.get("gate_reasons"), list) else [], + "target_price": gate_detail.get("target_price"), + "distance_to_entry_pct": gate_detail.get("distance_to_entry_pct"), + "max_distance_to_entry_pct": gate_detail.get("max_distance_to_entry_pct"), + "rr1": gate_detail.get("rr1"), + "min_rr": gate_detail.get("min_rr"), + "rec_score": gate_detail.get("rec_score"), + "min_rec_score": gate_detail.get("min_rec_score"), + "execution_status": detail.get("execution_status") or "", + "action_status": detail.get("action_status") or "", + } + + def _extract_gate_reasons(rows): counter = Counter() samples = [] + seen = set() for row in rows: detail = _loads(row.get("detail_json"), {}) + dedupe_key = ( + row.get("symbol") or "", + row.get("strategy_code") or detail.get("strategy_code") or "", + detail.get("recommendation_id") or detail.get("rec_id") or "", + detail.get("reason") or "", + tuple(detail.get("gate_reasons") if isinstance(detail.get("gate_reasons"), list) else []), + ) + if dedupe_key in seen: + continue + seen.add(dedupe_key) reason = str(detail.get("reason") or "").strip() if reason: counter[reason] += 1 @@ -69,7 +97,7 @@ def _extract_gate_reasons(rows): "symbol": row.get("symbol") or "", "strategy_code": row.get("strategy_code") or detail.get("strategy_code") or "", "reason": reason or (gate_reasons[0] if isinstance(gate_reasons, list) and gate_reasons else decision), - "detail": detail, + "detail": _compact_detail(detail), }) return _counter_items(counter), samples[:12] diff --git a/app/db/migrations/0020_intraday_long_short_strategy_pool.sql b/app/db/migrations/0020_intraday_long_short_strategy_pool.sql new file mode 100644 index 0000000..11d5bd4 --- /dev/null +++ b/app/db/migrations/0020_intraday_long_short_strategy_pool.sql @@ -0,0 +1,30 @@ +INSERT INTO strategy_catalog ( + strategy_code, strategy_name, strategy_version, status, mode, description, config_json, created_at, updated_at +) VALUES + ('long_intraday_momentum_15m_1h_v1', '多头日内动量启动', '', 'active', 'paper_enabled', '15m当前突破叠加1H成交量/波动增强,捕捉山寨币日内到1-3天启动段。', '{}', NOW()::TEXT, NOW()::TEXT), + ('long_second_wave_pullback_1h_v1', '多头二波回踩', '', 'active', 'paper_enabled', '强势第一波后回踩EMA、箱体上沿或前高转支撑,再次承接的短线策略。', '{}', NOW()::TEXT, NOW()::TEXT), + ('long_compression_breakout_1h_4h_v1', '多头压缩突破', '', 'active', 'paper_enabled', '1H/4H低波动压缩后突然放量突破,捕捉启动前后第一段。', '{}', NOW()::TEXT, NOW()::TEXT), + ('long_box_retest_4h_v1', '多头4H箱体回踩', '', 'active', 'paper_enabled', '4H箱体突破后第一次或第二次回踩箱体上沿/均线承接。', '{}', NOW()::TEXT, NOW()::TEXT), + ('short_breakdown_retest_1h_v1', '空头破位反抽', '', 'active', 'paper_enabled', '1H支撑或箱体下沿破位后反抽失败,叠加15m弱确认和相对弱势的空头短线策略。', '{}', NOW()::TEXT, NOW()::TEXT), + ('short_weak_bounce_failure_15m_1h_v1', '空头弱反弹失败', '', 'active', 'paper_enabled', '弱势环境下15m/1H反弹无量,反抽均线或前支撑后再次转弱。', '{}', NOW()::TEXT, NOW()::TEXT) +ON CONFLICT(strategy_code) DO UPDATE SET + strategy_name=EXCLUDED.strategy_name, + status=EXCLUDED.status, + mode=EXCLUDED.mode, + description=EXCLUDED.description, + updated_at=NOW()::TEXT; + +UPDATE strategy_catalog +SET status='retired', + mode='disabled', + updated_at=NOW()::TEXT +WHERE strategy_code IN ( + 'main_composite_v1', + 'long_momentum_breakout_15m_1h_v1', + 'volume_ignition_1h_v1', + 'intraday_momentum_15m_v1', + 'box_retest_1h_v1', + 'box_retest_4h_v1', + 'compression_breakout_4h_v1', + 'breakdown_retest_short_1h_v1' +); diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 0f07bb1..82f48fb 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -330,12 +330,16 @@ def _paper_cfg_for_rec(rec: dict, config: dict | None = None) -> dict: "ALPHAX_PAPER_ORDER_EXPIRE_HOURS": "order_expire_hours", "ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT": "trailing_activate_pnl_pct", "ALPHAX_PAPER_TRAILING_VOL_MIN_ACTIVATE_PCT": "trailing_volatility_min_activation_pct", + "ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT": "max_stop_loss_leverage_risk_pct", + "ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN": "dynamic_leverage_min", } for env_name, key in env_float_keys.items(): if os.getenv(env_name) is not None: cfg[key] = _safe_float(os.getenv(env_name), cfg.get(key)) if os.getenv("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER") is not None: cfg["order_require_current_trigger"] = str(os.getenv("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER") or "").strip().lower() in {"1", "true", "yes", "on"} + if os.getenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED") is not None: + cfg["dynamic_leverage_enabled"] = str(os.getenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED") or "").strip().lower() in {"1", "true", "yes", "on"} return cfg @@ -559,6 +563,47 @@ def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str ) +def _same_gate_reject(a: dict, b: dict) -> bool: + return ( + str(a.get("reason") or "") == str(b.get("reason") or "") + and list(a.get("gate_reasons") or []) == list(b.get("gate_reasons") or []) + and str(a.get("action_status") or "") == str(b.get("action_status") or "") + and str(a.get("execution_status") or "") == str(b.get("execution_status") or "") + ) + + +def _recent_recommendation_event_exists(conn, rec_id: int, event_type: str, strategy_code: str, detail: dict, minutes: int = 30, event_time: str = "") -> bool: + if rec_id <= 0 or event_type != "paper_gate_reject": + return False + try: + reference = datetime.fromisoformat(str(event_time or "").replace("Z", "+00:00")).replace(tzinfo=None) if event_time else datetime.now() + except Exception: + reference = datetime.now() + cutoff = (reference - timedelta(minutes=max(1, int(minutes or 30)))).isoformat() + try: + rows = conn.execute( + """ + SELECT detail_json + FROM paper_trade_events + WHERE recommendation_id=%s + AND trade_id=0 + AND event_type=%s + AND COALESCE(strategy_code,'')=%s + AND event_time::timestamp >= %s::timestamp + ORDER BY event_time DESC + LIMIT 20 + """, + (rec_id, event_type, strategy_code, cutoff), + ).fetchall() + except Exception: + return False + for row in rows: + existing = _loads_json(row.get("detail_json"), {}) + if _same_gate_reject(existing, detail): + return True + return False + + def _record_recommendation_event(conn, rec: dict, event_type: str, message: str, detail=None, event_time: str = "", price: float = 0.0): rec = rec or {} plan = _entry_plan(rec) @@ -572,6 +617,16 @@ def _record_recommendation_event(conn, rec: dict, event_type: str, message: str, "action_status": rec.get("action_status") or plan.get("entry_action") or "", "entry_plan": plan, } + if _recent_recommendation_event_exists( + conn, + _safe_int(rec.get("id")), + event_type, + event_detail["strategy_code"], + event_detail, + minutes=_safe_int(os.getenv("ALPHAX_PAPER_GATE_REJECT_DEDUP_MINUTES") or 30), + event_time=event_time, + ): + return _record_event( conn, 0, diff --git a/app/db/scheduler_db.py b/app/db/scheduler_db.py index 6c475f9..516f3d7 100644 --- a/app/db/scheduler_db.py +++ b/app/db/scheduler_db.py @@ -61,7 +61,7 @@ DEFAULT_JOBS = [ { "job_name": "confirm", "command": "confirm", - "args": ["--compact", "--limit", "8", "--max-seconds", "90"], + "args": ["--compact", "--limit", "24", "--max-seconds", "180"], "every_seconds": 600, "initial_delay": 40, "lock_group": "recommendation_write", diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index f2fb719..c51f540 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -43,9 +43,12 @@ from app.config.config_loader import ( from app.core.opportunity_lifecycle import apply_entry_quality_gate from app.core.strategy_registry import ( BREAKDOWN_RETEST_SHORT_1H_STRATEGY, + BOX_RETEST_4H_STRATEGY, + COMPRESSION_BREAKOUT_4H_STRATEGY, LONG_MOMENTUM_BREAKOUT_STRATEGY, LONG_SECOND_WAVE_PULLBACK_STRATEGY, is_strategy_allowed_for_side, + strategy_entry_gate_config, ) from app.core.trade_direction import direction_label, normalize_trade_side from app.core.opportunity_level import ( @@ -62,10 +65,17 @@ from app.db.onchain_db import get_onchain_factor_context from app.db.strategy_signal_queries import insert_strategy_signal from app.services.market_overview import get_crypto_market_overview from app.strategies.altcoin_breakout import ( + build_compression_breakout_4h_signal, + build_long_box_retest_4h_signal, build_long_momentum_breakout_signal, build_long_second_wave_pullback_signal, ) -from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h +from app.strategies.orchestrator import arbitrate_strategy_signals +from app.strategies.short_breakdown import ( + build_breakdown_retest_short_1h_signal, + build_short_weak_bounce_failure_signal, + detect_breakdown_retest_short_1h, +) 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, @@ -101,25 +111,35 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {} signal_candidates = [] if trade_side == "short": - if not short_1h.get("detected"): - return {} + if short_1h.get("detected"): + signal_candidates.append( + build_breakdown_retest_short_1h_signal( + symbol=symbol, + current_price=result.get("price") or 0, + detection=short_1h, + entry_plan=entry_plan or {}, + market_regime=market_regime, + decision_log=result.get("decision_log") or {}, + ) + ) signal_candidates.append( - build_breakdown_retest_short_1h_signal( + build_short_weak_bounce_failure_signal( symbol=symbol, - current_price=result.get("price") or 0, - detection=short_1h, + result=result, entry_plan=entry_plan or {}, market_regime=market_regime, - decision_log=result.get("decision_log") or {}, ) ) else: signal_candidates.extend([ build_long_momentum_breakout_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), build_long_second_wave_pullback_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), + build_compression_breakout_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), + build_long_box_retest_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), ]) + signal_candidates = arbitrate_strategy_signals([item for item in signal_candidates if item]) saved_payloads = [] - for signal in [item for item in signal_candidates if item]: + for signal in signal_candidates: payload = signal.to_json_dict() if hasattr(signal, "to_json_dict") else dict(signal) if not is_strategy_allowed_for_side(payload.get("strategy_code"), trade_side): continue @@ -1731,6 +1751,22 @@ def confirm_burst(symbol, cand): signals.append(f"🟡 历史强背景+当前结构确认(score≥{structure_gate_score})") confirmed = True + intraday_trigger_confirmed = False + if not confirmed and fresh_ok and trade_side != "short": + intraday_cfg = confirm_cfg.get("intraday_current_trigger", {}) + if intraday_cfg.get("enabled", True): + min_intraday_score = float(intraday_cfg.get("min_score") or 7) + required_action = str(intraday_cfg.get("require_entry_action") or "即刻买入").strip() + action_ok = entry_action in (required_action, "可即刻买入") if required_action else entry_action in ("即刻买入", "可即刻买入") + current_15m_trigger = bool(current_trigger_times) or (action_ok and bool(pa_15min_result) and not pa_15min_result.get("false_breakout")) + context_ok = True + if intraday_cfg.get("require_m30_or_1h_context", False): + context_ok = bool(m30_aligned or bp_1h.get("detected") or vp_fly_count >= 1 or vol_ratio >= 2.0) + if action_ok and current_15m_trigger and context_ok and score >= min_intraday_score: + signals.append(f"🟢 日内当前触发确认(score≥{min_intraday_score:g})") + confirmed = True + intraday_trigger_confirmed = True + # ---- v1.7.0: 强共振旁路(在量价齐飞门控未过时启用)---- bypass_confirmed = False if fresh_ok and not confirmed: @@ -1984,6 +2020,14 @@ def confirm_burst(symbol, cand): sector_context=cand_detail.get("sector_context", {}), m30_aligned=m30_aligned, ) + if intraday_trigger_confirmed: + level_meta = { + **level_meta, + "opportunity_level": "intraday_breakout", + "label": "日内启动", + "max_action": "buy_now", + "plan_basis": list(dict.fromkeys([*(level_meta.get("plan_basis") or []), "日内当前触发确认"])), + } opportunity_level = level_meta.get("opportunity_level", "structure_watch") stop_loss, stop_basis = select_level_stop_loss( level=opportunity_level, @@ -2038,6 +2082,8 @@ def confirm_burst(symbol, cand): if trade_side == "short" and short_entry_plan is not None: entry_plan = short_entry_plan gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY + elif intraday_trigger_confirmed: + gate_strategy_code = LONG_MOMENTUM_BREAKOUT_STRATEGY else: signal_text = " ".join(str(x or "") for x in signals) has_second_wave_context = ( @@ -2048,6 +2094,10 @@ def confirm_burst(symbol, cand): ) gate_strategy_code = LONG_SECOND_WAVE_PULLBACK_STRATEGY if has_second_wave_context else LONG_MOMENTUM_BREAKOUT_STRATEGY entry_plan.setdefault("strategy_code", gate_strategy_code) + strategy_gate_cfg = strategy_entry_gate_config(gate_strategy_code) + strategy_min_rr = float(strategy_gate_cfg.get("min_rr_buy_now") or 1.5) + entry_plan["risk_reward_ok"] = rr1 >= strategy_min_rr + entry_plan["min_rr_required"] = strategy_min_rr # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 gated_action, gated_plan, gate_reasons = apply_entry_quality_gate( @@ -2298,6 +2348,10 @@ def _result_brief(item: dict) -> dict: inferred_strategy = BREAKDOWN_RETEST_SHORT_1H_STRATEGY elif side == "short": inferred_strategy = "" + elif any(key in signal_text for key in ("4H箱体突破回踩", "4H 箱体突破回踩", "4H箱体回踩")): + inferred_strategy = BOX_RETEST_4H_STRATEGY + elif any(key in signal_text for key in ("压缩突破", "压缩放量", "低波动压缩", "4H压缩")): + inferred_strategy = COMPRESSION_BREAKOUT_4H_STRATEGY elif any(key in signal_text for key in ("24h强势榜", "回踩", "箱体突破回踩")): inferred_strategy = LONG_SECOND_WAVE_PULLBACK_STRATEGY elif any(key in signal_text for key in ("15min", "短周期", "量价齐飞", "放量")): diff --git a/app/strategies/altcoin_breakout.py b/app/strategies/altcoin_breakout.py index 645ca25..54ec673 100644 --- a/app/strategies/altcoin_breakout.py +++ b/app/strategies/altcoin_breakout.py @@ -10,6 +10,8 @@ from __future__ import annotations from app.core.factor_roles import CONFIRMATION, ENTRY, PREREQUISITE, RISK, TRIGGER from app.core.strategy_contract import StrategySignal, current_strategy_version from app.core.strategy_registry import ( + LONG_BOX_RETEST_4H_STRATEGY, + LONG_COMPRESSION_BREAKOUT_STRATEGY, LONG_MOMENTUM_BREAKOUT_STRATEGY, LONG_SECOND_WAVE_PULLBACK_STRATEGY, ) @@ -51,6 +53,18 @@ def _has_second_wave_context(result: dict) -> bool: return bool(has_first_wave and has_pullback_context) +def _has_4h_box_retest_context(result: dict) -> bool: + text = _signals_text(result) + ctx = (result or {}).get("market_context") or {} + bp = (result or {}).get("box_breakout_pullback_4h") or ctx.get("box_breakout_pullback_4h") or {} + return bool(bp.get("detected")) or any(key in text for key in ("4H箱体突破回踩", "4H 箱体突破回踩", "4H箱体回踩")) + + +def _has_compression_context(result: dict) -> bool: + text = _signals_text(result) + return any(key in text for key in ("压缩放量", "压缩突破", "低波动压缩", "静K蓄力", "4H压缩")) + + def _trigger_context(result: dict) -> dict: return (result or {}).get("trigger_context") or ((result or {}).get("market_context") or {}).get("trigger_context") or {} @@ -164,12 +178,93 @@ def build_long_second_wave_pullback_signal(*, symbol: str, result: dict, entry_p def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: - return None + """Compatibility builder: 1H volume ignition is part of intraday momentum.""" + return build_long_momentum_breakout_signal(symbol=symbol, result=result, entry_plan=entry_plan) def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: - return None + if _has_short_context(result) or _has_4h_box_retest_context(result): + return None + text = _signals_text(result) + if not _has_compression_context(result): + return None + has_breakout = any(key in text for key in ("突破", "起爆", "放量", "量价齐飞", "15min")) + if not has_breakout: + return None + status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=True, allow_wait=True) + score = _safe_float(result.get("score")) + confidence = min(100.0, max(0.0, score * 6 + (12 if _has_current_trigger(result) else 0) + 8)) + return StrategySignal( + strategy_code=LONG_COMPRESSION_BREAKOUT_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="long", + status=status, + confidence=confidence, + score=score, + trigger={ + "factor_code": "compression_breakout_1h_4h", + "factor_label": "1H/4H压缩后放量突破", + "has_current_trigger": _has_current_trigger(result), + "trigger_status": _trigger_context(result).get("trigger_status") or "", + "opportunity_level": "intraday_to_3d", + }, + factor_roles={ + "compression_breakout_1h_4h": TRIGGER, + "static_accum_4h": PREREQUISITE, + "short_tf_15m_ignition": ENTRY, + "volume_consecutive_1h": CONFIRMATION, + "false_breakout": RISK, + "risk_reward_bad": RISK, + }, + entry_plan=entry_plan or {}, + risk_plan={ + "invalid_if": ["突破后跌回压缩区", "放量冲高回落", "15m触发失败", "RR不足"], + "risk_reasons": reasons, + }, + decision_log={"module": LONG_COMPRESSION_BREAKOUT_STRATEGY, "decision": status, "reasons": reasons}, + ) def build_intraday_momentum_15m_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: - return None + return build_long_momentum_breakout_signal(symbol=symbol, result=result, entry_plan=entry_plan) + + +def build_long_box_retest_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + if _has_short_context(result): + return None + if not _has_4h_box_retest_context(result): + return None + status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True) + score = _safe_float(result.get("score")) + confidence = min(100.0, max(0.0, score * 6 + (10 if _has_current_trigger(result) else 0) + 10)) + return StrategySignal( + strategy_code=LONG_BOX_RETEST_4H_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="long", + status=status, + confidence=confidence, + score=score, + trigger={ + "factor_code": "box_retest_4h", + "factor_label": "4H箱体突破后回踩承接", + "has_current_trigger": _has_current_trigger(result), + "trigger_status": _trigger_context(result).get("trigger_status") or "", + "opportunity_level": "swing_1_3d", + }, + factor_roles={ + "box_retest_4h": TRIGGER, + "box_breakout_pullback_4h": TRIGGER, + "pullback_15m_confirm": ENTRY, + "volume_consecutive_1h": CONFIRMATION, + "false_breakout": RISK, + "risk_reward_bad": RISK, + }, + entry_plan=entry_plan or {}, + risk_plan={ + "invalid_if": ["跌回箱体内部", "回踩放量跌破", "突破后过久才回踩", "RR不足"], + "risk_reasons": reasons, + }, + decision_log={"module": LONG_BOX_RETEST_4H_STRATEGY, "decision": status, "reasons": reasons}, + ) diff --git a/app/strategies/orchestrator.py b/app/strategies/orchestrator.py index 562e3f5..bca5508 100644 --- a/app/strategies/orchestrator.py +++ b/app/strategies/orchestrator.py @@ -1,20 +1,68 @@ -"""Minimal strategy orchestration helpers for first-stage multi-strategy rollout.""" +"""Strategy orchestration helpers for the multi-strategy intraday rollout.""" from __future__ import annotations from app.core.strategy_contract import StrategySignal -def arbitrate_strategy_signals(signals: list[StrategySignal]) -> list[StrategySignal]: - """Deduplicate same-symbol same-direction signals by confidence. +def _copy_with_status(signal: StrategySignal, status: str, reason: str) -> StrategySignal: + payload = signal.to_json_dict() + payload["status"] = status + risk = dict(payload.get("risk_plan") or {}) + reasons = list(risk.get("risk_reasons") or []) + if reason not in reasons: + reasons.append(reason) + risk["risk_reasons"] = reasons + payload["risk_plan"] = risk + log = dict(payload.get("decision_log") or {}) + log["decision"] = status + log["reasons"] = reasons + payload["decision_log"] = log + return StrategySignal( + strategy_code=payload["strategy_code"], + strategy_version=payload.get("strategy_version") or "", + symbol=payload.get("symbol") or "", + direction=payload.get("direction") or "long", + status=status, + confidence=float(payload.get("confidence") or 0), + score=float(payload.get("score") or 0), + run_id=payload.get("run_id") or "", + trigger=payload.get("trigger") or {}, + factor_roles=payload.get("factor_roles") or {}, + entry_plan=payload.get("entry_plan") or {}, + risk_plan=risk, + decision_log=log, + created_at=payload.get("created_at") or "", + ) - First stage deliberately avoids complex voting. Opposite directions are - left to future long/short architecture; current system is long-only. - """ + +def arbitrate_strategy_signals(signals: list[StrategySignal]) -> list[StrategySignal]: + """Deduplicate and resolve long/short conflicts for the same symbol.""" winners: dict[tuple[str, str], StrategySignal] = {} for signal in signals or []: key = (str(signal.symbol or "").upper(), str(signal.direction or "long").lower()) existing = winners.get(key) if existing is None or float(signal.confidence or 0) > float(existing.confidence or 0): winners[key] = signal - return list(winners.values()) + by_symbol: dict[str, list[StrategySignal]] = {} + for signal in winners.values(): + by_symbol.setdefault(str(signal.symbol or "").upper(), []).append(signal) + result: list[StrategySignal] = [] + for symbol_signals in by_symbol.values(): + directions = {str(item.direction or "long").lower() for item in symbol_signals} + if len(directions) < 2: + result.extend(symbol_signals) + continue + ranked = sorted(symbol_signals, key=lambda x: float(x.confidence or 0), reverse=True) + leader = ranked[0] + runner = ranked[1] + leader_gap = float(leader.confidence or 0) - float(runner.confidence or 0) + runner_candidate = str(runner.status or "") == "candidate" + if leader_gap >= 20 and not runner_candidate: + result.append(leader) + for item in ranked[1:]: + result.append(_copy_with_status(item, "observe", "同币多空冲突,低置信方向降级观察")) + else: + for item in ranked: + result.append(_copy_with_status(item, "observe", "同币多空信号冲突,等待方向确认")) + return result diff --git a/app/strategies/short_breakdown.py b/app/strategies/short_breakdown.py index dec6649..4137866 100644 --- a/app/strategies/short_breakdown.py +++ b/app/strategies/short_breakdown.py @@ -8,7 +8,7 @@ from __future__ import annotations from app.core.factor_roles import CONFIRMATION, ENTRY, RISK, TRIGGER from app.core.strategy_contract import StrategySignal, current_strategy_version -from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY +from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY, SHORT_WEAK_BOUNCE_FAILURE_STRATEGY def _safe_float(value, default=0.0) -> float: @@ -176,4 +176,64 @@ def build_breakdown_retest_short_1h_signal( ) -__all__ = ["build_breakdown_retest_short_1h_signal", "detect_breakdown_retest_short_1h"] +def build_short_weak_bounce_failure_signal( + *, + symbol: str, + result: dict, + entry_plan: dict | None = None, + market_regime: dict | None = None, +) -> StrategySignal | None: + """Build a short signal for weak bounce failures in risk-off regimes.""" + result = result or {} + entry_plan = dict(entry_plan or {}) + text = " ".join(str(x or "") for x in result.get("signals") or []) + market_regime = market_regime or result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {} + risk_level = str(market_regime.get("risk_level") or "").lower() + regime = str(market_regime.get("regime") or "").lower() + has_weak_bounce = any(key in text for key in ("弱反弹", "反抽失败", "空头加速", "放量阴线", "破位")) + has_entry = any(key in text for key in ("15min", "15m", "反抽失败", "空头加速")) + risk_off = risk_level in {"high", "critical"} or regime in {"risk_off", "bearish", "downtrend"} + if not (has_weak_bounce and has_entry and risk_off): + return None + action = str(entry_plan.get("entry_action") or result.get("entry_action") or "").strip() + status = "candidate" if action in {"可即刻买入", "即刻买入", "可开空"} else "observe" + reasons = [] if status == "candidate" else ["弱反弹失败已出现,但还缺少当前开空动作"] + score = _safe_float(result.get("score")) + confidence = min(100.0, max(0.0, score * 7 + 12)) + entry_plan.setdefault("side", "short") + entry_plan.setdefault("entry_action", "可即刻买入" if status == "candidate" else "观察") + return StrategySignal( + strategy_code=SHORT_WEAK_BOUNCE_FAILURE_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="short", + status=status, + confidence=confidence, + score=score, + trigger={ + "factor_code": "weak_bounce_failure_15m_1h_short", + "factor_label": "弱势反弹失败做空", + "risk_level": risk_level, + "regime": regime, + "entry_action": action, + }, + factor_roles={ + "weak_bounce_failure_15m_1h_short": TRIGGER, + "retest_reject_15m_short": ENTRY, + "market_risk_off_short": CONFIRMATION, + "funding_extreme": RISK, + }, + entry_plan=entry_plan, + risk_plan={ + "invalid_if": ["重新站回反抽高点", "BTC/ETH快速转强", "15m空头结构失效", "RR不足"], + "risk_reasons": reasons, + }, + decision_log={"module": SHORT_WEAK_BOUNCE_FAILURE_STRATEGY, "decision": status, "reasons": reasons}, + ) + + +__all__ = [ + "build_breakdown_retest_short_1h_signal", + "build_short_weak_bounce_failure_signal", + "detect_breakdown_retest_short_1h", +] diff --git a/docs/MULTI_STRATEGY_ARCHITECTURE.md b/docs/MULTI_STRATEGY_ARCHITECTURE.md index c17fad9..2ddceb5 100644 --- a/docs/MULTI_STRATEGY_ARCHITECTURE.md +++ b/docs/MULTI_STRATEGY_ARCHITECTURE.md @@ -44,7 +44,7 @@ 一个策略至少要定义: -- `strategy_code`:稳定代码,例如 `box_retest_4h_v1`。 +- `strategy_code`:稳定代码,例如 `long_box_retest_4h_v1`。 - `strategy_version`:版本,例如 `v2026.05.26-r1`。 - `market_regime`:适用市场环境。 - `universe_filter`:交易宇宙要求。 @@ -77,7 +77,7 @@ ## 4. 初始策略池建议 -### 4.1 `box_retest_4h_v1` +### 4.1 `long_box_retest_4h_v1` 定位:底部箱体突破后,第一次或第二次回踩箱体上沿/EMA 承接。 @@ -90,7 +90,7 @@ 注意:`box_breakout_pullback_4h` 是该策略的核心触发因子,但不等于策略本身。 -### 4.2 `momentum_acceleration_1h_v1` +### 4.2 `long_intraday_momentum_15m_1h_v1` 定位:1H 量价齐飞后的加速机会。 @@ -98,23 +98,15 @@ - 风险重点:追高、假突破、短线衰竭。 - 入场要求:必须有 15m 承接或回踩,不允许单纯因为放量就直接买。 -### 4.3 `short_tf_early_watch_v1` +### 4.3 `long_compression_breakout_1h_4h_v1` -定位:5m/15m 早期启动观察,不直接交易。 +定位:1H/4H 低波动压缩后放量突破,捕捉启动前后第一段。 -- 核心触发:`short_tf_15m_ignition`、`short_tf_5m_ignition`。 -- 默认动作:观察或加入候选,不直接进入 paper trading。 -- 升级条件:后续 1H/4H 结构确认。 +- 核心触发:`compression_breakout_1h_4h`。 +- 入场要求:必须有 15m 当前触发或回踩确认。 +- 风险重点:假突破、放量冲高回落、RR不足。 -### 4.4 `onchain_tech_confirm_v1` - -定位:链上重要资金行为 + 技术确认。 - -- 先决条件:链上事件必须可读、可映射、金额或置信度达到阈值。 -- 核心触发:鲸鱼增持、聪明钱买入、交易所流出等。 -- 入场要求:必须经过技术结构确认,不允许链上事件直接下单。 - -### 4.5 `top_gainer_second_wave_v1` +### 4.4 `long_second_wave_pullback_1h_v1` 定位:强势榜币种第一波后,回踩承接走二波。 @@ -122,6 +114,22 @@ - 核心触发:强势榜 + 回踩结构。 - 风险重点:高位回落、流动性退潮、meme 过热。 +### 4.5 `short_breakdown_retest_1h_v1` + +定位:1H 支撑/箱体下沿破位后反抽失败。 + +- 核心触发:`breakdown_retest_1h_short`。 +- 入场要求:反抽失败确认、离反抽区不能太远。 +- 风险重点:重新站回破位区、BTC/ETH 快速转强。 + +### 4.6 `short_weak_bounce_failure_15m_1h_v1` + +定位:弱势环境下 15m/1H 反弹无量,反抽均线或前支撑后再次转弱。 + +- 核心触发:`weak_bounce_failure_15m_1h_short`。 +- 确认条件:risk_off/high/critical 环境或相对弱势。 +- 风险重点:反弹转强、空头拥挤、RR不足。 + ## 5. 数据模型改造 ### 5.1 第一阶段:兼容式加字段 @@ -180,11 +188,11 @@ - 建立 `app/core/factor_roles.py`,统一因子角色分类。 - 给 `recommendation` / `paper_trades` / `paper_orders` 补 `strategy_code` 和 `strategy_signal_id`。 - 在确认层先把现有综合确认策略标为 `main_composite_v1`。 -- 把 `box_breakout_pullback_4h` 标记为 `box_retest_4h_v1` 的核心触发候选,但仍通过完整策略条件判断。 +- 把 `box_breakout_pullback_4h` 标记为 `long_box_retest_4h_v1` 的核心触发候选,但仍通过完整策略条件判断。 ### P1:拆出第一个独立策略 -目标:让 `box_retest_4h_v1` 独立运行,与其他策略平等并行。 +目标:让 `long_box_retest_4h_v1` 独立运行,与其他策略平等并行。 - 新增 `app/strategies/box_retest_4h.py`。 - 让它消费统一交易宇宙和 4H K线。 diff --git a/rules.yaml b/rules.yaml index e9e0ac7..6d8f72b 100644 --- a/rules.yaml +++ b/rules.yaml @@ -164,8 +164,13 @@ screener: sector_only_max_state: 蓄力 confirm: min_score: 5 - max_candidates_per_run: 8 - max_run_seconds: 90 + max_candidates_per_run: 24 + max_run_seconds: 180 + intraday_current_trigger: + enabled: true + min_score: 6 + require_entry_action: 即刻买入 + require_m30_or_1h_context: false http_timeout_seconds: 2.5 kline_timeout_ms: 4500 state_cooldown_hours: 6 diff --git a/static/app.html b/static/app.html index be20d76..1619cab 100644 --- a/static/app.html +++ b/static/app.html @@ -384,6 +384,7 @@ var latestVersion = ''; var currentVersion = ''; var currentFilter = ''; var currentSideFilter = ''; +var currentStrategyFilter = ''; var cachedLiveData = []; var liveOffset = 0; var liveLimit = 24; @@ -430,6 +431,8 @@ async function loadVersions() { async function onVersionChange() { currentVersion = $('historyVersionSelect').value; currentFilter = ''; + currentSideFilter = ''; + currentStrategyFilter = ''; if (curTab === 'history') await loadHistoryRecommendations(true); else await loadContent(true); } @@ -718,6 +721,8 @@ async function loadContent(reset) { } catch (renderErr) { console.error('live render failed', renderErr); var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); + if (currentStrategyFilter) visible = visible.filter(function(r){ return String(r.strategy_code || '') === currentStrategyFilter; }); + if (currentSideFilter) visible = visible.filter(function(r){ return recSide(r) === currentSideFilter; }); var weakCount = visible.filter(isWeakObserveRec).length; var fallbackItems = currentFilter === 'weak_observe' ? visible.filter(function(r){ return r.observe_tier === 'weak'; }) @@ -732,6 +737,8 @@ async function loadContent(reset) { } catch(e) { console.error('loadContent failed', e); var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); + if (currentStrategyFilter) visible = visible.filter(function(r){ return String(r.strategy_code || '') === currentStrategyFilter; }); + if (currentSideFilter) visible = visible.filter(function(r){ return recSide(r) === currentSideFilter; }); var weakCount = visible.filter(isWeakObserveRec).length; $('liveCards').innerHTML = visible.length ? visible.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount ? '
另有 '+weakCount+' 个弱观察候选已收起。
' : '') @@ -759,6 +766,7 @@ function isRenderableLiveRec(r) { } function applyFilterAndRender() { var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); + if (currentStrategyFilter) visible = visible.filter(function(r){ return String(r.strategy_code || '') === currentStrategyFilter; }); if (currentSideFilter) visible = visible.filter(function(r){ return recSide(r) === currentSideFilter; }); var weakCount = visible.filter(isWeakObserveRec).length; var filtered = visible.filter(function(r){ return !isWeakObserveRec(r); }); @@ -787,6 +795,16 @@ function setSideFilter(side) { refreshVisibleKlines(); } +function setStrategyFilter(code) { + currentStrategyFilter = code || ''; + applyFilterAndRender(); + refreshVisibleKlines(); +} + +function strategyLabelFromCode(code) { + return ({long_intraday_momentum_15m_1h_v1:'日内动量',long_momentum_breakout_15m_1h_v1:'日内动量',long_second_wave_pullback_1h_v1:'二波回踩',long_compression_breakout_1h_4h_v1:'压缩突破',long_box_retest_4h_v1:'4H箱体',short_breakdown_retest_1h_v1:'破位反抽',short_weak_bounce_failure_15m_1h_v1:'弱反弹失败'}[code||''] || code || '未识别策略'); +} + function renderLiveStats(data) { var visible = []; try { @@ -794,12 +812,13 @@ function renderLiveStats(data) { } catch (e) { console.error('renderLiveStats failed', e); } - var statusBase = visible.filter(function(r){ return !currentSideFilter || recSide(r) === currentSideFilter; }); + var statusBase = visible.filter(function(r){ return (!currentStrategyFilter || String(r.strategy_code || '') === currentStrategyFilter) && (!currentSideFilter || recSide(r) === currentSideFilter); }); var total = statusBase.length; var buy = statusBase.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length; var observeStrong = statusBase.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length; var observeWeak = statusBase.filter(function(r){ return r.observe_tier === 'weak'; }).length; var directionBase = visible.filter(function(r){ + if (currentStrategyFilter && String(r.strategy_code || '') !== currentStrategyFilter) return false; if (!currentFilter) return !isWeakObserveRec(r); if (currentFilter === 'buy_now') return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r); if (currentFilter === 'observe') return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; @@ -809,6 +828,21 @@ function renderLiveStats(data) { var directionTotal = directionBase.length; var longCount = directionBase.filter(function(r){ return recSide(r) === 'long'; }).length; var shortCount = directionBase.filter(function(r){ return recSide(r) === 'short'; }).length; + var strategyBase = visible.filter(function(r){ + if (currentSideFilter && recSide(r) !== currentSideFilter) return false; + if (!currentFilter) return !isWeakObserveRec(r); + if (currentFilter === 'buy_now') return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r); + if (currentFilter === 'observe') return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; + if (currentFilter === 'weak_observe') return r.observe_tier === 'weak'; + return true; + }); + var strategyCounts = {}; + strategyBase.forEach(function(r){ var code=String(r.strategy_code||''); if(code) strategyCounts[code]=(strategyCounts[code]||0)+1; }); + var strategyTabs = Object.keys(strategyCounts).sort(function(a,b){return strategyCounts[b]-strategyCounts[a]}).map(function(code){ + var cls = 'tab-btn' + (currentStrategyFilter === code ? ' active' : ''); + return ''; + }).join(''); + var strategyAllCls = 'tab-btn' + (!currentStrategyFilter ? ' active' : ''); var allCls = 'tab-btn' + (!currentFilter ? ' active' : ''); var bCls = 'tab-btn' + (currentFilter === 'buy_now' ? ' active' : ''); var oCls = 'tab-btn' + (currentFilter === 'observe' ? ' active' : ''); @@ -834,7 +868,14 @@ function renderLiveStats(data) { '' + '' + '' + - '
状态筛选和方向筛选是两个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。
'; + '
' + + '
策略来源
' + + '
' + + '' + + (strategyTabs || '') + + '
' + + '
' + + '
状态、方向和策略是三个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。
'; } function renderLiveCards(data, weakCount) { @@ -943,7 +984,7 @@ function renderRecCard(r) { return ''+displaySignalText(s)+''; }).join(''); var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||''; - var strategyLabel = r.strategy_name || ({long_momentum_breakout_15m_1h_v1:'多头动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',short_breakdown_retest_1h_v1:'空头破位反抽'}[r.strategy_code||''] || r.strategy_code || ''); + var strategyLabel = r.strategy_name || ({long_intraday_momentum_15m_1h_v1:'多头日内动量启动',long_momentum_breakout_15m_1h_v1:'多头日内动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',long_compression_breakout_1h_4h_v1:'多头压缩突破',long_box_retest_4h_v1:'多头4H箱体回踩',short_breakdown_retest_1h_v1:'空头破位反抽',short_weak_bounce_failure_15m_1h_v1:'空头弱反弹失败'}[r.strategy_code||''] || r.strategy_code || ''); var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length; var entryLabel = isWait ? (side === 'short' ? '反抽参考' : '回踩参考') : (hasQualityGate ? '失效参考' : '参考价位'); var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0); diff --git a/static/opportunity_detail.html b/static/opportunity_detail.html index 79b9dec..a0d17d7 100644 --- a/static/opportunity_detail.html +++ b/static/opportunity_detail.html @@ -39,7 +39,7 @@ function sideRr(side,entry,stop,tp){return side==='short'?((entry-tp)/(stop-entr function signals(r){return Array.isArray(r.signal_labels)&&r.signal_labels.length?r.signal_labels:(Array.isArray(r.signals)?r.signals:[])} function aiInsight(r){return r.llm_insight&&r.llm_insight.content?r.llm_insight.content:null} function renderRows(rows,opts){opts=opts||{};if(!rows||!rows.length)return'
'+(opts.empty||'暂无数据')+'
';return '
'+rows.map(function(x){return '
'+esc(opts.time?opts.time(x):'--')+'
'+esc(opts.title?opts.title(x):'--')+'
'+esc(opts.sub?opts.sub(x):'')+'
'+esc(opts.val?opts.val(x):'')+'
'}).join('')+'
'} -function renderDetail(d){window.__opportunityDetail=d||{};var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),side=recSide(r),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r);$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?sideRr(side,entry,stop,tp1).toFixed(2):'--';var chg=entry&¤t?sidePnl(side,current,entry):0;var aiHtml=ai?'
'+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'
暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var markerCount=(d.trade_markers||[]).length;var root='
'+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+sideBadge(r)+''+esc(statusLabel(r))+'总分 '+esc(r.rec_score||0)+''+esc(r.strategy_version||'--')+'
当前价'+price(current)+'
相对参考'+pct(chg)+'
'+(side==='short'?'计划开空':'计划入场')+''+price(entry)+'
止损 / 止盈'+price(stop)+' / '+price(tp1)+'
盈亏比'+esc(rr)+'
多周期 K 线
'+(markerCount?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
加载 K 线...
决策与计划
列表页只保留摘要,这里看完整依据

当前结论

'+esc(statusLabel(r))+'

原因

'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'

'+(side==='short'?'开空模型':'入场模型')+'

'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'

失效条件

'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'

因子评分拆解
机会、买点、风险分开看
机会分'+num(sc.opportunity_score||0)+'
买点分'+num(sc.entry_score||0)+'
风险扣分'+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+''}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
';$('detailRoot').innerHTML=root;loadKline()} +function renderDetail(d){window.__opportunityDetail=d||{};var r=d.current||{},symbol=d.symbol||r.symbol||'--',base=symbol.replace('/USDT',''),lp=d.latest_price||{},current=Number(lp.price||r.current_price||r.entry_price||0),ep=r.entry_plan||{},entry=Number(ep.entry_price||r.entry_price||0),stop=Number(ep.stop_loss||r.stop_loss||0),tp1=Number(ep.tp1||ep.take_profit_1||r.tp1||0),side=recSide(r),sc=scoreComponents(r),rg=marketRegime(r),dl=decisionLog(r),ai=aiInsight(r),strategyMap={long_intraday_momentum_15m_1h_v1:'多头日内动量启动',long_momentum_breakout_15m_1h_v1:'多头日内动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',long_compression_breakout_1h_4h_v1:'多头压缩突破',long_box_retest_4h_v1:'多头4H箱体回踩',short_breakdown_retest_1h_v1:'空头破位反抽',short_weak_bounce_failure_15m_1h_v1:'空头弱反弹失败'},strategyName=r.strategy_name||strategyMap[r.strategy_code||'']||r.strategy_code||'未识别策略';$('updatedAt').textContent='最新价格 '+fmtTime(lp.updated_at||r.last_track_time||r.rec_time);var rr=entry&&tp1&&stop?sideRr(side,entry,stop,tp1).toFixed(2):'--';var chg=entry&¤t?sidePnl(side,current,entry):0;var aiHtml=ai?'
'+esc(clean(ai.summary||ai.why_now_or_not||'已缓存 AI 解读'))+'
':'
暂无 AI 解读
';var onchain=d.onchain||{};var metric=onchain.metric||{};var markerCount=(d.trade_markers||[]).length;var root='
'+esc(base)+'
'+esc(symbol)+' · 推荐 #'+esc(r.id||'--')+' · '+fmtTime(r.rec_time)+'
'+sideBadge(r)+''+esc(statusLabel(r))+''+esc(strategyName)+'总分 '+esc(r.rec_score||0)+''+esc(r.strategy_version||'--')+'
当前价'+price(current)+'
相对参考'+pct(chg)+'
'+(side==='short'?'计划开空':'计划入场')+''+price(entry)+'
止损 / 止盈'+price(stop)+' / '+price(tp1)+'
盈亏比'+esc(rr)+'
多周期 K 线
'+(markerCount?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
加载 K 线...
决策与策略剧本
'+esc(strategyName)+' · 触发、入场、失效条件分开看

当前结论

'+esc(statusLabel(r))+'

策略来源

'+esc(strategyName)+'

原因

'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'

'+(side==='short'?'开空模型':'入场模型')+'

'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'

失效条件

'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'

因子评分拆解
机会、买点、风险分开看
机会分'+num(sc.opportunity_score||0)+'
买点分'+num(sc.entry_score||0)+'
风险扣分'+num(sc.risk_score||0)+'
'+signals(r).slice(0,12).map(function(s){return''+esc(clean(s))+''}).join('')+'
筛选与推荐历史
'+esc((d.summary||{}).history_count||0)+' 次推荐 · '+esc((d.summary||{}).screening_count||0)+' 条筛选记录
'+renderRows(d.history,{empty:'暂无推荐历史',time:function(x){return fmtTime(x.rec_time)},title:function(x){return '#'+x.id+' · '+statusLabel(x)},sub:function(x){return signals(x).slice(0,3).map(clean).join(' · ')||'--'},val:function(x){return '分 '+(x.rec_score||0)}})+'
'+renderRows(d.screening,{empty:'暂无筛选记录',time:function(x){return fmtTime(x.scan_time)},title:function(x){return (x.layer||'筛选')+' · '+(x.state||'--')},sub:function(x){return (x.signals||[]).slice(0,3).map(clean).join(' · ')},val:function(x){return x.score||0}})+'
';$('detailRoot').innerHTML=root;loadKline()} function loadKline(){var c=$('kline');if(!c)return;var active=document.querySelector('.kline-int-btn.active');var interval=active?active.dataset.int:'1h';fetch(API+'/api/kline?symbol='+encodeURIComponent(c.dataset.symbol)+'&interval='+interval+'&limit=100').then(function(r){return r.json()}).then(function(resp){var candles=resp.candles||[];if(!window.AlphaXCharts||!window.AlphaXCharts.renderKline)throw new Error('chart unavailable');c.innerHTML='';window.AlphaXCharts.renderKline(c,{symbol:c.dataset.symbol,candles:candles,entryPrice:Number(c.dataset.entryPrice||0),stopLoss:Number(c.dataset.stopLoss||0),tp1:Number(c.dataset.tp1||0),recTime:c.dataset.recTime||'',refPrice:Number(c.dataset.refPrice||0),tradeMarkers:(window.__opportunityDetail&&window.__opportunityDetail.trade_markers)||[]});c.classList.remove('loading')}).catch(function(){c.innerHTML='
K线加载失败
'})} function switchKline(btn){document.querySelectorAll('.kline-int-btn').forEach(function(b){b.classList.remove('active')});btn.classList.add('active');var c=$('kline');c.classList.add('loading');c.innerHTML='
加载 K 线...
';loadKline()} async function load(){var q=new URLSearchParams(location.search);var recId=q.get('rec_id')||'';var symbol=q.get('symbol')||'';var url=API+'/api/opportunity/detail?symbol='+encodeURIComponent(symbol)+'&rec_id='+encodeURIComponent(recId);try{var d=await (await fetch(url)).json();if(d.error){$('detailRoot').innerHTML='
没有找到该机会
';return}renderDetail(d)}catch(e){$('detailRoot').innerHTML='
机会详情加载失败
'}} diff --git a/static/paper_trading.html b/static/paper_trading.html index 95b1569..fcd244f 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -228,7 +228,7 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML=''+ ''}).join('')} function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'移动止盈 $'+fmt(trail,6)+'':'移动止盈未启动';return '
TP $'+fmt(x.tp1,6)+'SL $'+fmt(x.stop_loss,6)+''+trailHtml+'
'} -function strategyName(x){return (x&&x.strategy_name)||({long_momentum_breakout_15m_1h_v1:'多头动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',short_breakdown_retest_1h_v1:'空头破位反抽'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))} +function strategyName(x){return (x&&x.strategy_name)||({long_intraday_momentum_15m_1h_v1:'多头日内动量启动',long_momentum_breakout_15m_1h_v1:'多头日内动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',long_compression_breakout_1h_4h_v1:'多头压缩突破',long_box_retest_4h_v1:'多头4H箱体回踩',short_breakdown_retest_1h_v1:'空头破位反抽',short_weak_bounce_failure_15m_1h_v1:'空头弱反弹失败'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))} async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open'+tradeQuery());openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML=''+esc(e.message)+''}} async function loadClosedTrades(){$('closedTradeRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('closedTradeRows',d.items||[],'暂无已完结持仓')}catch(e){$('closedTradeRows').innerHTML=''+esc(e.message)+''}} async function loadCanceledOrders(){$('canceledOrderRows').innerHTML='加载中...';try{var sets=await Promise.all(['canceled','expired','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s+tradeQuery())}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCanceledOrders(items)}catch(e){$('canceledOrderRows').innerHTML=''+esc(e.message)+''}} diff --git a/tests/test_intraday_frequency.py b/tests/test_intraday_frequency.py new file mode 100644 index 0000000..8594831 --- /dev/null +++ b/tests/test_intraday_frequency.py @@ -0,0 +1,43 @@ +import json +from datetime import datetime + +from app.db.intraday_frequency import get_intraday_frequency_health + + +def test_intraday_frequency_dedupes_repeated_gate_reject_events(pg_conn): + now = datetime.now().isoformat(timespec="seconds") + detail = { + "reason": "paper_order_gate_rejected", + "gate_reasons": ["too_far_from_entry"], + "recommendation_id": 101, + "gate_detail": { + "target_price": 10, + "distance_to_entry_pct": 8.5, + "max_distance_to_entry_pct": 8, + "rr1": 1.8, + "min_rr": 1.25, + "rec_score": 55, + "min_rec_score": 25, + }, + } + for _ in range(3): + pg_conn.execute( + """ + INSERT INTO paper_trade_events ( + trade_id, recommendation_id, symbol, event_type, event_time, detail_json, strategy_code + ) VALUES ( + 0, 101, 'DASH/USDT', 'paper_gate_reject', %s, %s, 'long_second_wave_pullback_1h_v1' + ) + """, + (now, json.dumps(detail, ensure_ascii=False)), + ) + pg_conn.commit() + + data = get_intraday_frequency_health(days=1) + + reasons = {item["reason"]: item["count"] for item in data["gate_reasons"]} + assert reasons["paper_order_gate_rejected"] == 1 + assert reasons["too_far_from_entry"] == 1 + assert len(data["gate_samples"]) == 1 + assert data["gate_samples"][0]["detail"]["distance_to_entry_pct"] == 8.5 + assert "entry_plan" not in data["gate_samples"][0]["detail"] diff --git a/tests/test_intraday_strategy_builders.py b/tests/test_intraday_strategy_builders.py new file mode 100644 index 0000000..6c1bb55 --- /dev/null +++ b/tests/test_intraday_strategy_builders.py @@ -0,0 +1,99 @@ +from app.core.strategy_registry import ( + LONG_BOX_RETEST_4H_STRATEGY, + LONG_COMPRESSION_BREAKOUT_STRATEGY, + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, + SHORT_BREAKDOWN_RETEST_STRATEGY, + SHORT_WEAK_BOUNCE_FAILURE_STRATEGY, +) +from app.strategies.altcoin_breakout import ( + build_compression_breakout_4h_signal, + build_long_box_retest_4h_signal, + build_long_momentum_breakout_signal, + build_long_second_wave_pullback_signal, +) +from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, build_short_weak_bounce_failure_signal + + +def test_long_intraday_momentum_requires_current_trigger(): + signal = build_long_momentum_breakout_signal( + symbol="MOM/USDT", + result={"score": 8, "signals": ["1H量价齐飞", "15min即刻入场"], "trigger_context": {"current_triggers": ["15m"]}}, + entry_plan={"entry_action": "可即刻买入"}, + ) + missing = build_long_momentum_breakout_signal( + symbol="MOM/USDT", + result={"score": 8, "signals": ["1H量价齐飞"]}, + entry_plan={"entry_action": "可即刻买入"}, + ) + + assert signal.to_json_dict()["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY + assert signal.status == "candidate" + assert missing is None + + +def test_long_second_wave_pullback_can_wait_for_entry(): + signal = build_long_second_wave_pullback_signal( + symbol="WAVE/USDT", + result={"score": 10, "signals": ["24h强势榜", "1H箱体突破回踩", "量价齐飞"]}, + entry_plan={"entry_action": "等回踩"}, + ) + + assert signal.to_json_dict()["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY + assert signal.status == "candidate" + + +def test_long_compression_breakout_requires_current_entry_context(): + signal = build_compression_breakout_4h_signal( + symbol="COMP/USDT", + result={"score": 9, "signals": ["4H静K蓄力", "压缩突破", "15min即刻入场"], "trigger_context": {"current_triggers": ["15m"]}}, + entry_plan={"entry_action": "可即刻买入"}, + ) + + assert signal.to_json_dict()["strategy_code"] == LONG_COMPRESSION_BREAKOUT_STRATEGY + assert signal.status == "candidate" + + +def test_long_box_retest_4h_keeps_pullback_waiting_signal(): + signal = build_long_box_retest_4h_signal( + symbol="BOX/USDT", + result={"score": 9, "signals": ["4H箱体突破回踩", "15min回踩确认"]}, + entry_plan={"entry_action": "等回踩"}, + ) + + assert signal.to_json_dict()["strategy_code"] == LONG_BOX_RETEST_4H_STRATEGY + assert signal.status == "candidate" + + +def test_short_breakdown_retest_observes_until_retest_rejected(): + signal = build_breakdown_retest_short_1h_signal( + symbol="BD/USDT", + current_price=9.5, + detection={ + "detected": True, + "quality": "良好", + "score": 5, + "breakdown_level": 10, + "retest_zone": 10, + "stop_level": 10.4, + "target_1": 8.8, + "retest_rejected": False, + "relative_weakness": True, + }, + entry_plan={"side": "short", "entry_action": "等回踩"}, + market_regime={"risk_level": "high"}, + ) + + assert signal.to_json_dict()["strategy_code"] == SHORT_BREAKDOWN_RETEST_STRATEGY + assert signal.status == "observe" + + +def test_short_weak_bounce_failure_needs_weak_market(): + signal = build_short_weak_bounce_failure_signal( + symbol="WB/USDT", + result={"score": 7, "signals": ["15min反抽失败", "弱反弹失败"], "market_regime": {"regime": "risk_off", "risk_level": "high"}}, + entry_plan={"side": "short", "entry_action": "可即刻买入"}, + ) + + assert signal.to_json_dict()["strategy_code"] == SHORT_WEAK_BOUNCE_FAILURE_STRATEGY + assert signal.status == "candidate" diff --git a/tests/test_multi_strategy_infra.py b/tests/test_multi_strategy_infra.py index 9d09f2b..c0851a1 100644 --- a/tests/test_multi_strategy_infra.py +++ b/tests/test_multi_strategy_infra.py @@ -3,10 +3,13 @@ from app.core.signal_direction import sanitize_factor_breakdown_for_side, saniti from app.core.strategy_contract import StrategySignal, default_main_composite_signal from app.core.strategy_registry import ( BREAKDOWN_RETEST_SHORT_1H_STRATEGY, + LONG_BOX_RETEST_4H_STRATEGY, + LONG_COMPRESSION_BREAKOUT_STRATEGY, LONG_MOMENTUM_BREAKOUT_STRATEGY, LONG_SECOND_WAVE_PULLBACK_STRATEGY, MAIN_COMPOSITE_STRATEGY, SHORT_BREAKDOWN_RETEST_STRATEGY, + SHORT_WEAK_BOUNCE_FAILURE_STRATEGY, is_strategy_allowed_for_side, registered_strategy_codes, strategy_direction, @@ -18,14 +21,20 @@ from app.db.paper_trading import _open_trade, _order_payload_from_rec from app.db.strategy_signal_queries import insert_strategy_signal from app.db.strategy_insights import evaluate_strategy_decision from app.strategies.altcoin_breakout import ( + build_long_box_retest_4h_signal, build_long_momentum_breakout_signal, build_long_second_wave_pullback_signal, build_compression_breakout_4h_signal, build_intraday_momentum_15m_signal, build_volume_ignition_1h_signal, ) +from app.strategies.orchestrator import arbitrate_strategy_signals from app.strategies.box_retest_4h import build_box_retest_1h_signal -from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h +from app.strategies.short_breakdown import ( + build_breakdown_retest_short_1h_signal, + build_short_weak_bounce_failure_signal, + detect_breakdown_retest_short_1h, +) def test_factor_roles_never_promote_unknown_to_trigger(): @@ -39,7 +48,7 @@ def test_factor_roles_never_promote_unknown_to_trigger(): } -def test_active_strategy_pool_only_contains_short_term_three_strategies(): +def test_active_strategy_pool_contains_intraday_long_short_strategies(): signal = default_main_composite_signal( symbol="AAA/USDT", score=70, @@ -50,14 +59,20 @@ def test_active_strategy_pool_only_contains_short_term_three_strategies(): assert registered_strategy_codes() == [ LONG_MOMENTUM_BREAKOUT_STRATEGY, LONG_SECOND_WAVE_PULLBACK_STRATEGY, + LONG_COMPRESSION_BREAKOUT_STRATEGY, + LONG_BOX_RETEST_4H_STRATEGY, SHORT_BREAKDOWN_RETEST_STRATEGY, + SHORT_WEAK_BOUNCE_FAILURE_STRATEGY, ] assert signal["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY - assert signal["strategy_name"] == "多头动量启动" + assert signal["strategy_name"] == "多头日内动量启动" assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger" - assert strategy_label(LONG_MOMENTUM_BREAKOUT_STRATEGY) == "多头动量启动" + assert strategy_label(LONG_MOMENTUM_BREAKOUT_STRATEGY) == "多头日内动量启动" assert strategy_label(LONG_SECOND_WAVE_PULLBACK_STRATEGY) == "多头二波回踩" + assert strategy_label(LONG_COMPRESSION_BREAKOUT_STRATEGY) == "多头压缩突破" + assert strategy_label(LONG_BOX_RETEST_4H_STRATEGY) == "多头4H箱体回踩" assert strategy_label(SHORT_BREAKDOWN_RETEST_STRATEGY) == "空头破位反抽" + assert strategy_label(SHORT_WEAK_BOUNCE_FAILURE_STRATEGY) == "空头弱反弹失败" assert strategy_direction(LONG_MOMENTUM_BREAKOUT_STRATEGY) == "long" assert strategy_direction(BREAKDOWN_RETEST_SHORT_1H_STRATEGY) == "short" assert is_strategy_allowed_for_side(MAIN_COMPOSITE_STRATEGY, "short") is False @@ -68,7 +83,10 @@ def test_active_strategy_pool_only_contains_short_term_three_strategies(): def test_active_strategy_pool_uses_intraday_paper_gates(): long_momentum = strategy_paper_config(LONG_MOMENTUM_BREAKOUT_STRATEGY) second_wave = strategy_paper_config(LONG_SECOND_WAVE_PULLBACK_STRATEGY) + compression = strategy_paper_config(LONG_COMPRESSION_BREAKOUT_STRATEGY) + box_retest = strategy_paper_config(LONG_BOX_RETEST_4H_STRATEGY) short_retest = strategy_paper_config(SHORT_BREAKDOWN_RETEST_STRATEGY) + short_bounce = strategy_paper_config(SHORT_WEAK_BOUNCE_FAILURE_STRATEGY) assert long_momentum["frequency_profile"] == "intraday_trading" assert long_momentum["entry_min_rr"] == 1.25 @@ -76,8 +94,11 @@ def test_active_strategy_pool_uses_intraday_paper_gates(): assert long_momentum["order_require_current_trigger"] is True assert second_wave["order_min_distance_to_entry_pct"] == 0 assert second_wave["order_require_current_trigger"] is False + assert compression["order_require_current_trigger"] is True + assert box_retest["entry_min_rr"] == 1.3 assert short_retest["entry_min_rr"] == 1.3 assert short_retest["order_require_current_trigger"] is False + assert short_bounce["order_require_current_trigger"] is True def test_long_momentum_breakout_strategy_builds_independent_signal(): @@ -141,24 +162,73 @@ def test_momentum_and_second_wave_are_mutually_exclusive(): assert signal.to_json_dict()["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY -def test_legacy_strategy_builders_no_longer_emit_signals(): +def test_intraday_strategy_builders_emit_independent_signals(): assert build_volume_ignition_1h_signal( symbol="OLD/USDT", result={"score": 9, "signals": ["1H量价齐飞", "15min即刻入场"], "trigger_context": {"current_triggers": ["15m"]}}, entry_plan={"entry_action": "可即刻买入"}, - ) is None - assert build_compression_breakout_4h_signal( - symbol="OLD/USDT", - result={"score": 9, "signals": ["4H静K压缩,突破箱体上沿"]}, - entry_plan={"entry_action": "等回踩"}, - ) is None + ).to_json_dict()["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY assert build_intraday_momentum_15m_signal( symbol="OLD/USDT", - result={"score": 9, "signals": ["15min强突破"], "trigger_context": {"current_triggers": ["15m"]}}, + result={"score": 9, "signals": ["1H突破起爆", "15min强突破"], "trigger_context": {"current_triggers": ["15m"]}}, entry_plan={"entry_action": "可即刻买入"}, + ).to_json_dict()["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY + assert build_compression_breakout_4h_signal( + symbol="COMP/USDT", + result={"score": 9, "signals": ["4H静K蓄力", "压缩突破", "15min即刻入场"], "trigger_context": {"current_triggers": ["15m"]}}, + entry_plan={"entry_action": "可即刻买入"}, + ).to_json_dict()["strategy_code"] == LONG_COMPRESSION_BREAKOUT_STRATEGY + assert build_long_box_retest_4h_signal( + symbol="BOX/USDT", + result={"score": 9, "signals": ["4H箱体突破回踩", "15min回踩确认"]}, + entry_plan={"entry_action": "等回踩"}, + ).to_json_dict()["strategy_code"] == LONG_BOX_RETEST_4H_STRATEGY + + +def test_short_weak_bounce_failure_builder_requires_risk_off_context(): + base = { + "score": 7, + "signals": ["15min反抽失败", "弱反弹失败"], + "market_regime": {"regime": "risk_off", "risk_level": "high"}, + } + signal = build_short_weak_bounce_failure_signal( + symbol="WEAK/USDT", + result=base, + entry_plan={"side": "short", "entry_action": "可即刻买入"}, + ) + + assert signal is not None + assert signal.to_json_dict()["strategy_code"] == SHORT_WEAK_BOUNCE_FAILURE_STRATEGY + assert build_short_weak_bounce_failure_signal( + symbol="WEAK/USDT", + result={**base, "market_regime": {"regime": "altcoin_rotation", "risk_level": "medium"}}, + entry_plan={"side": "short", "entry_action": "可即刻买入"}, ) is None +def test_strategy_orchestrator_demotes_same_symbol_long_short_conflict(): + long_signal = StrategySignal( + strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, + symbol="CLASH/USDT", + direction="long", + status="candidate", + confidence=70, + ) + short_signal = StrategySignal( + strategy_code=SHORT_BREAKDOWN_RETEST_STRATEGY, + symbol="CLASH/USDT", + direction="short", + status="candidate", + confidence=80, + ) + + result = arbitrate_strategy_signals([long_signal, short_signal]) + + assert {item.direction for item in result} == {"long", "short"} + assert all(item.status == "observe" for item in result) + assert all("同币多空信号冲突" in " ".join(item.risk_plan.get("risk_reasons") or []) for item in result) + + def test_long_strategy_cannot_emit_short_signal(): import pytest diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index 360af23..948ceaf 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -362,3 +362,32 @@ def test_ws_tracker_does_not_push_when_gate_downgrades_buy_now(): assert action != '可即刻买入' assert plan['risk_reward_ok_live'] is True assert any('缺少当前15min触发' in r for r in reasons) + + +def test_intraday_momentum_uses_strategy_rr_threshold_not_legacy_1_5(): + action, plan, reasons = apply_entry_quality_gate( + action_status='可即刻买入', + entry_plan={ + 'strategy_code': LONG_MOMENTUM_BREAKOUT_STRATEGY, + 'side': 'long', + 'entry_action': '可即刻买入', + 'entry_price': 100, + 'current_price': 100, + 'stop_loss': 92, + 'tp1': 110, + 'tp2': 116, + 'risk_reward_ok': True, + 'rr1': 1.25, + 'entry_trigger_confirmed': True, + 'opportunity_level': 'intraday_breakout', + 'score_components': {'entry_score': 3}, + }, + signals=['15min即刻入场信号', '1H 量价齐飞K(量2.2x)'], + current_price=100, + market_context={'change_24h': 4.0}, + strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, + ) + + assert action == '可即刻买入' + assert plan['risk_reward_ok_live'] is True + assert not any('rr1=' in r and '< 1.5' in r for r in reasons) diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 1cd09b0..5f9b077 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -307,6 +307,37 @@ def test_wait_pullback_without_tradeable_plan_does_not_create_order(monkeypatch) assert "missing_stop_loss" in events[0]["detail"]["gate_reasons"] +def test_repeated_paper_gate_reject_is_deduped(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_GATE_REJECT_DEDUP_MINUTES", "30") + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="DEDUP/USDT", + rec_state="蓄力", + rec_score=20, + entry_price=95, + signals=["等待回踩"], + entry_plan={"entry_action": "等回踩", "entry_price": 95}, + ) + rec = { + "id": rec_id, + "symbol": "DEDUP/USDT", + "execution_status": "wait_pullback", + "action_status": "等回踩", + "entry_price": 95, + "entry_plan": {"entry_action": "等回踩", "entry_price": 95}, + } + + first = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + second = sync_recommendation(rec, 100, event_time="2026-05-16T10:05:00") + + assert first["reason"] == "paper_order_gate_rejected" + assert second["reason"] == "paper_order_gate_rejected" + events = list_paper_trade_events(event_type="paper_gate_reject")["items"] + assert len(events) == 1 + assert events[0]["symbol"] == "DEDUP/USDT" + + def test_wait_pullback_requires_confirmed_risk_reward(monkeypatch): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") altcoin_db.init_db() @@ -424,13 +455,15 @@ def test_wait_pullback_too_close_to_entry_does_not_create_order(monkeypatch): assert list_paper_orders()["total"] == 0 -def test_buy_now_rejects_large_leveraged_stop_loss(monkeypatch): +def test_buy_now_lowers_leverage_for_large_stop_loss(monkeypatch): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5") monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", "50") monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_RR", "1.8") monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "20") + monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_MIN", "1") altcoin_db.init_db() rec_id = altcoin_db.create_recommendation( symbol="WIDESTOP/USDT", @@ -446,6 +479,37 @@ def test_buy_now_rejects_large_leveraged_stop_loss(monkeypatch): result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + assert result["opened"] is True, result + trade = list_paper_trades()["items"][0] + assert trade["leverage"] < 5 + assert trade["leverage"] >= 1 + assert trade["leverage"] * 10.05 <= 20.01 + + +def test_buy_now_rejects_large_leveraged_stop_loss_when_dynamic_leverage_disabled(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", "50") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_RR", "1.8") + monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "20") + monkeypatch.setenv("ALPHAX_PAPER_DYNAMIC_LEVERAGE_ENABLED", "0") + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="WIDESTOP2/USDT", + rec_state="爆发", + rec_score=60, + entry_price=100, + stop_loss=90, + tp1=120, + signals=["当前15min即刻入场信号"], + entry_plan={"entry_action": "可即刻买入", "entry_price": 100, "stop_loss": 90, "tp1": 120, "risk_reward_ok": True, "rr1": 2.0, "entry_trigger_confirmed": True}, + ) + rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id) + rec = {**rec, "action_status": "可即刻买入", "execution_status": "buy_now"} + + result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + assert result["reason"] == "entry_gate_rejected" assert "stop_loss_leverage_risk_exceeded" in result["gate_reasons"] assert list_paper_trades()["total"] == 0