1
This commit is contained in:
parent
f6c95d4380
commit
dcd8ee7b45
@ -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
|
||||
|
||||
@ -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. 数据与状态中心
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", "箱体", "突破", "回踩")),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
30
app/db/migrations/0020_intraday_long_short_strategy_pool.sql
Normal file
30
app/db/migrations/0020_intraday_long_short_strategy_pool.sql
Normal 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'
|
||||
);
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", "短周期", "量价齐飞", "放量")):
|
||||
|
||||
@ -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},
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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线。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,''')+'\')"><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
@ -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,''')+'\')">删除</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>'}}
|
||||
|
||||
43
tests/test_intraday_frequency.py
Normal file
43
tests/test_intraday_frequency.py
Normal 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"]
|
||||
99
tests/test_intraday_strategy_builders.py
Normal file
99
tests/test_intraday_strategy_builders.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user