diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index 1640372..65f468c 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -168,6 +168,24 @@ def detect_static_count(signals: Iterable[Any]) -> int: return max_count +def has_current_entry_trigger(signals: Iterable[Any], entry_plan: Dict[str, Any]) -> bool: + if entry_plan.get("entry_trigger_confirmed") is True: + return True + summary = str(entry_plan.get("pa_15min_summary") or "") + if "即刻入场" in summary and "无动K突破" not in summary: + return True + for sig in signals or []: + text = str(sig) + if "15min即刻入场" in text or "当前15min" in text or "当前 15min" in text: + return True + return False + + +def has_bearish_flow_risk(signals: Iterable[Any]) -> bool: + risk_keywords = ("空头加速", "放量阴线", "多头出货", "量价背离") + return any(any(keyword in str(sig) for keyword in risk_keywords) for sig in signals or []) + + def _calc_position_pct(current_price: float, entry_plan: Dict[str, Any]) -> float: support = to_float(entry_plan.get("support") or entry_plan.get("support_price") or entry_plan.get("range_low")) resistance = to_float(entry_plan.get("resistance") or entry_plan.get("resistance_price") or entry_plan.get("range_high")) @@ -302,6 +320,10 @@ def apply_entry_quality_gate( reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入") if action_status == "可即刻买入": + if not has_current_entry_trigger(signals, entry_plan): + reasons.append("缺少当前15min触发,禁止现价买入") + if has_bearish_flow_risk(signals): + reasons.append("出现空头加速/放量阴线风险,禁止现价买入") if current_price > 0: plan_entry_price = to_float(entry_plan.get("entry_price")) # 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。 diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index 8102c87..60b7e73 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -1363,7 +1363,15 @@ def _is_executed_trade(item): status = (item.get("status") or "").strip() action_status = normalize_action_status(item.get("action_status"), status) execution_status = item.get("execution_status") or "" - if action_status == "可即刻买入" or execution_status == "buy_now": + try: + entry_triggered = int(item.get("entry_triggered") or 0) == 1 + except Exception: + entry_triggered = False + if entry_triggered: + return True + if status in ("hit_tp1", "hit_tp2", "stopped_out"): + return True + if item.get("display_bucket") == "position" or execution_status in ("holding", "completed"): return True return is_executed_lifecycle(status, action_status, execution_status) diff --git a/app/db/analytics.py b/app/db/analytics.py index 4ed9012..c4d3797 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -52,6 +52,42 @@ def _safe_float(value, default=0.0): return default +EXECUTED_TRADE_WHERE = """( + COALESCE(entry_triggered, 0) = 1 + OR COALESCE(display_bucket, '') = 'position' + OR COALESCE(execution_status, '') IN ('holding', 'completed') + OR status IN ('hit_tp1', 'hit_tp2', 'stopped_out') +)""" + + +SUCCESS_CASE = f"""( + ({EXECUTED_TRADE_WHERE}) + AND ( + status IN ('hit_tp1', 'hit_tp2') + OR ( + status NOT IN ('stopped_out', 'expired', 'invalid', 'archived') + AND COALESCE(max_pnl_pct, 0) >= 5 + ) + ) +)""" + + +FAILURE_CASE = f"""( + ({EXECUTED_TRADE_WHERE}) + AND (status='stopped_out' OR COALESCE(pnl_pct, 0) <= -3 OR COALESCE(max_drawdown_pct, 0) <= -5) +)""" + + +def _executed_trade_where(alias=""): + prefix = f"{alias}." if alias else "" + return f"""( + COALESCE({prefix}entry_triggered, 0) = 1 + OR COALESCE({prefix}display_bucket, '') = 'position' + OR COALESCE({prefix}execution_status, '') IN ('holding', 'completed') + OR {prefix}status IN ('hit_tp1', 'hit_tp2', 'stopped_out') +)""" + + def _parse_dt(value): if isinstance(value, datetime): return value @@ -162,20 +198,16 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, except Exception: offset = 0 - result_where = """(status IN ('hit_tp1', 'hit_tp2', 'stopped_out') - OR (COALESCE(max_pnl_pct, 0) >= 5) - OR (COALESCE(pnl_pct, 0) <= -3 OR COALESCE(max_drawdown_pct, 0) <= -5))""" + result_where = EXECUTED_TRADE_WHERE version_where = " AND strategy_version=?" if version else "" params = [version] if version else [] total = None summary = None version_counts = [] - success_case = "status IN ('hit_tp1','hit_tp2') OR (status NOT IN ('stopped_out','expired','invalid','archived') AND COALESCE(max_pnl_pct,0) >= 5)" - failure_case = "status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5" realized_pnl_case = ( - f"CASE WHEN {failure_case} THEN COALESCE(pnl_pct,0) " - f"WHEN {success_case} THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) " + f"CASE WHEN {FAILURE_CASE} THEN COALESCE(pnl_pct,0) " + f"WHEN {SUCCESS_CASE} THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) " "ELSE 0 END" ) @@ -201,10 +233,10 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, SELECT COUNT(*) AS total, SUM(CASE WHEN """ - + success_case + + SUCCESS_CASE + """ THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN """ - + failure_case + + FAILURE_CASE + """ THEN 1 ELSE 0 END) AS failure_count, SUM(""" + realized_pnl_case @@ -213,7 +245,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, + realized_pnl_case + """) AS best_pnl, AVG(CASE WHEN """ - + failure_case + + FAILURE_CASE + """ THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl FROM ( SELECT r.* @@ -232,6 +264,9 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, tuple(params), ).fetchone() summary = dict(summary_row) if summary_row else {} + for key in ("total", "success_count", "failure_count", "total_pnl", "best_pnl", "avg_failure_pnl"): + if summary.get(key) is None: + summary[key] = 0 vc_rows = conn.execute( """ @@ -493,13 +528,20 @@ def get_stats(): decay_watch = decay_candidates[0] if decay_candidates else None points_24h = [] - rows_24h = conn.execute(""" - SELECT substr(track_time, 1, 13) || ':00:00' AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count - FROM price_tracking - WHERE julianday(?) - julianday(track_time) <= 1.0 + rows_24h = conn.execute( + """ + SELECT substr(pt.track_time, 1, 13) || ':00:00' AS bucket, AVG(pt.pnl_pct) AS avg_pnl, COUNT(*) AS sample_count + FROM price_tracking pt + JOIN recommendation r ON r.id = pt.rec_id + WHERE julianday(?) - julianday(pt.track_time) <= 1.0 + AND """ + + _executed_trade_where("r") + + """ GROUP BY bucket ORDER BY bucket ASC - """, (now.isoformat(),)).fetchall() + """, + (now.isoformat(),), + ).fetchall() for row in rows_24h: points_24h.append({ "time": row["bucket"], @@ -508,13 +550,20 @@ def get_stats(): }) points_7d = [] - rows_7d = conn.execute(""" - SELECT substr(track_time, 1, 10) AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count - FROM price_tracking - WHERE julianday(?) - julianday(track_time) <= 7.0 + rows_7d = conn.execute( + """ + SELECT substr(pt.track_time, 1, 10) AS bucket, AVG(pt.pnl_pct) AS avg_pnl, COUNT(*) AS sample_count + FROM price_tracking pt + JOIN recommendation r ON r.id = pt.rec_id + WHERE julianday(?) - julianday(pt.track_time) <= 7.0 + AND """ + + _executed_trade_where("r") + + """ GROUP BY bucket ORDER BY bucket ASC - """, (now.isoformat(),)).fetchall() + """, + (now.isoformat(),), + ).fetchall() for row in rows_7d: points_7d.append({ "time": row["bucket"], diff --git a/rules.yaml b/rules.yaml index 27c6a77..b0e0279 100644 --- a/rules.yaml +++ b/rules.yaml @@ -407,11 +407,11 @@ event_driven: note: Solana meme主题扩散 meta: version: 1 - last_review: '2026-05-15T11:16:43.982118' - last_reverse_analysis: '2026-05-15T11:17:20.540656' - total_reviews: 44 + last_review: '2026-05-15T11:52:18.602057' + last_reverse_analysis: '2026-05-15T11:52:58.300578' + total_reviews: 45 total_rules_learned: 37 - iteration_count: 49 + iteration_count: 50 strategy_version: v1.7.11 strategy_revision_started_at: '2026-05-09T01:20:00' strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记' diff --git a/tests/test_actionable_active_recommendations.py b/tests/test_actionable_active_recommendations.py index be4beaf..92c9287 100644 --- a/tests/test_actionable_active_recommendations.py +++ b/tests/test_actionable_active_recommendations.py @@ -35,7 +35,7 @@ def _insert_recommendation(db_path, **kwargs): tp1=110.0, tp2=118.0, sector='AI', - signals='[]', + signals='["🟢 15min即刻入场信号"]', is_meme=0, status='active', current_price=100.0, @@ -49,7 +49,7 @@ def _insert_recommendation(db_path, **kwargs): stopped_out_time='', expired_time='', last_track_time='2026-04-30T10:05:00', - entry_plan_json='{}', + entry_plan_json='{"entry_trigger_confirmed": true}', action_status='持有', direction='多头启动', strategy_version='v1.2', @@ -151,9 +151,9 @@ def test_stats_only_count_actionable_active_recommendations(temp_db): stats = altcoin_db.get_stats() assert stats['active_count'] == 2 assert stats['raw_active_count'] == 4 - assert stats['active_pnl_sum'] == 5.0 - assert stats['active_avg_pnl'] == 5.0 - assert stats['active_success_count'] == 1 + assert stats['active_pnl_sum'] == 0 + assert stats['active_avg_pnl'] == 0 + assert stats['active_success_count'] == 0 assert stats['active_failed_count'] == 0 assert stats['active_pending_count'] == 0 @@ -198,12 +198,12 @@ def test_stats_api_exposes_separate_live_and_history_sections(temp_db): live = stats['live_overview'] assert live['actionable_count'] == 2 - assert live['executed_trade_count'] == 1 - assert live['executed_pnl_sum'] == 5.0 - assert live['executed_avg_pnl'] == 5.0 - assert live['actionable_pnl_sum'] == 5.0 - assert live['actionable_avg_pnl'] == 5.0 - assert live['actionable_success_count'] == 1 + assert live['executed_trade_count'] == 0 + assert live['executed_pnl_sum'] == 0 + assert live['executed_avg_pnl'] == 0 + assert live['actionable_pnl_sum'] == 0 + assert live['actionable_avg_pnl'] == 0 + assert live['actionable_success_count'] == 0 assert live['actionable_failed_count'] == 0 assert live['actionable_pending_count'] == 0 assert live['raw_active_count'] == 4 @@ -307,6 +307,44 @@ def test_history_avg_pnl_only_uses_real_tp_and_stopout_samples(temp_db): assert stats['history_overview']['avg_pnl_pct'] == pytest.approx(1.0) + +def test_history_api_excludes_untriggered_observe_wait_and_invalid_losses(temp_db): + _insert_recommendation( + temp_db, + symbol='OBSLOSS/USDT', + action_status='持有', + status='active', + entry_plan_json='{"entry_action": "继续观察"}', + pnl_pct=-4.0, + max_drawdown_pct=-6.0, + ) + _insert_recommendation( + temp_db, + symbol='WAITLOSS/USDT', + action_status='等回踩', + status='active', + entry_plan_json='{"entry_action": "等回踩", "entry_price": 95}', + pnl_pct=-5.0, + max_drawdown_pct=-7.0, + ) + _insert_recommendation( + temp_db, + symbol='INVLOSS/USDT', + action_status='衰减', + status='active', + entry_plan_json='{"entry_action": "继续观察"}', + pnl_pct=-6.0, + max_drawdown_pct=-8.0, + ) + + page = altcoin_db.get_all_recommendations(limit=20, decision_only=True, with_meta=True) + + assert page['items'] == [] + assert page['total'] == 0 + assert page['summary']['total'] == 0 + assert page['summary']['failure_count'] == 0 + assert page['summary']['total_pnl'] == 0 + def test_version_filter_labels_use_plan_not_executable_to_avoid_wait_pullback_confusion(temp_db): _insert_recommendation( temp_db, diff --git a/tests/test_executed_trade_pnl_semantics.py b/tests/test_executed_trade_pnl_semantics.py index 55d1308..82a1792 100644 --- a/tests/test_executed_trade_pnl_semantics.py +++ b/tests/test_executed_trade_pnl_semantics.py @@ -25,7 +25,7 @@ def temp_db(monkeypatch, tmp_path): return db_path -def test_wait_pullback_plan_is_actionable_but_not_counted_as_executed_pnl(temp_db): +def test_actionable_plans_are_not_counted_as_executed_pnl_until_entry_triggers(temp_db): _insert_recommendation( temp_db, symbol='BUY/USDT', @@ -59,14 +59,34 @@ def test_wait_pullback_plan_is_actionable_but_not_counted_as_executed_pnl(temp_d assert live['buy_now_count'] == 1 assert live['wait_pullback_count'] == 1 - # 但收益只算已经执行/触发入场的 BUY,不把 WAIT/OBS 的发现后涨幅算收益 + # 但 buy_now 只是入场窗口,不等于已成交;未触发入场前不计算收益。 + assert live['executed_trade_count'] == 0 + assert live['executed_pnl_sum'] == pytest.approx(0.0) + assert live['executed_avg_pnl'] == pytest.approx(0.0) + assert stats['active_pnl_sum'] == pytest.approx(0.0) + assert stats['active_avg_pnl'] == pytest.approx(0.0) + assert stats['active_success_count'] == 0 + assert stats['active_pending_count'] == 0 + + +def test_entry_triggered_buy_now_counts_as_executed_pnl(temp_db): + _insert_recommendation( + temp_db, + symbol='BUY/USDT', + action_status='可即刻买入', + entry_plan_json='{"entry_action": "可即刻买入"}', + pnl_pct=5.0, + max_pnl_pct=5.0, + ) + conn = altcoin_db.sqlite3.connect(str(temp_db)) + conn.execute("UPDATE recommendation SET entry_triggered=1 WHERE symbol='BUY/USDT'") + conn.commit() + conn.close() + + stats = altcoin_db.get_stats() + live = stats['live_overview'] assert live['executed_trade_count'] == 1 assert live['executed_pnl_sum'] == pytest.approx(5.0) - assert live['executed_avg_pnl'] == pytest.approx(5.0) - assert stats['active_pnl_sum'] == pytest.approx(5.0) - assert stats['active_avg_pnl'] == pytest.approx(5.0) - assert stats['active_success_count'] == 1 - assert stats['active_pending_count'] == 0 def test_active_api_marks_wait_plan_as_unexecuted_not_success(temp_db): diff --git a/tests/test_history_grouping.py b/tests/test_history_grouping.py index c7cf72c..8a94bd4 100644 --- a/tests/test_history_grouping.py +++ b/tests/test_history_grouping.py @@ -36,7 +36,7 @@ class RecommendationHistoryBase(unittest.TestCase): tp1=110.0, tp2=118.0, sector='AI', - signals='[]', + signals=json.dumps(['🟢 15min即刻入场信号'], ensure_ascii=False), is_meme=0, status='active', current_price=100.0, @@ -60,6 +60,7 @@ class RecommendationHistoryBase(unittest.TestCase): 'tp2': 118.0, 'rr1': 2.0, 'rr2': 3.6, + 'entry_trigger_confirmed': True, }, ensure_ascii=False), action_status='可即刻买入', direction='多头启动', diff --git a/tests/test_llm_insights.py b/tests/test_llm_insights.py index 701ccb2..49580a3 100644 --- a/tests/test_llm_insights.py +++ b/tests/test_llm_insights.py @@ -52,7 +52,7 @@ def _insert_recommendation(db_path, **kwargs): stopped_out_time="", expired_time="", last_track_time="2026-05-01T10:10:00", - entry_plan_json=json.dumps({"entry_price": 100.0, "entry_action": "可即刻买入"}, ensure_ascii=False), + entry_plan_json=json.dumps({"entry_price": 100.0, "entry_action": "可即刻买入", "entry_trigger_confirmed": True}, ensure_ascii=False), action_status="可即刻买入", direction="多头启动", execution_status="buy_now", diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index 53421ec..63f135a 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -53,6 +53,28 @@ def test_buy_now_with_bad_rr_sets_real_pullback_price(): assert any('现价不买' in r for r in reasons) +def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk(): + action, plan, reasons = apply_entry_quality_gate( + action_status='可即刻买入', + entry_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, + }, + signals=['1H历史起爆点已过期(12根前)', '⚠️ 1H连续3K空头加速'], + current_price=1.0, + market_context={'change_24h': 2.0}, + ) + + assert action != '可即刻买入' + assert any('缺少当前15min触发' in r for r in reasons) + assert any('空头加速' in r for r in reasons) + + def test_tracker_gate_downgrade_removes_provisional_buy_signal(): signals = reconcile_buy_signals_after_gate( [ diff --git a/tests/test_recommendation_execution_status.py b/tests/test_recommendation_execution_status.py index bec79c7..68c8fdc 100644 --- a/tests/test_recommendation_execution_status.py +++ b/tests/test_recommendation_execution_status.py @@ -31,7 +31,7 @@ class RecommendationExecutionStatusTests(unittest.TestCase): tp1=110.0, tp2=118.0, sector='AI', - signals='[]', + signals=json.dumps(['🟢 15min即刻入场信号'], ensure_ascii=False), is_meme=0, status='active', current_price=100.0, @@ -55,6 +55,7 @@ class RecommendationExecutionStatusTests(unittest.TestCase): 'tp2': 118.0, 'rr1': 2.0, 'rr2': 3.6, + 'entry_trigger_confirmed': True, }, ensure_ascii=False), action_status='可即刻买入', direction='多头启动', diff --git a/tests/test_signal_trust_stage1.py b/tests/test_signal_trust_stage1.py index 66c1cd6..e107def 100644 --- a/tests/test_signal_trust_stage1.py +++ b/tests/test_signal_trust_stage1.py @@ -35,7 +35,7 @@ class RecommendationSignalTrustTests(unittest.TestCase): tp1=10.8, tp2=11.4, sector='', - signals=json.dumps(['15m 入场窗口信号'], ensure_ascii=False), + signals=json.dumps(['🟢 15min即刻入场信号'], ensure_ascii=False), is_meme=0, status='active', current_price=10.0, @@ -53,6 +53,7 @@ class RecommendationSignalTrustTests(unittest.TestCase): 'entry_price': 10.0, 'entry_action': '可即刻买入', 'risk_reward_ok': True, + 'entry_trigger_confirmed': True, 'rr1': 2.0, 'stop_loss': 9.6, 'tp1': 10.8,