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 ''+esc(strategyLabelFromCode(code))+' '+strategyCounts[code]+' ';
+ }).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) {
'做空 '+shortCount+' ' +
'' +
'' +
- '状态筛选和方向筛选是两个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。
';
+ '' +
+ '
策略来源
' +
+ '
' +
+ '全部策略 '+strategyBase.length+' ' +
+ (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?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
当前结论 '+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}})+'
市场环境
'+esc(rg.label||rg.regime||'--')+'
'+(Array.isArray(rg.reasons)?rg.reasons:['市场环境已进入推荐上下文']).slice(0,8).map(function(s){return''+esc(clean(s))+' '}).join('')+'
链上与外部证据
正向 '+esc(onchain.positive_count||0)+' · 风险 '+esc(onchain.risk_count||0)+'
链上分 '+num(metric.onchain_score||0)+'
风险分 '+num(metric.risk_score||0)+'
'+renderRows(onchain.events,{empty:'暂无链上事件',time:function(x){return fmtTime(x.detected_at)},title:function(x){return x.signal_label||x.signal_code||'链上事件'},sub:function(x){return [x.chain,x.direction,x.tx_hash].filter(Boolean).join(' · ')},val:function(x){return x.value_usd?('$'+num(x.value_usd)):''},cls:function(x){return x.direction==='risk'?'red':'green'}})+'
'+renderRows(d.paper_orders,{empty:'暂无挂单',time:function(x){return fmtTime(x.created_at)},title:function(x){return '挂单 · '+(x.status||'--')},sub:function(x){return '目标 '+price(x.target_price)+' · 当前 '+price(x.current_price_at_create)},val:function(x){return x.cancel_reason||x.status||''}})+'
'+renderRows(d.paper_trades,{empty:'暂无持仓交易',time:function(x){return fmtTime(x.opened_at)},title:function(x){return '交易 · '+(x.status||'--')},sub:function(x){return '入场 '+price(x.entry_price)+' · 当前 '+price(x.current_price)},val:function(x){return pct(x.pnl_pct||x.realized_pnl_pct)},cls:function(x){return Number(x.pnl_pct||x.realized_pnl_pct||0)>=0?'green':'red'}})+'
'+renderRows(d.paper_events,{empty:'暂无交易事件',time:function(x){return fmtTime(x.event_time)},title:function(x){return x.event_type||'事件'},sub:function(x){return x.message||'--'},val:function(x){return price(x.price)}})+'
复盘记录
'+esc((d.summary||{}).review_count||0)+' 条
'+renderRows(d.reviews,{empty:'暂无复盘',time:function(x){return fmtTime(x.review_time)},title:function(x){return x.outcome||'复盘'},sub:function(x){return x.lesson||'--'},val:function(x){return pct(x.pnl_48h||0)},cls:function(x){return Number(x.pnl_48h||0)>=0?'green':'red'}})+'
';$('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?'标注策略交易操作:挂单、成交、开仓、平仓、移动止盈':'暂无策略交易操作,显示计划价作为参考')+'
决策与策略剧本
'+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}})+'
市场环境
'+esc(rg.label||rg.regime||'--')+'
'+(Array.isArray(rg.reasons)?rg.reasons:['市场环境已进入推荐上下文']).slice(0,8).map(function(s){return''+esc(clean(s))+' '}).join('')+'
链上与外部证据
正向 '+esc(onchain.positive_count||0)+' · 风险 '+esc(onchain.risk_count||0)+'
链上分 '+num(metric.onchain_score||0)+'
风险分 '+num(metric.risk_score||0)+'
'+renderRows(onchain.events,{empty:'暂无链上事件',time:function(x){return fmtTime(x.detected_at)},title:function(x){return x.signal_label||x.signal_code||'链上事件'},sub:function(x){return [x.chain,x.direction,x.tx_hash].filter(Boolean).join(' · ')},val:function(x){return x.value_usd?('$'+num(x.value_usd)):''},cls:function(x){return x.direction==='risk'?'red':'green'}})+'
'+renderRows(d.paper_orders,{empty:'暂无挂单',time:function(x){return fmtTime(x.created_at)},title:function(x){return '挂单 · '+(x.status||'--')},sub:function(x){return '目标 '+price(x.target_price)+' · 当前 '+price(x.current_price_at_create)},val:function(x){return x.cancel_reason||x.status||''}})+'
'+renderRows(d.paper_trades,{empty:'暂无持仓交易',time:function(x){return fmtTime(x.opened_at)},title:function(x){return '交易 · '+(x.status||'--')},sub:function(x){return '入场 '+price(x.entry_price)+' · 当前 '+price(x.current_price)},val:function(x){return pct(x.pnl_pct||x.realized_pnl_pct)},cls:function(x){return Number(x.pnl_pct||x.realized_pnl_pct||0)>=0?'green':'red'}})+'
'+renderRows(d.paper_events,{empty:'暂无交易事件',time:function(x){return fmtTime(x.event_time)},title:function(x){return x.event_type||'事件'},sub:function(x){return x.message||'--'},val:function(x){return price(x.price)}})+'
复盘记录
'+esc((d.summary||{}).review_count||0)+' 条
'+renderRows(d.reviews,{empty:'暂无复盘',time:function(x){return fmtTime(x.review_time)},title:function(x){return x.outcome||'复盘'},sub:function(x){return x.lesson||'--'},val:function(x){return pct(x.pnl_48h||0)},cls:function(x){return Number(x.pnl_48h||0)>=0?'green':'red'}})+'
';$('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