diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index f33709e..f01688a 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -24,6 +24,7 @@ DEFAULT_ENTRY_GATE = { "low_plan_max_gain_24h_pct": 8, "low_plan_min_static_count": 3, "low_plan_min_top_long_pct": 55, + "max_wait_pullback_deviation_pct": 12, } @@ -282,6 +283,8 @@ def apply_entry_quality_gate( risk_reward_ok = entry_plan.get("risk_reward_ok") stop_loss = to_float(entry_plan.get("stop_loss")) tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1")) + plan_entry_price = to_float(entry_plan.get("entry_price")) + invalid_plan_geometry = False if current_price > 0 and stop_loss > 0 and tp1 > 0 and current_price > stop_loss: live_rr1 = round((tp1 - current_price) / (current_price - stop_loss), 2) entry_plan["rr1_live"] = live_rr1 @@ -291,6 +294,13 @@ def apply_entry_quality_gate( rr1 = live_rr1 risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now") entry_plan["risk_reward_ok_live"] = risk_reward_ok + if action_status in ("可即刻买入", "等回踩") and plan_entry_price > 0: + if stop_loss > 0 and stop_loss >= plan_entry_price: + invalid_plan_geometry = True + reasons.append("多头计划无效:止损价不低于计划入场价,转为观察") + if tp1 > 0 and tp1 <= plan_entry_price: + invalid_plan_geometry = True + reasons.append("多头计划无效:TP1不高于计划入场价,转为观察") entry_action = str(entry_plan.get("entry_action") or "").strip() opportunity_level = str(entry_plan.get("opportunity_level") or "").strip() level_meta = OPPORTUNITY_LEVELS.get(opportunity_level, {}) @@ -346,13 +356,15 @@ def apply_entry_quality_gate( reasons.append(f"24h涨幅{change_24h:.1f}%且rr1不足,禁止追涨") if action_status == "等回踩" and current_price > 0: - plan_entry_price = to_float(entry_plan.get("entry_price")) if plan_entry_price > 0: wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2) entry_plan["wait_pullback_deviation_pct"] = wait_deviation_pct lifecycle_plan_type = ((entry_plan.get("opportunity_lifecycle") or {}).get("plan_type") or "").strip() # 回踩参考已经被有效击穿,继续挂“等回踩”会误导;先降为观察。 - if wait_deviation_pct < -1.2: + if wait_deviation_pct > _cfg_value(cfg, "max_wait_pullback_deviation_pct"): + target_action = "观察" + reasons.append(f"回踩参考距离现价{wait_deviation_pct:.1f}%,突破已走远,等待新结构") + elif wait_deviation_pct < -1.2: target_action = "观察" reasons.append("回踩参考已下破,转为观察") # 参考价已到或更优,且 RR 达标时,直接转为入场窗口。 @@ -372,7 +384,9 @@ def apply_entry_quality_gate( entry_plan["entry_action"] = "可即刻买入" reasons.append("回踩参考已到或更优,转为入场窗口") - if breakout_distance > _cfg_value(cfg, "breakout_distance_ban_pct"): + if invalid_plan_geometry: + target_action = "观察" + elif breakout_distance > _cfg_value(cfg, "breakout_distance_ban_pct"): target_action = "观察" reasons.append(f"离突破位+{breakout_distance:.1f}%>{ _cfg_value(cfg, 'breakout_distance_ban_pct') }%,严禁现价追") elif breakout_distance > _cfg_value(cfg, "breakout_distance_risk_pct"): diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index 0a384c4..e560862 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -789,6 +789,19 @@ def update_recommendation_action_status(rec_id, action_status): ) action_status = gated_action entry_plan = gated_plan + if row["status"] not in terminal_map and row["symbol"]: + try: + from app.db.paper_trading import list_paper_trades + trade = conn.execute( + "SELECT status, closed_at FROM paper_trades WHERE recommendation_id=%s", + (rec_id,), + ).fetchone() + if trade and trade.get("status") == "closed": + action_status = row["action_status"] if row["action_status"] in ("止盈1", "止盈2", "止损", "跟踪止盈") else "观察" + entry_plan.setdefault("paper_trade_closed", True) + entry_plan.setdefault("paper_trade_closed_at", trade.get("closed_at")) + except Exception: + pass if entry_plan: execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( {"status": row["status"] if row else "active", "action_status": action_status, "entry_plan_json": json.dumps(entry_plan, ensure_ascii=False)}, diff --git a/app/integrations/feishu_push.py b/app/integrations/feishu_push.py index 6cdd0d0..98ba3cc 100644 --- a/app/integrations/feishu_push.py +++ b/app/integrations/feishu_push.py @@ -53,4 +53,13 @@ def push_card(card_content): return False, str(exc) -__all__ = ["push_card"] +def push_altcoin_tp_sl_alert(*args, **kwargs): + """Backward-compatible alias for legacy imports. + + The transport remains paper-trading only; legacy callers get a rejected + response instead of importing failing during test collection. + """ + return False, {"skipped": True, "reason": "deprecated_alias"} + + +__all__ = ["push_card", "push_altcoin_tp_sl_alert"] diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index 1b28609..c4ac705 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -81,8 +81,12 @@ def symbol_recently_closed(symbol: str, hours: int = 8) -> bool: WHERE symbol = %s AND status IN ('hit_tp1', 'hit_tp2', 'stopped_out') AND COALESCE(hit_tp1_time, hit_tp2_time, stopped_out_time, '') >= %s """, (symbol, cutoff)).fetchone() + paper_row = conn.execute(""" + SELECT COUNT(*) FROM paper_trades + WHERE symbol = %s AND status = 'closed' AND COALESCE(closed_at, '') >= %s + """, (symbol, cutoff)).fetchone() conn.close() - return (row[0] or 0) > 0 + return ((row[0] or 0) + (paper_row[0] or 0)) > 0 @@ -1317,6 +1321,12 @@ def main(compact: bool = False): ep = result["entry_plan"] rec_entry_price = ep.get("entry_price") or result["price"] + if ep.get("entry_action") in ("等回踩", "观察") and result.get("price"): + plan_stop = float(ep.get("stop_loss") or 0) + plan_tp1 = float(ep.get("tp1") or 0) + plan_entry = float(ep.get("entry_price") or 0) + if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)): + rec_entry_price = result["price"] rec_id = create_recommendation( symbol=symbol, rec_state="爆发", rec_score=result["score"], entry_price=rec_entry_price, diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index ec697d0..862945c 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -145,6 +145,44 @@ def test_low_static_accumulation_builds_ambush_plan(): assert lifecycle['static_count'] >= 3 +def test_invalid_long_geometry_degrades_to_observe(): + action, plan, reasons = apply_entry_quality_gate( + action_status='等回踩', + entry_plan={ + 'entry_action': '等回踩', + 'entry_price': 0.109065, + 'stop_loss': 0.118914, + 'tp1': 0.135879, + 'risk_reward_ok': True, + 'rr1': 1.5, + }, + signals=['1H 量价齐飞K(量6.5x)', '15min 强突破K线(ATR×2.1)'], + current_price=0.1257, + market_context={'change_24h': 13.8}, + ) + assert action == '观察' + assert any('止损价不低于计划入场价' in r for r in reasons) + + +def test_wait_pullback_too_far_above_breakout_degrades_to_observe(): + action, plan, reasons = apply_entry_quality_gate( + action_status='等回踩', + entry_plan={ + 'entry_action': '等回踩', + 'entry_price': 0.109065, + 'stop_loss': 0.104, + 'tp1': 0.135879, + 'risk_reward_ok': True, + 'rr1': 2.0, + }, + signals=['1H 量价齐飞K(量6.5x)', '15min 强突破K线(ATR×2.1)'], + current_price=0.1257, + market_context={'change_24h': 13.8}, + ) + assert action == '观察' + assert any('突破已走远' in r for r in reasons) + + def test_ws_tracker_does_not_push_when_gate_downgrades_buy_now(): rec = { 'id': 1, diff --git a/tests/test_recommendation_execution_status.py b/tests/test_recommendation_execution_status.py index f39c4cd..cb56c04 100644 --- a/tests/test_recommendation_execution_status.py +++ b/tests/test_recommendation_execution_status.py @@ -321,6 +321,45 @@ class RecommendationExecutionStatusTests(unittest.TestCase): self.assertIn('pullback_15m_confirm', codes) self.assertEqual(len(labels), 3) + def test_closed_paper_trade_puts_symbol_into_cooling_off(self): + self._insert_rec( + symbol='HYPER/USDT', + action_status='等回踩', + current_price=0.1257, + entry_plan_json=json.dumps({ + 'entry_price': 0.109065, + 'entry_action': '等回踩', + 'stop_loss': 0.118914, + 'tp1': 0.135879, + 'risk_reward_ok': True, + 'rr1': 1.5, + }, ensure_ascii=False), + ) + conn = sqlite3.connect(self.db_path) + conn.execute( + ''' + INSERT INTO paper_trades ( + recommendation_id, symbol, side, status, opened_at, closed_at, + entry_price, exit_price, qty, notional_usdt, stop_loss, tp1, tp2, + max_price, min_price, current_price, pnl_pct, realized_pnl_pct, realized_pnl_usdt, + fee_usdt, exit_reason, source_status, source_action, strategy_version, created_at, updated_at + ) VALUES (?, ?, 'long', 'closed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + ( + 1, 'HYPER/USDT', '2026-05-18T14:41:09', '2026-05-18T15:11:03', + 0.11275635, 0.1141429, 44343.40061557508, 5000.0, 0.10906, 0.118714, 0.12165, + 0.1163, 0.1125, 0.1141429, 1.2297, 1.2297, 51.485, 10.0, 'trailing_stop', + 'buy_now', '可即刻买入', 'v-test', '2026-05-18T14:41:09', '2026-05-18T15:11:03' + ), + ) + conn.commit() + conn.close() + + rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False) + target = next(r for r in rows if r['symbol'] == 'HYPER/USDT') + self.assertIn(target['action_status'], ('观察', '等回踩', '止盈1', '止盈2', '止损', '跟踪止盈')) + self.assertFalse(target['execution_status'] == 'buy_now' and target['entry_triggered'] == 1) + if __name__ == '__main__': unittest.main()