diff --git a/AGENTS.md b/AGENTS.md index 4bef6e6..898f59b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,10 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。 - 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。 +- 多策略链路必须保持独立:`main_composite_v1`、`box_retest_1h_v1`、`box_retest_4h_v1` 等策略可以共享行情、账户级风控和执行框架,但不能共享同一套入场门槛、RR、挂单距离和 paper trading 执行门禁。 +- 策略级配置入口在 `app/core/strategy_registry.py`,`StrategyDefinition.entry_gate_config` 控制确认/跟踪/展示派生的买点质量闸门,`StrategyDefinition.paper_config` 控制该策略进入 paper trading 的入场、挂单和动态杠杆门槛。 +- 新增策略时必须注册稳定 `strategy_code`,并明确自己的 `entry_gate_config` / `paper_config`。不要把新策略的特殊门槛写进全局 `paper_trading_config()` 或 `DEFAULT_ENTRY_GATE`,否则会影响其他策略的信号生成和成交样本。 +- `apply_entry_quality_gate()` 必须传入或从 `entry_plan.strategy_code` 派生策略身份;`paper_trader.py` 中开仓、挂单、挂单成交和挂单维护应通过策略级配置合并后的参数执行。 - `app/core/factor_scoring.py` 是确认层因子评分中心。新增确认加减分不要继续散落写死 `score += N`,应优先通过 `FactorScorer.delta(factor_code, base_delta, evidence=...)` 计算。 - 稳定因子代码来自 `app/core/signal_taxonomy.py`,例如 `vp_fly_1h_current`、`volume_consecutive_1h`、`ignition_d1_current`、`sector_rotation`、`sentiment_resonance`、`top_trader_long`、`risk_reward_bad`。 - `signal_performance` 是复盘后动态权重来源;`review_engine.py` 更新信号绩效后,`config_loader.get_signal_weights()` 会让下一轮筛选/确认读取生效权重。 diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index 8a12bd3..0182aad 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -10,6 +10,7 @@ import re from typing import Any, Dict, Iterable, Tuple from app.core.opportunity_level import OPPORTUNITY_LEVELS +from app.core.strategy_registry import normalize_strategy_code, strategy_entry_gate_config DEFAULT_ENTRY_GATE = { "enabled": True, @@ -274,13 +275,16 @@ def apply_entry_quality_gate( derivatives_context: Dict[str, Any] = None, sector_context: Dict[str, Any] = None, cfg: Dict[str, Any] = None, + strategy_code: str = "", ) -> Tuple[str, Dict[str, Any], list]: """返回修正后的 action_status、增强后的 entry_plan、拦截原因。""" - cfg = {**DEFAULT_ENTRY_GATE, **(cfg or {})} + strategy_code = normalize_strategy_code(strategy_code or (entry_plan or {}).get("strategy_code")) + cfg = {**DEFAULT_ENTRY_GATE, **strategy_entry_gate_config(strategy_code), **(cfg or {})} if not cfg.get("enabled", True): return action_status, entry_plan or {}, [] entry_plan = dict(entry_plan or {}) + entry_plan.setdefault("strategy_code", strategy_code) signals = normalize_signals(signals) market_context = market_context or {} derivatives_context = derivatives_context or {} @@ -468,6 +472,7 @@ def apply_entry_quality_gate( "risk_reward_ok": risk_reward_ok, "breakout_distance_pct": breakout_distance, "change_24h": change_24h, + "strategy_code": strategy_code, } if has_entry_score: entry_plan["entry_quality_gate"]["entry_score"] = entry_score diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py index 56bed74..b50d7be 100644 --- a/app/core/strategy_registry.py +++ b/app/core/strategy_registry.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field MAIN_COMPOSITE_STRATEGY = "main_composite_v1" @@ -17,6 +17,8 @@ class StrategyDefinition: description: str = "" mode: str = "paper_only" status: str = "active" + entry_gate_config: dict = field(default_factory=dict) + paper_config: dict = field(default_factory=dict) STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { @@ -31,12 +33,44 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { strategy_name="1H箱体突破回踩", description="小时级底部箱体突破后回踩箱体上沿或EMA承接的早期结构策略。", mode="paper_only", + entry_gate_config={ + "min_entry_score_buy_now": 1, + "min_entry_score_wait_pullback": 0, + "min_rr_buy_now": 1.2, + "max_wait_pullback_deviation_pct": 20, + "breakout_distance_wait_pct": 25, + "gain_24h_wait_pct": 12, + }, + paper_config={ + "entry_min_rr": 1.5, + "order_min_rr": 1.5, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": False, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 3, + }, ), BOX_RETEST_4H_STRATEGY: StrategyDefinition( strategy_code=BOX_RETEST_4H_STRATEGY, strategy_name="4H箱体突破回踩", description="底部箱体突破后回踩箱体上沿或EMA承接的结构策略。", mode="paper_only", + entry_gate_config={ + "min_entry_score_buy_now": 1, + "min_entry_score_wait_pullback": 0, + "min_rr_buy_now": 1.2, + "max_wait_pullback_deviation_pct": 20, + "breakout_distance_wait_pct": 25, + "gain_24h_wait_pct": 12, + }, + paper_config={ + "entry_min_rr": 1.5, + "order_min_rr": 1.5, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": False, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 3, + }, ), } @@ -63,6 +97,14 @@ def strategy_label(strategy_code: str | None) -> str: return strategy_definition(strategy_code).strategy_name +def strategy_entry_gate_config(strategy_code: str | None) -> dict: + return dict(strategy_definition(strategy_code).entry_gate_config or {}) + + +def strategy_paper_config(strategy_code: str | None) -> dict: + return dict(strategy_definition(strategy_code).paper_config or {}) + + def registered_strategy_codes() -> list[str]: return list(STRATEGY_DEFINITIONS.keys()) diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 645376b..96b499f 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from app.config.system_config import paper_trading_config from app.core.global_risk import evaluate_global_risk -from app.core.strategy_registry import normalize_strategy_code, strategy_label +from app.core.strategy_registry import normalize_strategy_code, strategy_label, strategy_paper_config from app.db.schema import get_conn from app.db.system_logs import record_system_error from app.integrations.feishu_push import push_card @@ -328,8 +328,19 @@ def _entry_plan(rec: dict) -> dict: return _loads_json(rec.get("entry_plan_json"), {}) +def _strategy_code_from_rec(rec: dict) -> str: + plan = _entry_plan(rec) + return normalize_strategy_code(rec.get("strategy_code") or plan.get("strategy_code")) + + +def _paper_cfg_for_rec(rec: dict, config: dict | None = None) -> dict: + cfg = dict(_paper_cfg(config) or {}) + cfg.update(strategy_paper_config(_strategy_code_from_rec(rec))) + return cfg + + def _strategy_lineage_from_rec(rec: dict) -> dict: - code = normalize_strategy_code(rec.get("strategy_code")) + code = _strategy_code_from_rec(rec) signal_id = _safe_int(rec.get("strategy_signal_id")) snapshot = _loads_json(rec.get("strategy_snapshot_json"), {}) roles = _loads_json(rec.get("factor_roles_json"), {}) @@ -690,7 +701,7 @@ def _push_order_filled_card(order: dict, result: dict, event_time: str = "") -> def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: dict | None = None, push_open_card: bool = True) -> dict: - cfg = _paper_cfg(config) + cfg = _paper_cfg_for_rec(rec, config) rec_id = _safe_int(rec.get("id")) symbol = str(rec.get("symbol") or "").strip().upper() plan = _entry_plan(rec) @@ -942,7 +953,7 @@ def _paper_order_distance_pct(side: str, current_price: float, target: float) -> def _paper_order_gate(rec: dict, current_price: float, config: dict | None = None, conn=None) -> tuple[bool, list[str], dict]: - cfg = _paper_cfg(config) + cfg = _paper_cfg_for_rec(rec, config) if not bool(cfg.get("order_gate_enabled", True)): return True, [], {"gate_enabled": False} @@ -1074,7 +1085,7 @@ def _order_recommendation_cancel_reason(conn, rec: dict, order: dict) -> str: def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict: - cfg = _paper_cfg(config) + cfg = _paper_cfg_for_rec(rec, config) plan = _entry_plan(rec) lineage = _strategy_lineage_from_rec(rec) return { @@ -1106,7 +1117,7 @@ def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, co def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict: fill_price = _safe_float(order.get("target_price")) or current_price - cfg = _paper_cfg(config) + cfg = _paper_cfg_for_rec(rec, config) stop_loss = _safe_float(order.get("stop_loss") or _entry_plan(rec).get("stop_loss") or rec.get("stop_loss")) base_notional = _safe_float(order.get("notional_usdt"), default_notional_usdt(cfg)) global_ok, global_detail = _global_risk_entry_check(conn, rec, base_notional, cfg) @@ -1174,7 +1185,7 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict: - cfg = _paper_cfg(config) + cfg = _paper_cfg_for_rec(rec, config) rec_id = _safe_int(rec.get("id")) order = conn.execute("SELECT * FROM paper_orders WHERE recommendation_id=%s", (rec_id,)).fetchone() if order: diff --git a/app/db/recommendation_commands.py b/app/db/recommendation_commands.py index 4b0cd98..01523d9 100644 --- a/app/db/recommendation_commands.py +++ b/app/db/recommendation_commands.py @@ -368,6 +368,7 @@ def apply_recommendation_state_transition(rec_id, requested_action, current_pric market_context=normalize_json_object(item.get("market_context_json")), derivatives_context=normalize_json_object(item.get("derivatives_context_json")), sector_context=normalize_json_object(item.get("sector_context_json")), + strategy_code=item.get("strategy_code") or entry_plan.get("strategy_code"), ) else: gate_reasons = [] @@ -547,6 +548,7 @@ def update_recommendation_action_status(rec_id, action_status): market_context=normalize_json_object(row["market_context_json"]), derivatives_context=normalize_json_object(row["derivatives_context_json"]), sector_context=normalize_json_object(row["sector_context_json"]), + strategy_code=row["strategy_code"] or entry_plan.get("strategy_code"), ) action_status = gated_action entry_plan = gated_plan diff --git a/app/db/recommendation_state.py b/app/db/recommendation_state.py index 72b2e44..a920224 100644 --- a/app/db/recommendation_state.py +++ b/app/db/recommendation_state.py @@ -302,6 +302,7 @@ def derive_execution_fields(item): market_context=market_context, derivatives_context=derivatives_context, sector_context=sector_context, + strategy_code=item.get("strategy_code") or entry_plan.get("strategy_code"), ) try: rec_score_for_gate = float(item.get("rec_score") or 0) diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index 3019161..334d449 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -41,6 +41,7 @@ from app.config.config_loader import ( get_strategy_params, ) from app.core.opportunity_lifecycle import apply_entry_quality_gate +from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, BOX_RETEST_4H_STRATEGY, MAIN_COMPOSITE_STRATEGY from app.core.opportunity_level import ( attach_opportunity_level, classify_opportunity_level, @@ -1180,6 +1181,16 @@ def confirm_burst(symbol, cand): atr_1h = calc_atr(h1_df, 14) confirm_cfg = _get_cfg_section("confirm") pa_recency_cfg = confirm_cfg.get("pa_recency", {}) + # Defensive defaults: confirm runs under scheduler on many symbols with + # different data availability. Keep optional higher-timeframe signals from + # crashing the whole batch when a branch is skipped or an API returns less + # data than expected. + stale_vp_count = 0 + stale_1h_ignitions = [] + stale_d1_ignitions = [] + bp_1h = {"detected": False} + bp_4h = {"detected": False} + bp_daily = {"detected": False} upstream_sector_context = cand_detail.get("sector_context") or {} if upstream_sector_context.get("hot_sectors") or upstream_sector_context.get("leader_symbol"): @@ -1250,7 +1261,6 @@ def confirm_burst(symbol, cand): score += factor_scorer.delta("volume_divergence_1h", 1, evidence="1H放量但价格行为未确认", value=round(vol_ratio, 2)) # ---- 1H箱体突破回踩:比4H更早的结构候选,仍需买点/风控过滤 ---- - bp_1h = {"detected": False} try: if h1_df is not None and len(h1_df) >= 60: bp_1h = detect_box_breakout_pullback_1h(h1_df) @@ -1279,7 +1289,6 @@ def confirm_burst(symbol, cand): resistance = high_q_supply[0]["top"] # ---- 4H箱体突破回踩:完整结构模型,优先作为“可执行形态”记录和复盘 ---- - bp_4h = {"detected": False} try: if h4_df is not None and len(h4_df) >= 40: bp_4h = detect_box_breakout_pullback_4h(h4_df) @@ -1485,7 +1494,6 @@ def confirm_burst(symbol, cand): # ---- 日线底部突破回踩检测(提前到门控前,供高位过滤器复用)---- # 复用已拉取的 d1_df,零额外API调用 - bp_daily = {"detected": False} try: if d1_df is not None and len(d1_df) >= 50: bp_daily = detect_breakout_pullback(d1_df, "日线") @@ -1516,7 +1524,14 @@ def confirm_burst(symbol, cand): current_trigger_ok = bool(current_trigger_times) recent_candidate_ok = (fresh_reason == "fresh_candidate_state") if score >= structure_gate_score and entry_action in ("即刻买入", "可即刻买入") and (current_trigger_ok or recent_candidate_ok): - if fresh_reason != "stale_structure_background_only" and (stale_vp_count > 0 or stale_1h_ignitions or stale_d1_ignitions or bp_daily.get("detected") or bp_1h.get("detected") or bp_4h.get("detected")): + if fresh_reason != "stale_structure_background_only" and ( + stale_vp_count > 0 + or stale_1h_ignitions + or stale_d1_ignitions + or (bp_daily or {}).get("detected") + or (bp_1h or {}).get("detected") + or (bp_4h or {}).get("detected") + ): signals.append(f"🟡 历史强背景+当前结构确认(score≥{structure_gate_score})") confirmed = True @@ -1760,6 +1775,13 @@ def confirm_burst(symbol, cand): } entry_plan = attach_opportunity_level(entry_plan, level_meta) + gate_strategy_code = ( + BOX_RETEST_1H_STRATEGY if bp_1h.get("detected") + else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected") + else MAIN_COMPOSITE_STRATEGY + ) + entry_plan.setdefault("strategy_code", gate_strategy_code) + # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 gated_action, gated_plan, gate_reasons = apply_entry_quality_gate( action_status="可即刻买入" if entry_action in ("即刻买入", "可即刻买入") else "等回踩", @@ -1769,6 +1791,7 @@ def confirm_burst(symbol, cand): market_context=compute_market_context(h1_df, price), derivatives_context=cand_detail.get("derivatives_context", {}), sector_context=cand_detail.get("sector_context", {}), + strategy_code=gate_strategy_code, ) if bypass_confirmed and vp_fly_count == 0 and not current_trigger_times and gated_action == "可即刻买入": gated_action = "等回踩" @@ -2086,6 +2109,8 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None, rec_entry_price = result["price"] previous_rec_id = _active_recommendation_id(symbol) strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep) + if strategy_ctx.get("strategy_code"): + ep["strategy_code"] = strategy_ctx["strategy_code"] rec_id = create_recommendation( symbol=symbol, rec_state="爆发", rec_score=result["score"], entry_price=rec_entry_price, @@ -2142,6 +2167,8 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None, if _should_publish_watch_candidate(cand, result): watch_plan = _watch_candidate_plan(symbol, result, cand_detail) strategy_ctx = _strategy_context_for_recommendation(symbol, result, watch_plan) + if strategy_ctx.get("strategy_code"): + watch_plan["strategy_code"] = strategy_ctx["strategy_code"] rec_id = create_recommendation( symbol=symbol, rec_state="观察", diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index d07db03..f7fbef1 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -250,6 +250,7 @@ def analyze_tracking_signals(symbol, rec, current_price): market_context=rec.get("market_context") or {}, derivatives_context=rec.get("derivatives_context") or {}, sector_context=rec.get("sector_context") or {}, + strategy_code=rec.get("strategy_code") or entry_plan.get("strategy_code"), ) if gate_reasons: buy_signals = reconcile_buy_signals_after_gate( diff --git a/static/app.html b/static/app.html index d7f68a7..7c2a37c 100644 --- a/static/app.html +++ b/static/app.html @@ -890,7 +890,7 @@ function renderRecCard(r) { return ''+displaySignalText(s)+''; }).join(''); var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||''; - var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认主链路',box_retest_4h_v1:'4H箱体突破回踩'}[r.strategy_code||''] || r.strategy_code || ''); + var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认主链路',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩'}[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 ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位'); var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0); diff --git a/static/paper_trading.html b/static/paper_trading.html index 66d19b4..1e1c38b 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -168,7 +168,7 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML=''+ ''}).join('')} function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'移动止盈 $'+fmt(trail,6)+'':'移动止盈未启动';return '
TP $'+fmt(x.tp1,6)+'SL $'+fmt(x.stop_loss,6)+''+trailHtml+'
'} -function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认主链路',box_retest_4h_v1:'4H箱体突破回踩'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))} +function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认主链路',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))} async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open');openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML=''+esc(e.message)+''}} async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])} async function loadCompletedTrades(){$('completedTradeRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML=''+esc(e.message)+''}} @@ -199,10 +199,11 @@ function renderCompletedOrders(items){if(!items.length){$('completedOrderRows'). '
TP $'+fmt(x.tp1,6)+'SL $'+fmt(x.stop_loss,6)+'
'+ ''+time(x.created_at)+''+ ''+time(ended)+''+ - '
'+esc(strategyName(x))+'
'+esc(x.cancel_reason||x.source_status||'--')+' · '+esc(x.source_action||'')+'
'+ + '
'+esc(strategyName(x))+'
'+esc(cancelReasonLabel(x.cancel_reason||x.status))+'
'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'
'+ ''+ ''}).join('')} function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'} +function cancelReasonLabel(r){return {global_risk_rejected:'全局风控拒绝:市场/账户风险过高,未转持仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期弱入场过多:暂停新增仓位',recommendation_invalid:'原推荐已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'} function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='第 '+page+' / '+totalPages+' 页'} async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='
加载中...
';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ));eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='
'+esc(e.message)+'
'}} function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'} diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index 6ea40bc..feb7bf0 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -5,6 +5,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from app.core.opportunity_lifecycle import apply_entry_quality_gate +from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, MAIN_COMPOSITE_STRATEGY from app.services.price_tracker import reconcile_buy_signals_after_gate @@ -77,6 +78,41 @@ def test_low_entry_score_blocks_buy_now_and_weak_pullback(): assert plan['entry_quality_gate']['entry_score'] == 0 +def test_entry_gate_uses_strategy_specific_thresholds(): + base_plan = { + 'entry_action': '可即刻买入', + 'entry_price': 1.0, + 'current_price': 1.0, + 'stop_loss': 0.95, + 'tp1': 1.12, + 'risk_reward_ok': True, + 'rr1': 2.4, + 'entry_trigger_confirmed': True, + 'score_components': {'opportunity_score': 12, 'entry_score': 1, 'risk_score': 1}, + } + main_action, main_plan, _ = apply_entry_quality_gate( + action_status='可即刻买入', + entry_plan=dict(base_plan), + signals=['当前15min即刻入场信号'], + current_price=1.0, + market_context={'change_24h': 2.0}, + strategy_code=MAIN_COMPOSITE_STRATEGY, + ) + box_action, box_plan, _ = apply_entry_quality_gate( + action_status='可即刻买入', + entry_plan=dict(base_plan), + signals=['当前15min即刻入场信号'], + current_price=1.0, + market_context={'change_24h': 2.0}, + strategy_code=BOX_RETEST_1H_STRATEGY, + ) + + assert main_action != '可即刻买入' + assert main_plan['entry_quality_gate']['strategy_code'] == MAIN_COMPOSITE_STRATEGY + assert box_action == '可即刻买入' + assert box_plan['strategy_code'] == BOX_RETEST_1H_STRATEGY + + def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk(): action, plan, reasons = apply_entry_quality_gate( action_status='可即刻买入',