This commit is contained in:
aaron 2026-06-07 20:29:45 +08:00
parent f6c95d4380
commit dcd8ee7b45
24 changed files with 925 additions and 83 deletions

View File

@ -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

View File

@ -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. 数据与状态中心

View File

@ -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)),

View File

@ -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,

View File

@ -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", "箱体", "突破", "回踩")),

View File

@ -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:

View File

@ -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]

View File

@ -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'
);

View File

@ -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,

View File

@ -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",

View File

@ -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", "短周期", "量价齐飞", "放量")):

View File

@ -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},
)

View File

@ -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

View File

@ -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",
]

View File

@ -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线。

View File

@ -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

View File

@ -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 ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '')
@ -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 '<button class="'+cls+'" type="button" onclick="setStrategyFilter(\''+esc(code).replace(/'/g,'&#39;')+'\')"><span class="tab-dot obs"></span><span>'+esc(strategyLabelFromCode(code))+'</span><span class="tab-count">'+strategyCounts[code]+'</span></button>';
}).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) {
'<button class="'+sCls+'" type="button" onclick="setSideFilter(\'short\')"><span class="tab-dot short"></span><span>做空</span><span class="tab-count">'+shortCount+'</span></button>' +
'</div>' +
'</div>' +
'<div class="stats-note">状态筛选和方向筛选是两个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。</div>';
'<div class="stats-group">' +
'<div class="stats-label">策略来源</div>' +
'<div class="tabs" role="tablist" aria-label="策略筛选">' +
'<button class="'+strategyAllCls+'" type="button" onclick="setStrategyFilter(\'\')"><span class="tab-dot all"></span><span>全部策略</span><span class="tab-count">'+strategyBase.length+'</span></button>' +
(strategyTabs || '') +
'</div>' +
'</div>' +
'<div class="stats-note">状态、方向和策略是三个维度;数字展示当前加载机会的分布,点击只改变下方列表,不代表另一类机会消失。</div>';
}
function renderLiveCards(data, weakCount) {
@ -943,7 +984,7 @@ function renderRecCard(r) {
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
}).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);

File diff suppressed because one or more lines are too long

View File

@ -228,7 +228,7 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')}
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'<span class="trail-line">移动止盈 $'+fmt(trail,6)+'</span>':'<span class="trail-line off">移动止盈未启动</span>';return '<div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span>'+trailHtml+'</div>'}
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='<tr><td colspan="15" class="loading">加载中...</td></tr>';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='<tr><td colspan="15" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadClosedTrades(){$('closedTradeRows').innerHTML='<tr><td colspan="15" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('closedTradeRows',d.items||[],'暂无已完结持仓')}catch(e){$('closedTradeRows').innerHTML='<tr><td colspan="15" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCanceledOrders(){$('canceledOrderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';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='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}

View File

@ -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"]

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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