From 82b61bd80891f1a5bb799245e120b2035f3dd63e Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 4 Jun 2026 23:33:21 +0800 Subject: [PATCH] update --- app/cli.py | 11 ++ app/core/strategy_registry.py | 157 +++++---------- .../0019_short_term_strategy_pool.sql | 26 +++ app/db/paper_trading.py | 66 ++++++- app/db/recommendation_commands.py | 12 +- app/db/strategy_sample_cleanup.py | 152 ++++++++++++++ app/services/altcoin_confirm.py | 72 +++---- app/services/altcoin_screener.py | 185 ++++++++++++++++-- app/strategies/altcoin_breakout.py | 107 ++++------ app/strategies/box_retest_4h.py | 168 +--------------- rules.yaml | 6 + static/app.html | 2 +- static/paper_trading.html | 2 +- tests/test_multi_strategy_infra.py | 118 ++++++----- tests/test_opportunity_lifecycle.py | 22 +-- tests/test_paper_trading.py | 20 +- tests/test_recommendation_state_mainline.py | 4 +- tests/test_review_center.py | 2 +- tests/test_screener_optimizations.py | 46 +++++ tests/test_strategy_sample_cleanup.py | 68 +++++++ 20 files changed, 783 insertions(+), 463 deletions(-) create mode 100644 app/db/migrations/0019_short_term_strategy_pool.sql create mode 100644 app/db/strategy_sample_cleanup.py create mode 100644 tests/test_strategy_sample_cleanup.py diff --git a/app/cli.py b/app/cli.py index f9bcc8f..775eaa7 100644 --- a/app/cli.py +++ b/app/cli.py @@ -58,6 +58,11 @@ def build_parser(): repair_strategy.add_argument("--limit", type=int, default=500, help="最多扫描的 recommendation 数量") repair_strategy.add_argument("--dry-run", action="store_true", help="只预览不写库") + reset_strategy = subparsers.add_parser("reset-strategy-samples", help="清理已下线策略的历史样本") + reset_strategy.add_argument("--scope", choices=["legacy-strategies"], required=True) + reset_strategy.add_argument("--confirm", required=True, help="必须传 CLEAN_LEGACY_STRATEGY_SAMPLES") + reset_strategy.add_argument("--no-backup", action="store_true", help="跳过 pg_dump 备份,仅用于测试") + return parser @@ -132,6 +137,12 @@ def main(): result = repair_strategy_direction_mismatches(limit=args.limit, dry_run=args.dry_run) print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2)) return result + if args.command == "reset-strategy-samples": + from app.db.strategy_sample_cleanup import cleanup_legacy_strategy_samples + + result = cleanup_legacy_strategy_samples(confirm=args.confirm, create_backup=not args.no_backup) + print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2)) + return result parser.error(f"unknown command: {args.command}") diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py index f536a98..f3d700e 100644 --- a/app/core/strategy_registry.py +++ b/app/core/strategy_registry.py @@ -5,13 +5,19 @@ from __future__ import annotations from dataclasses import dataclass, field -MAIN_COMPOSITE_STRATEGY = "main_composite_v1" -BOX_RETEST_1H_STRATEGY = "box_retest_1h_v1" -BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1" -VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1" -COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1" -INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1" -BREAKDOWN_RETEST_SHORT_1H_STRATEGY = "breakdown_retest_short_1h_v1" +LONG_MOMENTUM_BREAKOUT_STRATEGY = "long_momentum_breakout_15m_1h_v1" +LONG_SECOND_WAVE_PULLBACK_STRATEGY = "long_second_wave_pullback_1h_v1" +SHORT_BREAKDOWN_RETEST_STRATEGY = "short_breakdown_retest_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 +BREAKDOWN_RETEST_SHORT_1H_STRATEGY = SHORT_BREAKDOWN_RETEST_STRATEGY @dataclass(frozen=True) @@ -27,67 +33,17 @@ class StrategyDefinition: STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { - MAIN_COMPOSITE_STRATEGY: StrategyDefinition( - strategy_code=MAIN_COMPOSITE_STRATEGY, - strategy_name="综合确认策略", - description="迁移期兼容综合策略,承载现有综合筛选与确认逻辑;它与其他策略平等运行。", - direction="both", + LONG_MOMENTUM_BREAKOUT_STRATEGY: StrategyDefinition( + strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, + strategy_name="多头动量启动", + description="15m当前突破叠加1H成交量/波动增强,捕捉山寨币日内到1-3天启动段。", + direction="long", mode="paper_enabled", - ), - BOX_RETEST_1H_STRATEGY: StrategyDefinition( - strategy_code=BOX_RETEST_1H_STRATEGY, - 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, - }, - ), - VOLUME_IGNITION_1H_STRATEGY: StrategyDefinition( - strategy_code=VOLUME_IGNITION_1H_STRATEGY, - strategy_name="1H放量突破启动", - description="1H量价齐飞或连续放量后的启动策略,适合捕捉日内到3天的第一段加速。", - mode="paper_only", - 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": 12, + "min_entry_score_buy_now": 3, + "min_entry_score_wait_pullback": 2, + "min_rr_buy_now": 1.5, + "breakout_distance_wait_pct": 8, "gain_24h_wait_pct": 10, }, paper_config={ @@ -99,55 +55,35 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { "dynamic_leverage_min": 3, }, ), - COMPRESSION_BREAKOUT_4H_STRATEGY: StrategyDefinition( - strategy_code=COMPRESSION_BREAKOUT_4H_STRATEGY, - strategy_name="4H压缩蓄力突破", - description="4H静K蓄力、底部抬高或压缩放量后的突破策略,偏向捕捉1周以内主升前段。", - mode="paper_only", + LONG_SECOND_WAVE_PULLBACK_STRATEGY: StrategyDefinition( + strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY, + strategy_name="多头二波回踩", + description="强势榜或放量币第一波后回踩EMA、箱体上沿或前高转支撑,再次承接的短线策略。", + direction="long", + 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": 18, - "breakout_distance_wait_pct": 18, - "gain_24h_wait_pct": 12, + "min_rr_buy_now": 1.5, + "max_wait_pullback_deviation_pct": 10, + "breakout_distance_wait_pct": 12, + "gain_24h_wait_pct": 18, }, paper_config={ - "entry_min_rr": 1.6, - "order_min_rr": 1.6, - "order_min_distance_to_entry_pct": 0, + "entry_min_rr": 1.5, + "order_min_rr": 1.5, + "order_min_distance_to_entry_pct": 1.5, "order_require_current_trigger": False, "dynamic_leverage_enabled": True, "dynamic_leverage_min": 3, }, ), - INTRADAY_MOMENTUM_15M_STRATEGY: StrategyDefinition( - strategy_code=INTRADAY_MOMENTUM_15M_STRATEGY, - strategy_name="15m日内动量延续", - description="短周期放量突破与1H背景共振的日内动量策略,只做当前触发,不做纯观察追高。", - mode="paper_only", - entry_gate_config={ - "min_entry_score_buy_now": 3, - "min_entry_score_wait_pullback": 2, - "min_rr_buy_now": 1.4, - "breakout_distance_wait_pct": 8, - "gain_24h_wait_pct": 8, - }, - paper_config={ - "entry_min_rr": 1.6, - "order_min_rr": 1.6, - "order_min_distance_to_entry_pct": 0, - "order_require_current_trigger": True, - "dynamic_leverage_enabled": True, - "dynamic_leverage_min": 3, - }, - ), - BREAKDOWN_RETEST_SHORT_1H_STRATEGY: StrategyDefinition( - strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY, - strategy_name="1H破位反抽做空", - description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。", + SHORT_BREAKDOWN_RETEST_STRATEGY: StrategyDefinition( + strategy_code=SHORT_BREAKDOWN_RETEST_STRATEGY, + strategy_name="空头破位反抽", + description="1H支撑或箱体下沿破位后反抽失败,叠加15m弱确认和相对弱势的空头短线策略。", direction="short", - mode="paper_only", + mode="paper_enabled", entry_gate_config={ "direction": "short", "min_entry_score_buy_now": 2, @@ -170,7 +106,18 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { def normalize_strategy_code(strategy_code: str | None) -> str: code = str(strategy_code or "").strip() - return code or MAIN_COMPOSITE_STRATEGY + 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, + "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, + "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 def strategy_definition(strategy_code: str | None) -> StrategyDefinition: diff --git a/app/db/migrations/0019_short_term_strategy_pool.sql b/app/db/migrations/0019_short_term_strategy_pool.sql new file mode 100644 index 0000000..030453a --- /dev/null +++ b/app/db/migrations/0019_short_term_strategy_pool.sql @@ -0,0 +1,26 @@ +INSERT INTO strategy_catalog ( + strategy_code, strategy_name, strategy_version, status, mode, description, config_json, created_at, updated_at +) VALUES + ('long_momentum_breakout_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), + ('short_breakdown_retest_1h_v1', '空头破位反抽', '', 'active', 'paper_enabled', '1H支撑或箱体下沿破位后反抽失败,叠加15m弱确认和相对弱势的空头短线策略。', '{}', 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', + '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' +); diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index dd93567..59df8ed 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -27,11 +27,24 @@ from app.core.trade_math import ( stop_loss_distance_pct as side_stop_loss_distance_pct, tighter_stop, ) -from app.core.strategy_registry import normalize_strategy_code, strategy_label, strategy_paper_config +from app.core.strategy_registry import ( + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, + SHORT_BREAKDOWN_RETEST_STRATEGY, + 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 +ACTIVE_PAPER_STRATEGIES = { + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, + SHORT_BREAKDOWN_RETEST_STRATEGY, +} + def _now() -> str: return datetime.now().isoformat() @@ -277,9 +290,29 @@ def _entry_plan(rec: dict) -> dict: return _loads_json(rec.get("entry_plan_json"), {}) -def _strategy_code_from_rec(rec: dict) -> str: +def _raw_strategy_code_from_rec(rec: dict) -> str: plan = _entry_plan(rec) - return normalize_strategy_code(rec.get("strategy_code") or plan.get("strategy_code")) + raw_code = str(rec.get("strategy_code") or plan.get("strategy_code") or "").strip() + if raw_code: + return raw_code + rec_id = _safe_int(rec.get("id")) + if rec_id <= 0: + return "" + try: + with get_conn() as conn: + row = conn.execute("SELECT strategy_code FROM recommendation WHERE id=%s", (rec_id,)).fetchone() + return str((row or {}).get("strategy_code") or "").strip() + except Exception: + return "" + + +def _is_active_paper_strategy_rec(rec: dict) -> bool: + raw_code = _raw_strategy_code_from_rec(rec) + return raw_code in ACTIVE_PAPER_STRATEGIES + + +def _strategy_code_from_rec(rec: dict) -> str: + return normalize_strategy_code(_raw_strategy_code_from_rec(rec)) def _paper_cfg_for_rec(rec: dict, config: dict | None = None) -> dict: @@ -1529,6 +1562,13 @@ def sync_recommendation(rec: dict, current_price: float, event_time: str = "") - current_price = _safe_float(current_price) if rec_id <= 0 or not symbol or current_price <= 0: return {"enabled": True, "skipped": True, "reason": "invalid_input"} + if not _is_active_paper_strategy_rec(rec): + return { + "enabled": True, + "skipped": True, + "reason": "legacy_strategy_disabled", + "strategy_code": _raw_strategy_code_from_rec(rec), + } execution_status = str(rec.get("execution_status") or "").strip() action_status = str(rec.get("action_status") or "").strip() event_time = event_time or _now() @@ -1712,6 +1752,26 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di "derivatives_context_json": item.get("derivatives_context_json"), "sector_context_json": item.get("sector_context_json"), } + if not _is_active_paper_strategy_rec(rec): + conn.execute( + """ + UPDATE paper_orders + SET status='canceled', + cancel_reason='legacy_strategy_disabled', + canceled_at=%s, + updated_at=%s + WHERE id=%s AND status='pending' + """, + (event_time, event_time, order.get("id")), + ) + results.append({ + "skipped": True, + "reason": "legacy_strategy_disabled", + "paper_order_id": order.get("id"), + "symbol": order.get("symbol"), + "strategy_code": _raw_strategy_code_from_rec(rec), + }) + continue if current_price <= 0: result = { "skipped": True, diff --git a/app/db/recommendation_commands.py b/app/db/recommendation_commands.py index 0f8d6ec..d641058 100644 --- a/app/db/recommendation_commands.py +++ b/app/db/recommendation_commands.py @@ -12,7 +12,11 @@ from app.core.opportunity_lifecycle import ( from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels from app.core.strategy_contract import signal_to_recommendation_context from app.core.trade_direction import direction_label, trade_side_from_payload -from app.core.strategy_registry import normalize_strategy_code +from app.core.strategy_registry import ( + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, + normalize_strategy_code, +) from app.db.recommendation_state import ( derive_minimal_state_fields, entry_window_policy, @@ -80,6 +84,9 @@ def create_recommendation( ): """Create or merge the current recommendation record for one symbol.""" entry_plan = dict(entry_plan or {}) + explicit_strategy_code = str(strategy_code or "").strip() + if not explicit_strategy_code and str(entry_plan.get("entry_action") or "").strip() in {"等回踩", "等待回踩"}: + strategy_code = LONG_SECOND_WAVE_PULLBACK_STRATEGY raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0 rec_score_pct = min(raw_pct, 100) strategy_context = signal_to_recommendation_context( @@ -137,7 +144,7 @@ def create_recommendation( UPDATE recommendation SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector), signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s, - strategy_code=COALESCE(NULLIF(%s, ''), NULLIF(strategy_code, ''), 'main_composite_v1'), + strategy_code=COALESCE(NULLIF(%s, ''), NULLIF(strategy_code, ''), %s), strategy_signal_id=CASE WHEN %s > 0 THEN %s ELSE COALESCE(strategy_signal_id, 0) END, strategy_snapshot_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(strategy_snapshot_json, '{}') END, factor_roles_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(factor_roles_json, '{}') END, @@ -170,6 +177,7 @@ def create_recommendation( direction, strategy_version, strategy_code, + LONG_MOMENTUM_BREAKOUT_STRATEGY, strategy_signal_id, strategy_signal_id, json.dumps(strategy_snapshot or {}, ensure_ascii=False), diff --git a/app/db/strategy_sample_cleanup.py b/app/db/strategy_sample_cleanup.py new file mode 100644 index 0000000..72c3294 --- /dev/null +++ b/app/db/strategy_sample_cleanup.py @@ -0,0 +1,152 @@ +"""One-time cleanup for retired strategy samples.""" + +from __future__ import annotations + +import os +import subprocess +from datetime import datetime +from pathlib import Path + +from app.db.postgres_connection import get_database_url +from app.db.schema import get_conn + +LEGACY_STRATEGY_CODES = ( + "main_composite_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", +) + +CONFIRM_TOKEN = "CLEAN_LEGACY_STRATEGY_SAMPLES" + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def backup_database() -> str: + backup_dir = _repo_root() / "data" / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + out_file = backup_dir / f"legacy_strategy_cleanup_before_{stamp}.dump" + subprocess.run( + ["pg_dump", get_database_url(), "--format=custom", "--file", str(out_file)], + check=True, + ) + return str(out_file) + + +def cleanup_legacy_strategy_samples(*, confirm: str, create_backup: bool = True) -> dict: + if confirm != CONFIRM_TOKEN: + raise ValueError(f"confirm must be {CONFIRM_TOKEN}") + + backup_path = backup_database() if create_backup else "" + conn = get_conn() + deleted = { + "paper_trade_events": 0, + "paper_orders": 0, + "paper_trades": 0, + "recommendation": 0, + "strategy_signals": 0, + } + try: + trade_ids = [ + int(row["id"]) + for row in conn.execute( + "SELECT id FROM paper_trades WHERE strategy_code = ANY(%s)", + (list(LEGACY_STRATEGY_CODES),), + ).fetchall() + ] + recommendation_ids = [ + int(row["id"]) + for row in conn.execute( + "SELECT id FROM recommendation WHERE strategy_code = ANY(%s)", + (list(LEGACY_STRATEGY_CODES),), + ).fetchall() + ] + + with conn.transaction(): + if trade_ids: + deleted["paper_trade_events"] += int( + conn.execute( + "SELECT COUNT(*) FROM paper_trade_events WHERE trade_id = ANY(%s)", + (trade_ids,), + ).fetchone()[0] + or 0 + ) + conn.execute("DELETE FROM paper_trade_events WHERE trade_id = ANY(%s)", (trade_ids,)) + if recommendation_ids: + deleted["paper_trade_events"] += int( + conn.execute( + "SELECT COUNT(*) FROM paper_trade_events WHERE recommendation_id = ANY(%s)", + (recommendation_ids,), + ).fetchone()[0] + or 0 + ) + conn.execute("DELETE FROM paper_trade_events WHERE recommendation_id = ANY(%s)", (recommendation_ids,)) + + deleted["paper_orders"] = int( + conn.execute( + "SELECT COUNT(*) FROM paper_orders WHERE recommendation_id = ANY(%s) OR strategy_code = ANY(%s)", + (recommendation_ids, list(LEGACY_STRATEGY_CODES)), + ).fetchone()[0] + or 0 + ) + conn.execute( + "DELETE FROM paper_orders WHERE recommendation_id = ANY(%s) OR strategy_code = ANY(%s)", + (recommendation_ids, list(LEGACY_STRATEGY_CODES)), + ) + else: + deleted["paper_orders"] = int( + conn.execute( + "SELECT COUNT(*) FROM paper_orders WHERE strategy_code = ANY(%s)", + (list(LEGACY_STRATEGY_CODES),), + ).fetchone()[0] + or 0 + ) + conn.execute("DELETE FROM paper_orders WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),)) + + deleted["paper_trades"] = int( + conn.execute( + "SELECT COUNT(*) FROM paper_trades WHERE strategy_code = ANY(%s)", + (list(LEGACY_STRATEGY_CODES),), + ).fetchone()[0] + or 0 + ) + conn.execute("DELETE FROM paper_trades WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),)) + + deleted["recommendation"] = int( + conn.execute( + "SELECT COUNT(*) FROM recommendation WHERE strategy_code = ANY(%s)", + (list(LEGACY_STRATEGY_CODES),), + ).fetchone()[0] + or 0 + ) + conn.execute("DELETE FROM recommendation WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),)) + + deleted["strategy_signals"] = int( + conn.execute( + "SELECT COUNT(*) FROM strategy_signals WHERE strategy_code = ANY(%s)", + (list(LEGACY_STRATEGY_CODES),), + ).fetchone()[0] + or 0 + ) + conn.execute("DELETE FROM strategy_signals WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + return { + "status": "completed", + "scope": "legacy-strategies", + "legacy_strategy_codes": list(LEGACY_STRATEGY_CODES), + "backup_path": backup_path, + "deleted": deleted, + "run_time": datetime.now().isoformat(), + } diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index 7a7835d..a2a1597 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -42,10 +42,9 @@ from app.config.config_loader import ( ) from app.core.opportunity_lifecycle import apply_entry_quality_gate from app.core.strategy_registry import ( - BOX_RETEST_1H_STRATEGY, - BOX_RETEST_4H_STRATEGY, BREAKDOWN_RETEST_SHORT_1H_STRATEGY, - MAIN_COMPOSITE_STRATEGY, + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, is_strategy_allowed_for_side, ) from app.core.trade_direction import direction_label, normalize_trade_side @@ -63,11 +62,9 @@ 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_intraday_momentum_15m_signal, - build_volume_ignition_1h_signal, + build_long_momentum_breakout_signal, + build_long_second_wave_pullback_signal, ) -from app.strategies.box_retest_4h import build_box_retest_1h_signal, build_box_retest_4h_signal from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h from app.config.config_loader import _get_section as _get_cfg_section from app.core.pa_engine import ( @@ -118,32 +115,9 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: ) else: signal_candidates.extend([ - build_volume_ignition_1h_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_intraday_momentum_15m_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), + 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 {}), ]) - if bp_1h.get("detected"): - signal_candidates.append( - build_box_retest_1h_signal( - symbol=symbol, - current_price=result.get("price") or 0, - detection=bp_1h, - entry_plan=entry_plan or {}, - market_regime=market_regime, - decision_log=result.get("decision_log") or {}, - ) - ) - if bp_4h.get("detected"): - signal_candidates.append( - build_box_retest_4h_signal( - symbol=symbol, - current_price=result.get("price") or 0, - detection=bp_4h, - entry_plan=entry_plan or {}, - market_regime=market_regime, - decision_log=result.get("decision_log") or {}, - ) - ) saved_payloads = [] for signal in [item for item in signal_candidates if item]: payload = signal.to_json_dict() if hasattr(signal, "to_json_dict") else dict(signal) @@ -1209,6 +1183,18 @@ def confirm_burst(symbol, cand): except Exception: cand_change_24h = 0.0 cand_signal_text = " ".join(str(x) for x in (json.loads(cand.get("signals", "[]")) if isinstance(cand.get("signals"), str) and cand.get("signals", "").strip().startswith("[") else [cand.get("signals", "")])) + cand_short_hint = ( + cand_detail.get("short_breakdown_retest_1h") + or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h") + or {} + ) + if trade_side != "short" and ( + bool(cand_short_hint.get("detected")) + or "破位反抽做空" in cand_signal_text + or "等待反抽失败" in cand_signal_text + or "breakdown_retest_1h_short" in cand_signal_text + ): + trade_side = "short" cand_is_top_gainer = bool(cand_detail.get("top_gainer_24h") or "24h强势榜" in cand_signal_text or cand_change_24h >= get_burst_threshold(symbol) * 1.5) h1_df = fetch_klines(symbol, "1h", limit=100) @@ -2038,11 +2024,14 @@ def confirm_burst(symbol, cand): entry_plan = short_entry_plan gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY else: - 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 + signal_text = " ".join(str(x or "") for x in signals) + has_second_wave_context = ( + "24h强势榜" in signal_text + or "回踩" in signal_text + or bp_1h.get("detected") + or bp_4h.get("detected") ) + 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) # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 @@ -2288,10 +2277,13 @@ def _result_brief(item: dict) -> dict: decision = item.get("decision_log") or ctx.get("decision_log") or {} signal_text = " ".join(str(x) for x in (item.get("signals") or [])) inferred_strategy = "" - if "1H箱体突破回踩" in signal_text: - inferred_strategy = "box_retest_1h_v1" - elif "4H箱体突破回踩" in signal_text: - inferred_strategy = "box_retest_4h_v1" + side = normalize_trade_side(item.get("side") or ctx.get("side") or item.get("direction")) + if side == "short" or any(key in signal_text for key in ("破位反抽做空", "等待反抽失败", "breakdown_retest_1h_short")): + inferred_strategy = BREAKDOWN_RETEST_SHORT_1H_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", "短周期", "量价齐飞", "放量")): + inferred_strategy = LONG_MOMENTUM_BREAKOUT_STRATEGY return { "symbol": item.get("symbol"), "confirmed": bool(item.get("confirmed")), diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index c662f7d..25eb7ee 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -219,6 +219,12 @@ def _kline_scan_config(): "enabled": bool(cfg.get("enabled", True)), "main_min_volume_usd": float(cfg.get("main_min_volume_usd", MIN_24H_VOLUME_USD) or 0), "bypass_min_volume_usd": float(cfg.get("bypass_min_volume_usd", 2_000_000) or 0), + "discovery_min_volume_usd": float(cfg.get("discovery_min_volume_usd", cfg.get("bypass_min_volume_usd", 2_000_000)) or 0), + "tier_a_budget": max(0, int(cfg.get("tier_a_budget", 90) or 0)), + "tier_b_budget": max(0, int(cfg.get("tier_b_budget", 90) or 0)), + "bypass_extra_budget": max(0, int(cfg.get("bypass_extra_budget", 50) or 0)), + "tier_a_min_score": float(cfg.get("tier_a_min_score", 45) or 0), + "tier_b_min_score": float(cfg.get("tier_b_min_score", 20) or 0), "short_tf_min_volume_usd": float(cfg.get("short_tf_min_volume_usd", 5_000_000) or 0), "short_tf_min_abs_change_pct": float(cfg.get("short_tf_min_abs_change_pct", 1.0) or 0), "short_tf_high_volume_usd": float(cfg.get("short_tf_high_volume_usd", 20_000_000) or 0), @@ -260,6 +266,123 @@ def _symbol_priority_score(symbol: str, info: dict, recently_screened: set) -> t ) +def _range_24h_pct(info: dict) -> float: + price = float((info or {}).get("price") or 0) + high = float((info or {}).get("high_24h") or 0) + low = float((info or {}).get("low_24h") or 0) + base = price or low + if base <= 0 or high <= 0 or low <= 0 or high < low: + return 0.0 + return (high - low) / base * 100 + + +def _discovery_priority_score(symbol: str, info: dict, recently_screened: set, cfg: dict) -> dict: + """Cheap all-market score used only to decide which symbols deserve K-line calls.""" + volume = float((info or {}).get("volume_24h") or 0) + change = float((info or {}).get("change_24h") or 0) + abs_change = abs(change) + range_pct = _range_24h_pct(info) + main_min = float(cfg.get("main_min_volume_usd") or 0) + high_volume = max(float(cfg.get("short_tf_high_volume_usd") or 0), main_min * 3) + top_gainer = _is_top_gainer_candidate( + symbol, + info, + min_volume=float(cfg.get("discovery_min_volume_usd") or 0), + ) + + score = 0.0 + reasons = [] + if top_gainer: + score += 40 + reasons.append("24h强势榜") + if symbol in recently_screened: + score += 12 + reasons.append("近期已关注") + if high_volume and volume >= high_volume: + score += 22 + reasons.append("高成交额") + elif main_min and volume >= main_min * 2: + score += 14 + reasons.append("成交额活跃") + elif main_min and volume >= main_min: + score += 8 + reasons.append("成交额达标") + if abs_change >= 12: + score += 26 + reasons.append("价格大幅波动") + elif abs_change >= 6: + score += 18 + reasons.append("价格波动扩大") + elif abs_change >= 2: + score += 8 + reasons.append("价格开始活跃") + if range_pct >= 18: + score += 18 + reasons.append("24h振幅很大") + elif range_pct >= 8: + score += 10 + reasons.append("24h振幅扩大") + + tier_a_min = float(cfg.get("tier_a_min_score") or 45) + tier_b_min = float(cfg.get("tier_b_min_score") or 20) + tier = "A" if score >= tier_a_min else "B" if score >= tier_b_min else "C" + return { + "symbol": symbol, + "score": round(score, 2), + "tier": tier, + "reasons": reasons or ["轻量观察"], + "volume_24h": volume, + "change_24h": change, + "range_24h_pct": round(range_pct, 2), + "top_gainer_24h": top_gainer, + } + + +def _build_discovery_priority_queue(tickers: dict, *, recently_screened: set, cfg: dict) -> list[dict]: + min_volume = float(cfg.get("discovery_min_volume_usd") or 0) + queue = [] + for symbol, info in (tickers or {}).items(): + volume = float((info or {}).get("volume_24h") or 0) + if min_volume and volume < min_volume and not _is_top_gainer_candidate(symbol, info): + continue + queue.append(_discovery_priority_score(symbol, info, recently_screened, cfg)) + queue.sort( + key=lambda item: ( + item.get("tier") == "A", + item.get("score", 0), + item.get("top_gainer_24h", False), + item.get("volume_24h", 0), + ), + reverse=True, + ) + return queue + + +def _select_discovery_tiers(queue: list[dict], *, tier_a_budget: int, tier_b_budget: int, extra_tiers=(), extra_budget: int = 0) -> list[str]: + selected = [] + selected_set = set() + + def add(items, budget): + if budget <= 0: + return + count = 0 + for item in items: + symbol = item.get("symbol") + if not symbol or symbol in selected_set: + continue + if count >= budget: + break + selected.append(symbol) + selected_set.add(symbol) + count += 1 + + add([item for item in queue if item.get("tier") == "A"], tier_a_budget) + add([item for item in queue if item.get("tier") == "B"], tier_b_budget) + if extra_tiers and extra_budget: + add([item for item in queue if item.get("tier") in set(extra_tiers)], extra_budget) + return selected + + def _rule_based_kline_scan_symbols(tickers: dict, *, recently_screened: set, min_volume: float = 0, emergency_max: int = 0) -> list[str]: """Select K-line scan universe by rules first; emergency_max is off by default.""" items = [] @@ -988,14 +1111,16 @@ def _static_bypass_resonance(cand, *, static_cfg, sector_signal_count=0, top_tra return list(dict.fromkeys(signals)) -def _attach_top_gainer_discovery(candidates, tickers, recently_screened): +def _attach_top_gainer_discovery(candidates, tickers, recently_screened, priority_context=None): """为强势榜补发现入口;追高风险留给细筛/确认处理。""" + priority_context = priority_context or {} added = 0 for symbol, info in tickers.items(): if symbol in candidates: if _is_top_gainer_candidate(symbol, info): candidates[symbol]["top_gainer_24h"] = True candidates[symbol]["top_gainer_chase_risk"] = symbol not in recently_screened + candidates[symbol].setdefault("discovery_priority", priority_context.get(symbol, {})) continue if not _is_top_gainer_candidate(symbol, info): continue @@ -1025,6 +1150,7 @@ def _attach_top_gainer_discovery(candidates, tickers, recently_screened): "top_gainer_24h": True, "top_gainer_chase_risk": symbol not in recently_screened, "bypass_origin": "top_gainer_24h", + "discovery_priority": priority_context.get(symbol, {}), } added += 1 return added @@ -1104,21 +1230,39 @@ def layer1_coarse_filter(): else {} ) cached_runtime_skip_count = 0 - main_scan_symbols = set(_rule_based_kline_scan_symbols( + discovery_queue = _build_discovery_priority_queue( tickers, recently_screened=recently_screened, - min_volume=main_min_vol, - emergency_max=scan_cfg["emergency_main_max_symbols"], - )) - bypass_scan_symbols = set(_rule_based_kline_scan_symbols( - tickers, - recently_screened=recently_screened, - min_volume=low_turnover_threshold, - emergency_max=scan_cfg["emergency_bypass_max_symbols"], - )) + cfg=scan_cfg, + ) + priority_context = {item["symbol"]: item for item in discovery_queue} + main_selected = _select_discovery_tiers( + discovery_queue, + tier_a_budget=scan_cfg["tier_a_budget"], + tier_b_budget=scan_cfg["tier_b_budget"], + ) + bypass_selected = _select_discovery_tiers( + discovery_queue, + tier_a_budget=scan_cfg["tier_a_budget"], + tier_b_budget=0, + extra_tiers=("B", "C"), + extra_budget=scan_cfg["bypass_extra_budget"], + ) + if scan_cfg["emergency_main_max_symbols"] > 0: + main_selected = main_selected[:scan_cfg["emergency_main_max_symbols"]] + if scan_cfg["emergency_bypass_max_symbols"] > 0: + bypass_selected = bypass_selected[:scan_cfg["emergency_bypass_max_symbols"]] + main_scan_symbols = set(main_selected) + bypass_scan_symbols = set(bypass_selected) + tier_counts = { + "A": sum(1 for item in discovery_queue if item.get("tier") == "A"), + "B": sum(1 for item in discovery_queue if item.get("tier") == "B"), + "C": sum(1 for item in discovery_queue if item.get("tier") == "C"), + } print( - f" K线扫描规则: 主扫描{len(main_scan_symbols)}/{len(tickers)}," - f"旁路扫描{len(bypass_scan_symbols)}/{len(tickers)},动态缓存{len(cached_runtime_exclusions)}" + f" K线扫描队列: A{tier_counts['A']} B{tier_counts['B']} C{tier_counts['C']};" + f"主扫描{len(main_scan_symbols)}/{len(tickers)},旁路扫描{len(bypass_scan_symbols)}/{len(tickers)}," + f"动态缓存{len(cached_runtime_exclusions)}" ) try: @@ -1337,6 +1481,7 @@ def layer1_coarse_filter(): "weighted_avg_price": round(weighted_avg_price, 6) if weighted_avg_price else 0, "top_gainer_24h": _is_top_gainer_candidate(symbol, info), "top_gainer_chase_risk": is_unseen_top_gainer, + "discovery_priority": priority_context.get(symbol, {}), } # ==== 第二遍扫描:低成交量静K蓄力旁路 + 底部抬高 + 压缩放量 ==== @@ -1420,6 +1565,7 @@ def layer1_coarse_filter(): "bypass_origin": True, "top_gainer_24h": _is_top_gainer_candidate(symbol, info), "top_gainer_chase_risk": is_unseen_top_gainer, + "discovery_priority": priority_context.get(symbol, {}), } bypass_count += 1 added = True @@ -1454,6 +1600,7 @@ def layer1_coarse_filter(): "bypass_origin": "higher_lows", "top_gainer_24h": _is_top_gainer_candidate(symbol, info), "top_gainer_chase_risk": is_unseen_top_gainer, + "discovery_priority": priority_context.get(symbol, {}), } hl_count_total += 1 added = True @@ -1488,11 +1635,12 @@ def layer1_coarse_filter(): "bypass_origin": "compression_surge", "top_gainer_24h": _is_top_gainer_candidate(symbol, info), "top_gainer_chase_risk": is_unseen_top_gainer, + "discovery_priority": priority_context.get(symbol, {}), } cs_count_total += 1 added = True - top_gainer_count = _attach_top_gainer_discovery(candidates, tickers, recently_screened) + top_gainer_count = _attach_top_gainer_discovery(candidates, tickers, recently_screened, priority_context) # 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计, # 但不让它们进入后续机会链路。 @@ -1580,6 +1728,7 @@ def layer1_coarse_filter(): "bypass_origin": cand.get("bypass_origin", ""), "source_types": discovery_source_types(cand), "signal_codes": build_signal_codes(signals), + "discovery_priority": cand.get("discovery_priority") or {}, }, ), ) @@ -1600,7 +1749,15 @@ def layer1_coarse_filter(): "kline_h4_success_count": len(h4_success_symbols), "coarse_candidate_count": len(candidates), "top_gainer_discovery_count": top_gainer_count, + "discovery_queue_count": len(discovery_queue), + "discovery_tier_a_count": tier_counts["A"], + "discovery_tier_b_count": tier_counts["B"], + "discovery_tier_c_count": tier_counts["C"], + "discovery_tier_a_budget": scan_cfg["tier_a_budget"], + "discovery_tier_b_budget": scan_cfg["tier_b_budget"], + "discovery_bypass_extra_budget": scan_cfg["bypass_extra_budget"], "main_kline_min_volume_usd": scan_cfg["main_min_volume_usd"], + "discovery_min_volume_usd": scan_cfg["discovery_min_volume_usd"], "bypass_kline_min_volume_usd": low_turnover_threshold, "emergency_main_kline_scan_budget": scan_cfg["emergency_main_max_symbols"], "emergency_bypass_kline_scan_budget": scan_cfg["emergency_bypass_max_symbols"], diff --git a/app/strategies/altcoin_breakout.py b/app/strategies/altcoin_breakout.py index be7c62f..446cd82 100644 --- a/app/strategies/altcoin_breakout.py +++ b/app/strategies/altcoin_breakout.py @@ -10,9 +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 ( - COMPRESSION_BREAKOUT_4H_STRATEGY, - INTRADAY_MOMENTUM_15M_STRATEGY, - VOLUME_IGNITION_1H_STRATEGY, + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, ) @@ -53,24 +52,25 @@ def _status_for_entry(result: dict, entry_plan: dict | None = None, *, require_c return "observe", reasons -def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: +def build_long_momentum_breakout_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: text = _signals_text(result) - has_vp = "量价齐飞" in text or ("连续" in text and "放量" in text) - has_breakout = "1H" in text and ("突破" in text or "起爆" in text) - if not (has_vp or has_breakout): + has_15m = any(key in text for key in ("15min", "15m", "短周期", "5m/15m")) + has_1h_participation = "量价齐飞" in text or ("连续" in text and "放量" in text) or ("1H" in text and ("突破" in text or "起爆" in text)) + if not (has_15m and has_1h_participation): return None - status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True) + 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 * 7 + (12 if _has_current_trigger(result) else 0))) + confidence = min(100.0, max(0.0, score * 7 + 18)) trigger = { - "factor_code": "vp_fly_1h_current" if has_vp else "ignition_1h_current", - "factor_label": "1H放量突破启动", + "factor_code": "momentum_breakout_15m_1h", + "factor_label": "15m突破 + 1H放量/波动增强", "has_current_trigger": _has_current_trigger(result), "trigger_status": _trigger_context(result).get("trigger_status") or "", "entry_action": (entry_plan or {}).get("entry_action") or "", + "opportunity_level": "intraday", } return StrategySignal( - strategy_code=VOLUME_IGNITION_1H_STRATEGY, + strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, strategy_version=current_strategy_version(), symbol=symbol, direction="long", @@ -79,8 +79,9 @@ def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: di score=score, trigger=trigger, factor_roles={ - "vp_fly_1h_current": TRIGGER, + "momentum_breakout_15m_1h": TRIGGER, "volume_consecutive_1h": CONFIRMATION, + "vp_fly_1h_current": CONFIRMATION, "breakout_15m_current": ENTRY, "pullback_15m_confirm": ENTRY, "false_breakout": RISK, @@ -88,24 +89,25 @@ def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: di }, entry_plan=entry_plan or {}, risk_plan={ - "invalid_if": ["放量后不能延续", "15m假突破", "跌回启动K低点", "RR不足"], + "invalid_if": ["15m跌回突破K低点", "1H放量后不能延续", "快速冲高回落", "RR不足"], "risk_reasons": reasons, }, - decision_log={"module": VOLUME_IGNITION_1H_STRATEGY, "decision": status, "reasons": reasons}, + decision_log={"module": LONG_MOMENTUM_BREAKOUT_STRATEGY, "decision": status, "reasons": reasons}, ) -def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: +def build_long_second_wave_pullback_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: text = _signals_text(result) - has_compression = any(key in text for key in ("静K", "压缩", "布林收窄", "底部抬高")) - has_breakout_context = any(key in text for key in ("突破", "起爆", "量价齐飞", "回踩")) - if not (has_compression and has_breakout_context): + market_context = (result or {}).get("market_context") or {} + has_first_wave = bool((result or {}).get("top_gainer_24h") or market_context.get("top_gainer_24h")) or "24h强势榜" in text or "量价齐飞" in text or "放量" in text + has_pullback_context = any(key in text for key in ("回踩", "箱体", "EMA", "前高", "底部抬高", "静K")) + if not (has_first_wave and has_pullback_context): 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 "底部" in text else 0) + (8 if _has_current_trigger(result) else 0))) + confidence = min(100.0, max(0.0, score * 6 + (12 if "24h强势榜" in text else 0) + (8 if _has_current_trigger(result) else 0))) return StrategySignal( - strategy_code=COMPRESSION_BREAKOUT_4H_STRATEGY, + strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY, strategy_version=current_strategy_version(), symbol=symbol, direction="long", @@ -113,64 +115,37 @@ def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_pla confidence=confidence, score=score, trigger={ - "factor_code": "compression_surge_4h", - "factor_label": "4H压缩蓄力突破", + "factor_code": "second_wave_pullback_1h", + "factor_label": "强势第一波后回踩承接", "has_current_trigger": _has_current_trigger(result), "trigger_status": _trigger_context(result).get("trigger_status") or "", + "opportunity_level": "swing_1_3d", }, factor_roles={ - "static_accum_4h": PREREQUISITE, - "higher_lows_4h": PREREQUISITE, - "compression_surge_4h": TRIGGER, - "ignition_4h_current": CONFIRMATION, + "cex_top_gainer": PREREQUISITE, "vp_fly_1h_current": CONFIRMATION, + "box_breakout_pullback_1h": CONFIRMATION, + "box_breakout_pullback_4h": CONFIRMATION, + "second_wave_pullback_1h": TRIGGER, "pullback_15m_confirm": ENTRY, "false_breakout": RISK, }, entry_plan=entry_plan or {}, risk_plan={ - "invalid_if": ["突破后跌回压缩区间", "回踩放量跌破", "无量反抽失败", "市场风险升高"], + "invalid_if": ["跌回第一波启动区", "回踩放量跌破", "高位追涨无承接", "RR不足"], "risk_reasons": reasons, }, - decision_log={"module": COMPRESSION_BREAKOUT_4H_STRATEGY, "decision": status, "reasons": reasons}, + decision_log={"module": LONG_SECOND_WAVE_PULLBACK_STRATEGY, "decision": status, "reasons": reasons}, ) +def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + return None + + +def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + return None + + def build_intraday_momentum_15m_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: - text = _signals_text(result) - has_short_tf = any(key in text for key in ("15min", "15m", "短周期", "5m/15m")) - if not has_short_tf: - return None - status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=True, allow_wait=False) - score = _safe_float(result.get("score")) - confidence = min(100.0, max(0.0, score * 6 + 18)) - return StrategySignal( - strategy_code=INTRADAY_MOMENTUM_15M_STRATEGY, - strategy_version=current_strategy_version(), - symbol=symbol, - direction="long", - status=status, - confidence=confidence, - score=score, - trigger={ - "factor_code": "short_tf_15m_ignition", - "factor_label": "15m日内动量延续", - "has_current_trigger": _has_current_trigger(result), - "trigger_status": _trigger_context(result).get("trigger_status") or "", - }, - factor_roles={ - "short_tf_5m_ignition": PREREQUISITE, - "short_tf_15m_ignition": TRIGGER, - "short_tf_resonance": CONFIRMATION, - "vp_fly_1h_current": CONFIRMATION, - "breakout_15m_current": ENTRY, - "false_breakout": RISK, - "trend_exhaustion": RISK, - }, - entry_plan=entry_plan or {}, - risk_plan={ - "invalid_if": ["15m跌回突破K", "短周期量能衰减", "快速冲高回落", "RR不足"], - "risk_reasons": reasons, - }, - decision_log={"module": INTRADAY_MOMENTUM_15M_STRATEGY, "decision": status, "reasons": reasons}, - ) + return None diff --git a/app/strategies/box_retest_4h.py b/app/strategies/box_retest_4h.py index 109ad36..ec30f11 100644 --- a/app/strategies/box_retest_4h.py +++ b/app/strategies/box_retest_4h.py @@ -1,171 +1,21 @@ -"""4H box breakout retest strategy candidate. +"""Retired box-retest strategy builders. -The detector factor is not the strategy. This module wraps that factor with -market, freshness, entry-distance and risk semantics to produce a standard -StrategySignal. +Box/retest detection remains available as a factor inside the new short-term +strategy pool, but these legacy builders no longer emit standalone signals. """ from __future__ import annotations -from app.core.factor_roles import ENTRY, RISK, TRIGGER -from app.core.strategy_contract import StrategySignal, current_strategy_version -from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, BOX_RETEST_4H_STRATEGY +from app.core.strategy_contract import StrategySignal -def _safe_float(value, default=0.0) -> float: - try: - if value is None or value == "": - return default - return float(value) - except Exception: - return default +def build_box_retest_4h_signal(**kwargs) -> StrategySignal | None: + return None -def _safe_int(value, default=999) -> int: - try: - if value is None or value == "": - return default - return int(value) - except Exception: - return default - - -def _build_box_retest_signal( - *, - symbol: str, - current_price: float, - detection: dict, - strategy_code: str, - timeframe_label: str, - factor_code: str, - factor_label: str, - max_fresh_age_bars: int, - max_chase_distance_pct: float, - entry_plan: dict | None = None, - market_regime: dict | None = None, - decision_log: dict | None = None, -) -> StrategySignal | None: - if not (detection or {}).get("detected"): - return None - entry_plan = entry_plan or {} - market_regime = market_regime or {} - risk_level = str(market_regime.get("risk_level") or "medium").lower() - entry_zone = _safe_float(detection.get("entry_zone")) - current_price = _safe_float(current_price) - distance_pct = (current_price / entry_zone - 1) * 100 if entry_zone > 0 and current_price > 0 else 0 - age = _safe_int(detection.get("pullback_age_bars")) - quality = str(detection.get("quality") or "") - status = "candidate" - reasons = [] - if risk_level == "critical": - status = "observe" - reasons.append("全局风险 critical,仅观察") - if age > max_fresh_age_bars: - status = "observe" - reasons.append(f"回踩已过去 {age} 根{timeframe_label},时效偏旧") - if quality not in {"良好", "优质"}: - status = "observe" - reasons.append(f"形态质量 {quality or '未知'},不直接交易") - if distance_pct > max_chase_distance_pct: - status = "observe" - reasons.append(f"当前价离箱体上沿 {distance_pct:.1f}%,禁止追高") - - score = _safe_float(detection.get("score")) - confidence = min(100.0, max(0.0, score * 8)) - trigger = { - "factor_code": factor_code, - "factor_label": factor_label, - "box_high": detection.get("box_high"), - "box_low": detection.get("box_low"), - "entry_zone": detection.get("entry_zone"), - "stop_level": detection.get("stop_level"), - "pullback_kind": detection.get("pullback_kind"), - "pullback_age_bars": age, - "quality": quality, - "distance_to_entry_zone_pct": round(distance_pct, 4), - "market_regime": market_regime.get("regime") or "", - "risk_level": risk_level, - } - risk_plan = { - "invalid_if": ["跌回箱体", "放量冲高回落", "回踩过久", "全局风险升为critical"], - "stop_level": detection.get("stop_level"), - "risk_reasons": reasons, - } - return StrategySignal( - strategy_code=strategy_code, - strategy_version=current_strategy_version(), - symbol=symbol, - direction="long", - status=status, - confidence=confidence, - score=score, - trigger=trigger, - factor_roles={ - factor_code: TRIGGER, - "pullback_15m_confirm": ENTRY, - "trend_exhaustion": RISK, - "false_breakout": RISK, - }, - entry_plan=entry_plan, - risk_plan=risk_plan, - decision_log=decision_log or { - "module": "box_retest_4h_v1", - "decision": status, - "reasons": reasons, - }, - ) - - -def build_box_retest_4h_signal( - *, - symbol: str, - current_price: float, - detection: dict, - entry_plan: dict | None = None, - market_regime: dict | None = None, - decision_log: dict | None = None, -) -> StrategySignal | None: - return _build_box_retest_signal( - symbol=symbol, - current_price=current_price, - detection=detection, - strategy_code=BOX_RETEST_4H_STRATEGY, - timeframe_label="4H", - factor_code="box_breakout_pullback_4h", - factor_label="4H箱体突破回踩", - max_fresh_age_bars=4, - max_chase_distance_pct=8, - entry_plan=entry_plan, - market_regime=market_regime, - decision_log=decision_log, - ) - - -def build_box_retest_1h_signal( - *, - symbol: str, - current_price: float, - detection: dict, - entry_plan: dict | None = None, - market_regime: dict | None = None, - decision_log: dict | None = None, -) -> StrategySignal | None: - return _build_box_retest_signal( - symbol=symbol, - current_price=current_price, - detection=detection, - strategy_code=BOX_RETEST_1H_STRATEGY, - timeframe_label="1H", - factor_code="box_breakout_pullback_1h", - factor_label="1H箱体突破回踩", - max_fresh_age_bars=6, - max_chase_distance_pct=6, - entry_plan=entry_plan, - market_regime=market_regime, - decision_log=decision_log, - ) +def build_box_retest_1h_signal(**kwargs) -> StrategySignal | None: + return None def build_box_retest_signal(**kwargs) -> StrategySignal | None: - """Backward-compatible alias for the original 4H strategy builder.""" - return build_box_retest_4h_signal(**kwargs) + return None diff --git a/rules.yaml b/rules.yaml index ed8528e..e9e0ac7 100644 --- a/rules.yaml +++ b/rules.yaml @@ -56,6 +56,12 @@ screener: enabled: true main_min_volume_usd: 5000000 bypass_min_volume_usd: 2000000 + discovery_min_volume_usd: 2000000 + tier_a_budget: 90 + tier_b_budget: 90 + bypass_extra_budget: 50 + tier_a_min_score: 45 + tier_b_min_score: 20 short_tf_min_volume_usd: 5000000 short_tf_min_abs_change_pct: 1.0 short_tf_high_volume_usd: 20000000 diff --git a/static/app.html b/static/app.html index baf8edc..be20d76 100644 --- a/static/app.html +++ b/static/app.html @@ -943,7 +943,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_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',breakdown_retest_short_1h_v1:'1H破位反抽做空',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[r.strategy_code||''] || r.strategy_code || ''); + 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 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); diff --git a/static/paper_trading.html b/static/paper_trading.html index 6578bb4..95b1569 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -228,7 +228,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_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续',breakdown_retest_short_1h_v1:'1H破位反抽做空'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))} +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)||'--'))} 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'+tradeQuery());openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML=''+esc(e.message)+''}} async function loadClosedTrades(){$('closedTradeRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+tradeQuery());renderTradeRows('closedTradeRows',d.items||[],'暂无已完结持仓')}catch(e){$('closedTradeRows').innerHTML=''+esc(e.message)+''}} async function loadCanceledOrders(){$('canceledOrderRows').innerHTML='加载中...';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=''+esc(e.message)+''}} diff --git a/tests/test_multi_strategy_infra.py b/tests/test_multi_strategy_infra.py index e1c63c9..8c574c5 100644 --- a/tests/test_multi_strategy_infra.py +++ b/tests/test_multi_strategy_infra.py @@ -2,14 +2,13 @@ from app.core.factor_roles import RISK, TRIGGER, factor_role, factor_roles_for_c from app.core.signal_direction import sanitize_factor_breakdown_for_side, sanitize_signals_for_side from app.core.strategy_contract import StrategySignal, default_main_composite_signal from app.core.strategy_registry import ( - BOX_RETEST_1H_STRATEGY, - BOX_RETEST_4H_STRATEGY, - COMPRESSION_BREAKOUT_4H_STRATEGY, BREAKDOWN_RETEST_SHORT_1H_STRATEGY, - INTRADAY_MOMENTUM_15M_STRATEGY, + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, MAIN_COMPOSITE_STRATEGY, - VOLUME_IGNITION_1H_STRATEGY, + SHORT_BREAKDOWN_RETEST_STRATEGY, is_strategy_allowed_for_side, + registered_strategy_codes, strategy_direction, strategy_label, ) @@ -18,6 +17,8 @@ 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_momentum_breakout_signal, + build_long_second_wave_pullback_signal, build_compression_breakout_4h_signal, build_intraday_momentum_15m_signal, build_volume_ignition_1h_signal, @@ -37,7 +38,7 @@ def test_factor_roles_never_promote_unknown_to_trigger(): } -def test_default_main_composite_strategy_signal_is_stable(): +def test_active_strategy_pool_only_contains_short_term_three_strategies(): signal = default_main_composite_signal( symbol="AAA/USDT", score=70, @@ -45,23 +46,26 @@ def test_default_main_composite_strategy_signal_is_stable(): entry_plan={"entry_action": "观察"}, ).to_json_dict() - assert signal["strategy_code"] == MAIN_COMPOSITE_STRATEGY - assert signal["strategy_name"] == "综合确认策略" + assert registered_strategy_codes() == [ + LONG_MOMENTUM_BREAKOUT_STRATEGY, + LONG_SECOND_WAVE_PULLBACK_STRATEGY, + SHORT_BREAKDOWN_RETEST_STRATEGY, + ] + assert signal["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY + assert signal["strategy_name"] == "多头动量启动" assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger" - assert strategy_label(BOX_RETEST_1H_STRATEGY) == "1H箱体突破回踩" - assert strategy_label(VOLUME_IGNITION_1H_STRATEGY) == "1H放量突破启动" - assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破" - assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续" - assert strategy_label(BREAKDOWN_RETEST_SHORT_1H_STRATEGY) == "1H破位反抽做空" - assert strategy_direction(VOLUME_IGNITION_1H_STRATEGY) == "long" + assert strategy_label(LONG_MOMENTUM_BREAKOUT_STRATEGY) == "多头动量启动" + assert strategy_label(LONG_SECOND_WAVE_PULLBACK_STRATEGY) == "多头二波回踩" + assert strategy_label(SHORT_BREAKDOWN_RETEST_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 True - assert is_strategy_allowed_for_side(VOLUME_IGNITION_1H_STRATEGY, "short") is False + assert is_strategy_allowed_for_side(MAIN_COMPOSITE_STRATEGY, "short") is False + assert is_strategy_allowed_for_side(LONG_MOMENTUM_BREAKOUT_STRATEGY, "short") is False assert is_strategy_allowed_for_side(BREAKDOWN_RETEST_SHORT_1H_STRATEGY, "short") is True -def test_volume_ignition_strategy_builds_independent_signal(): - signal = build_volume_ignition_1h_signal( +def test_long_momentum_breakout_strategy_builds_independent_signal(): + signal = build_long_momentum_breakout_signal( symbol="VOL/USDT", result={ "score": 8, @@ -73,10 +77,28 @@ def test_volume_ignition_strategy_builds_independent_signal(): ) payload = signal.to_json_dict() - assert payload["strategy_code"] == VOLUME_IGNITION_1H_STRATEGY + assert payload["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY assert payload["status"] == "candidate" - assert payload["trigger"]["factor_code"] == "vp_fly_1h_current" - assert payload["factor_roles"]["vp_fly_1h_current"] == "trigger" + assert payload["trigger"]["factor_code"] == "momentum_breakout_15m_1h" + assert payload["factor_roles"]["momentum_breakout_15m_1h"] == "trigger" + + +def test_legacy_strategy_builders_no_longer_emit_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 + assert build_intraday_momentum_15m_signal( + symbol="OLD/USDT", + result={"score": 9, "signals": ["15min强突破"], "trigger_context": {"current_triggers": ["15m"]}}, + entry_plan={"entry_action": "可即刻买入"}, + ) is None def test_long_strategy_cannot_emit_short_signal(): @@ -84,7 +106,7 @@ def test_long_strategy_cannot_emit_short_signal(): with pytest.raises(ValueError): StrategySignal( - strategy_code=VOLUME_IGNITION_1H_STRATEGY, + strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, symbol="BAD/USDT", direction="short", factor_roles={"vp_fly_1h_current": "trigger"}, @@ -136,35 +158,35 @@ def test_short_factor_breakdown_removes_long_only_positive_factors(): } -def test_compression_breakout_strategy_requires_structure_and_breakout_context(): - signal = build_compression_breakout_4h_signal( +def test_second_wave_strategy_requires_first_wave_and_pullback_context(): + signal = build_long_second_wave_pullback_signal( symbol="QUIET/USDT", - result={"score": 6, "signals": ["4H静K压缩,突破箱体上沿"], "entry_plan": {"entry_action": "等回踩"}}, + result={"score": 6, "signals": ["24h强势榜异动", "1H箱体突破回踩"], "entry_plan": {"entry_action": "等回踩"}}, entry_plan={"entry_action": "等回踩", "entry_price": 1.0}, ) payload = signal.to_json_dict() - assert payload["strategy_code"] == COMPRESSION_BREAKOUT_4H_STRATEGY + assert payload["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY assert payload["status"] == "candidate" - assert payload["factor_roles"]["compression_surge_4h"] == "trigger" - assert build_compression_breakout_4h_signal( + assert payload["factor_roles"]["second_wave_pullback_1h"] == "trigger" + assert build_long_second_wave_pullback_signal( symbol="NOBOX/USDT", result={"score": 6, "signals": ["1H量价齐飞"], "entry_plan": {"entry_action": "可即刻买入"}}, entry_plan={"entry_action": "可即刻买入"}, ) is None -def test_intraday_momentum_strategy_requires_current_trigger(): - stale = build_intraday_momentum_15m_signal( +def test_long_momentum_strategy_requires_current_trigger(): + stale = build_long_momentum_breakout_signal( symbol="FAST/USDT", - result={"score": 7, "signals": ["15m短周期启动"], "entry_plan": {"entry_action": "可即刻买入"}}, + result={"score": 7, "signals": ["15m短周期启动", "1H量价齐飞"], "entry_plan": {"entry_action": "可即刻买入"}}, entry_plan={"entry_action": "可即刻买入"}, ).to_json_dict() - fresh = build_intraday_momentum_15m_signal( + fresh = build_long_momentum_breakout_signal( symbol="FAST/USDT", result={ "score": 7, - "signals": ["15min强突破"], + "signals": ["15min强突破", "1H量价齐飞"], "trigger_context": {"current_triggers": ["15m突破"], "trigger_status": "current"}, "entry_plan": {"entry_action": "可即刻买入"}, }, @@ -263,12 +285,12 @@ def test_strategy_evaluation_recommends_promote_or_pause(): def test_strategy_signal_insert_and_recommendation_lineage(pg_conn): signal = insert_strategy_signal( StrategySignal( - strategy_code=BOX_RETEST_4H_STRATEGY, + strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY, symbol="BOX/USDT", score=10, confidence=80, - trigger={"factor_code": "box_breakout_pullback_4h"}, - factor_roles={"box_breakout_pullback_4h": "trigger"}, + trigger={"factor_code": "second_wave_pullback_1h"}, + factor_roles={"second_wave_pullback_1h": "trigger"}, entry_plan={"entry_action": "等回踩", "entry_price": 1.0}, ) ) @@ -289,9 +311,9 @@ def test_strategy_signal_insert_and_recommendation_lineage(pg_conn): ) row = pg_conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone() - assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY + assert row["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY assert row["strategy_signal_id"] == signal["strategy_signal_id"] - assert "box_breakout_pullback_4h" in row["factor_roles_json"] + assert "second_wave_pullback_1h" in row["factor_roles_json"] def test_box_retest_strategy_preserves_zero_age_as_fresh(): @@ -308,11 +330,7 @@ def test_box_retest_strategy_preserves_zero_age_as_fresh(): }, market_regime={"regime": "altcoin_rotation", "risk_level": "medium"}, ) - payload = signal.to_json_dict() - - assert payload["status"] == "candidate" - assert payload["trigger"]["pullback_age_bars"] == 0 - assert payload["risk_plan"]["risk_reasons"] == [] + assert signal is None def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn): @@ -327,15 +345,15 @@ def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn): "execution_status": "buy_now", "action_status": "可即刻买入", "strategy_version": "v-test", - "strategy_code": BOX_RETEST_4H_STRATEGY, + "strategy_code": LONG_SECOND_WAVE_PULLBACK_STRATEGY, "strategy_signal_id": 42, - "strategy_snapshot_json": '{"strategy_code":"box_retest_4h_v1"}', - "factor_roles_json": '{"box_breakout_pullback_4h":"trigger"}', + "strategy_snapshot_json": '{"strategy_code":"long_second_wave_pullback_1h_v1"}', + "factor_roles_json": '{"second_wave_pullback_1h":"trigger"}', "entry_plan": {"entry_action": "可即刻买入", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12}, } payload = _order_payload_from_rec(rec, 1.01, "2026-05-27T00:00:00", {"trade_notional_usdt": 100}) - assert payload["strategy_code"] == BOX_RETEST_4H_STRATEGY + assert payload["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY assert payload["strategy_signal_id"] == 42 result = _open_trade( @@ -362,7 +380,7 @@ def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn): row = pg_conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone() event = pg_conn.execute("SELECT * FROM paper_trade_events WHERE trade_id=%s", (result["trade_id"],)).fetchone() - assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY + assert row["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY assert row["strategy_signal_id"] == 42 - assert event["strategy_code"] == BOX_RETEST_4H_STRATEGY - assert strategy_label(row["strategy_code"]) == "4H箱体突破回踩" + assert event["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY + assert strategy_label(row["strategy_code"]) == "多头二波回踩" diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index feb7bf0..51d2ec4 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -5,7 +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.core.strategy_registry import LONG_MOMENTUM_BREAKOUT_STRATEGY, LONG_SECOND_WAVE_PULLBACK_STRATEGY from app.services.price_tracker import reconcile_buy_signals_after_gate @@ -48,7 +48,7 @@ def test_buy_now_with_bad_rr_sets_real_pullback_price(): assert action == '等回踩' assert plan['entry_price'] < 0.11455 - assert round(plan['entry_price'], 6) == 0.113199 + assert round(plan['entry_price'], 6) == 0.11251 assert plan['rr_target_entry'] == plan['entry_price'] assert any('现价不买' in r for r in reasons) @@ -88,29 +88,29 @@ def test_entry_gate_uses_strategy_specific_thresholds(): 'risk_reward_ok': True, 'rr1': 2.4, 'entry_trigger_confirmed': True, - 'score_components': {'opportunity_score': 12, 'entry_score': 1, 'risk_score': 1}, + 'score_components': {'opportunity_score': 12, 'entry_score': 2, 'risk_score': 1}, } - main_action, main_plan, _ = apply_entry_quality_gate( + momentum_action, momentum_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, + strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY, ) - box_action, box_plan, _ = apply_entry_quality_gate( + second_wave_action, second_wave_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, + strategy_code=LONG_SECOND_WAVE_PULLBACK_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 + assert momentum_action != '可即刻买入' + assert momentum_plan['entry_quality_gate']['strategy_code'] == LONG_MOMENTUM_BREAKOUT_STRATEGY + assert second_wave_action == '可即刻买入' + assert second_wave_plan['strategy_code'] == LONG_SECOND_WAVE_PULLBACK_STRATEGY def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk(): diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 0c216d3..e271fc1 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -18,6 +18,10 @@ from app.db.paper_trading import ( sync_recommendation, ) from app.db.recommendation_queries import get_opportunity_detail +from app.core.strategy_registry import ( + LONG_MOMENTUM_BREAKOUT_STRATEGY, + SHORT_BREAKDOWN_RETEST_STRATEGY, +) def _visible_card_text(card: dict) -> str: @@ -72,18 +76,18 @@ def buy_now_rec(monkeypatch): rec_state="爆发", rec_score=28, entry_price=100, - stop_loss=95, + stop_loss=96, tp1=106, tp2=112, signals=["当前15min即刻入场信号"], entry_plan={ "entry_action": "可即刻买入", "entry_price": 100, - "stop_loss": 95, + "stop_loss": 96, "tp1": 106, "tp2": 112, "risk_reward_ok": True, - "rr1": 1.2, + "rr1": 1.5, "entry_trigger_confirmed": True, }, ) @@ -992,7 +996,7 @@ def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_ ) VALUES ( 301, 'SNAPSHORT/USDT', '2026-05-16T10:00:00', '蓄力', 88, 105, 'active', 'wait_pullback', '等回踩', 'watch_pool', 0, - 95, 115, 122, 'volume_ignition_1h_v1', %s + 95, 115, 122, 'long_momentum_breakout_15m_1h_v1', %s ) """, (json.dumps({"side": "long", "entry_action": "等回踩", "entry_price": 105, "stop_loss": 95, "tp1": 115, "rr1": 2.0}, ensure_ascii=False),), @@ -1008,12 +1012,12 @@ def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_ ) VALUES ( 301, 'SNAPSHORT/USDT', 'short', 'limit', 'pending', 'wait_pullback', '等反抽', 105, 100, - 5000, 110, 95, 90, 'v-test', 'breakdown_retest_short_1h_v1', + 5000, 110, 95, 90, 'v-test', 'short_breakdown_retest_1h_v1', 0, %s, '{}', %s, '2026-05-16T10:00:00', '2026-05-16T10:00:00', '2026-05-17T10:00:00' ) """, ( - json.dumps({"strategy_code": "breakdown_retest_short_1h_v1"}, ensure_ascii=False), + json.dumps({"strategy_code": "short_breakdown_retest_1h_v1"}, ensure_ascii=False), json.dumps({"side": "short", "entry_action": "等反抽", "entry_price": 105, "stop_loss": 110, "tp1": 95, "rr1": 2.0}, ensure_ascii=False), ), ) @@ -1025,10 +1029,10 @@ def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_ assert result["filled_count"] == 1 assert captured assert all(x.get("side") == "short" for x in captured) - assert all(x.get("strategy_code") == "breakdown_retest_short_1h_v1" for x in captured) + assert all(x.get("strategy_code") == "short_breakdown_retest_1h_v1" for x in captured) trade = list_paper_trades()["items"][0] assert trade["side"] == "short" - assert trade["strategy_code"] == "breakdown_retest_short_1h_v1" + assert trade["strategy_code"] == "short_breakdown_retest_1h_v1" def test_wait_pullback_order_cancels_when_recommendation_invalid(monkeypatch): diff --git a/tests/test_recommendation_state_mainline.py b/tests/test_recommendation_state_mainline.py index 62b69a5..2b681c1 100644 --- a/tests/test_recommendation_state_mainline.py +++ b/tests/test_recommendation_state_mainline.py @@ -31,7 +31,7 @@ class RecommendationStateMainlineTests(unittest.TestCase): rec_score=27, entry_price=0.06557, stop_loss=0.061846, - tp1=0.071156, + tp1=0.072000, tp2=0.074881, sector='', signals=json.dumps(['15min 回踩确认'], ensure_ascii=False), @@ -54,7 +54,7 @@ class RecommendationStateMainlineTests(unittest.TestCase): 'risk_reward_ok': True, 'rr1': 1.5, 'stop_loss': 0.061846, - 'tp1': 0.071156, + 'tp1': 0.072000, 'entry_trigger_confirmed': True, }, ensure_ascii=False), action_status='等回踩', diff --git a/tests/test_review_center.py b/tests/test_review_center.py index 12cae4e..3e4f828 100644 --- a/tests/test_review_center.py +++ b/tests/test_review_center.py @@ -92,7 +92,7 @@ def test_review_center_strategy_counts_use_same_closed_trade_window(pg_conn): old_close = now - timedelta(days=40) old_open_recent_close = now - timedelta(days=20) outside_open = now - timedelta(days=60) - strategy_code = "volume_ignition_1h_v1" + strategy_code = "long_momentum_breakout_15m_1h_v1" pg_conn.execute( """ diff --git a/tests/test_screener_optimizations.py b/tests/test_screener_optimizations.py index 0583e1a..724a7e0 100644 --- a/tests/test_screener_optimizations.py +++ b/tests/test_screener_optimizations.py @@ -189,6 +189,52 @@ def test_kline_scan_selection_is_rule_based_without_default_count_cap(monkeypatc assert len(capped) == 2 +def test_discovery_priority_queue_promotes_top_gainers_before_kline_scan(monkeypatch): + monkeypatch.setattr(altcoin_screener, "get_burst_threshold", lambda symbol: 4) + monkeypatch.setattr(altcoin_screener, "is_meme_coin", lambda symbol: False) + cfg = { + "main_min_volume_usd": 5_000_000, + "discovery_min_volume_usd": 2_000_000, + "short_tf_high_volume_usd": 20_000_000, + "tier_a_min_score": 45, + "tier_b_min_score": 20, + } + tickers = { + "SLEEP/USDT": {"price": 1, "volume_24h": 30_000_000, "change_24h": 0.2, "high_24h": 1.01, "low_24h": 0.99}, + "MOVE/USDT": {"price": 1, "volume_24h": 3_000_000, "change_24h": 12, "high_24h": 1.16, "low_24h": 0.96}, + "LOW/USDT": {"price": 1, "volume_24h": 300_000, "change_24h": 50, "high_24h": 1.8, "low_24h": 0.9}, + } + + queue = altcoin_screener._build_discovery_priority_queue(tickers, recently_screened=set(), cfg=cfg) + by_symbol = {item["symbol"]: item for item in queue} + + assert by_symbol["MOVE/USDT"]["tier"] == "A" + assert by_symbol["SLEEP/USDT"]["tier"] in {"B", "A"} + assert "LOW/USDT" not in by_symbol + + +def test_discovery_tier_selection_respects_a_b_budgets(): + queue = [ + {"symbol": "A1/USDT", "tier": "A", "score": 90}, + {"symbol": "A2/USDT", "tier": "A", "score": 80}, + {"symbol": "B1/USDT", "tier": "B", "score": 30}, + {"symbol": "B2/USDT", "tier": "B", "score": 25}, + {"symbol": "C1/USDT", "tier": "C", "score": 5}, + ] + + selected = altcoin_screener._select_discovery_tiers(queue, tier_a_budget=1, tier_b_budget=1) + bypass = altcoin_screener._select_discovery_tiers( + queue, + tier_a_budget=1, + tier_b_budget=0, + extra_tiers=("B", "C"), + extra_budget=2, + ) + + assert selected == ["A1/USDT", "B1/USDT"] + assert bypass == ["A1/USDT", "B1/USDT", "B2/USDT"] + + def _mock_weights(): return { "量价齐飞": 5, diff --git a/tests/test_strategy_sample_cleanup.py b/tests/test_strategy_sample_cleanup.py new file mode 100644 index 0000000..8253be0 --- /dev/null +++ b/tests/test_strategy_sample_cleanup.py @@ -0,0 +1,68 @@ +import json + +from app.db.strategy_sample_cleanup import CONFIRM_TOKEN, cleanup_legacy_strategy_samples + + +def test_cleanup_legacy_strategy_samples_removes_only_retired_strategy_data(pg_conn): + pg_conn.execute( + """ + INSERT INTO recommendation ( + id, symbol, rec_time, rec_state, rec_score, entry_price, + status, execution_status, action_status, display_bucket, entry_triggered, + strategy_code, entry_plan_json + ) VALUES + (9001, 'OLD/USDT', '2026-06-04T00:00:00', '爆发', 50, 1, + 'active', 'buy_now', '可即刻买入', 'realtime', 1, 'volume_ignition_1h_v1', '{}'), + (9002, 'NEW/USDT', '2026-06-04T00:00:00', '爆发', 50, 1, + 'active', 'buy_now', '可即刻买入', 'realtime', 1, 'long_momentum_breakout_15m_1h_v1', '{}') + """ + ) + pg_conn.execute( + """ + INSERT INTO strategy_signals ( + id, strategy_code, symbol, direction, signal_status, created_at + ) VALUES + (9101, 'volume_ignition_1h_v1', 'OLD/USDT', 'long', 'candidate', '2026-06-04T00:00:00'), + (9102, 'long_momentum_breakout_15m_1h_v1', 'NEW/USDT', 'long', 'candidate', '2026-06-04T00:00:00') + """ + ) + pg_conn.execute( + """ + INSERT INTO paper_orders ( + id, recommendation_id, symbol, side, order_type, status, target_price, + current_price_at_create, notional_usdt, strategy_code, created_at, updated_at, expires_at + ) VALUES + (9201, 9001, 'OLD/USDT', 'long', 'limit', 'pending', 1, 1, 5000, 'volume_ignition_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00', '2026-06-05T00:00:00'), + (9202, 9002, 'NEW/USDT', 'long', 'limit', 'pending', 1, 1, 5000, 'long_momentum_breakout_15m_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00', '2026-06-05T00:00:00') + """ + ) + pg_conn.execute( + """ + INSERT INTO paper_trades ( + id, recommendation_id, symbol, side, status, opened_at, entry_price, + qty, notional_usdt, strategy_code, created_at, updated_at + ) VALUES + (9301, 9001, 'OLD/USDT', 'long', 'open', '2026-06-04T00:00:00', 1, 1, 5000, 'volume_ignition_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00'), + (9302, 9002, 'NEW/USDT', 'long', 'open', '2026-06-04T00:00:00', 1, 1, 5000, 'long_momentum_breakout_15m_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00') + """ + ) + pg_conn.execute( + """ + INSERT INTO paper_trade_events ( + id, trade_id, recommendation_id, symbol, event_type, event_time, detail_json, strategy_code + ) VALUES + (9401, 9301, 9001, 'OLD/USDT', 'open', '2026-06-04T00:00:00', '{}', 'volume_ignition_1h_v1'), + (9402, 9302, 9002, 'NEW/USDT', 'open', '2026-06-04T00:00:00', '{}', 'long_momentum_breakout_15m_1h_v1') + """ + ) + pg_conn.commit() + + result = cleanup_legacy_strategy_samples(confirm=CONFIRM_TOKEN, create_backup=False) + + assert result["deleted"]["recommendation"] == 1 + assert result["deleted"]["strategy_signals"] == 1 + assert result["deleted"]["paper_orders"] == 1 + assert result["deleted"]["paper_trades"] == 1 + assert result["deleted"]["paper_trade_events"] >= 1 + assert pg_conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='OLD/USDT'").fetchone()[0] == 0 + assert pg_conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='NEW/USDT'").fetchone()[0] == 1