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='