From a292f7bc580bad64ef2f31de4ca25ae42a94de13 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Thu, 15 Oct 2026 12:27:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(migrate):=20=E5=86=85=E7=BD=AE=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E6=8C=89=20ID=20=E7=A7=8D=E5=85=A5=20custom=20?= =?UTF-8?q?=E2=80=94=20=E4=BF=AE=E5=A4=8D=E5=8D=87=E7=BA=A7=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=2025=20=E4=B8=AA=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动时按文件名补缺, 不覆盖已有, 标记防复活; 桌面打包 datas 落盘, 前端迁移 toast 同步提示。 --- backend/app/main.py | 12 ++ .../app/strategy/seeds/active_limit_gene.py | 88 +++++++++++++++ backend/app/strategy/seeds/boll_breakout.py | 75 +++++++++++++ .../strategy/seeds/breakout_new_high_60d.py | 83 ++++++++++++++ .../strategy/seeds/broken_board_recovery.py | 91 +++++++++++++++ .../app/strategy/seeds/bullish_alignment.py | 81 ++++++++++++++ .../strategy/seeds/consecutive_limit_ups.py | 64 +++++++++++ .../app/strategy/seeds/high_turnover_surge.py | 88 +++++++++++++++ .../app/strategy/seeds/limit_up_momentum.py | 75 +++++++++++++ .../seeds/long_lower_shadow_reversal.py | 87 +++++++++++++++ .../strategy/seeds/low_volatility_leader.py | 88 +++++++++++++++ .../strategy/seeds/ma_convergence_breakout.py | 105 ++++++++++++++++++ backend/app/strategy/seeds/ma_golden_cross.py | 87 +++++++++++++++ .../strategy/seeds/macd_below_zero_revival.py | 89 +++++++++++++++ backend/app/strategy/seeds/macd_golden.py | 99 +++++++++++++++++ .../app/strategy/seeds/n_day_low_reversal.py | 80 +++++++++++++ backend/app/strategy/seeds/near_limit_up.py | 93 ++++++++++++++++ backend/app/strategy/seeds/oversold_bounce.py | 88 +++++++++++++++ .../app/strategy/seeds/oversold_reversal.py | 93 ++++++++++++++++ .../seeds/platform_consolidation_breakout.py | 99 +++++++++++++++++ .../strategy/seeds/pullback_ma20_bounce.py | 90 +++++++++++++++ .../app/strategy/seeds/pullback_to_support.py | 105 ++++++++++++++++++ .../strategy/seeds/rsi_midline_pullback.py | 98 ++++++++++++++++ backend/app/strategy/seeds/strong_open.py | 95 ++++++++++++++++ backend/app/strategy/seeds/trend_breakout.py | 93 ++++++++++++++++ .../app/strategy/seeds/volume_price_surge.py | 81 ++++++++++++++ backend/app/strategy/strategy_seeds.py | 54 +++++++++ backend/tests/test_strategy_seeds.py | 58 ++++++++++ frontend/src/lib/api.ts | 6 +- frontend/src/pages/Screener.tsx | 5 +- packaging/tsp.spec | 3 + 31 files changed, 2349 insertions(+), 4 deletions(-) create mode 100644 backend/app/strategy/seeds/active_limit_gene.py create mode 100644 backend/app/strategy/seeds/boll_breakout.py create mode 100644 backend/app/strategy/seeds/breakout_new_high_60d.py create mode 100644 backend/app/strategy/seeds/broken_board_recovery.py create mode 100644 backend/app/strategy/seeds/bullish_alignment.py create mode 100644 backend/app/strategy/seeds/consecutive_limit_ups.py create mode 100644 backend/app/strategy/seeds/high_turnover_surge.py create mode 100644 backend/app/strategy/seeds/limit_up_momentum.py create mode 100644 backend/app/strategy/seeds/long_lower_shadow_reversal.py create mode 100644 backend/app/strategy/seeds/low_volatility_leader.py create mode 100644 backend/app/strategy/seeds/ma_convergence_breakout.py create mode 100644 backend/app/strategy/seeds/ma_golden_cross.py create mode 100644 backend/app/strategy/seeds/macd_below_zero_revival.py create mode 100644 backend/app/strategy/seeds/macd_golden.py create mode 100644 backend/app/strategy/seeds/n_day_low_reversal.py create mode 100644 backend/app/strategy/seeds/near_limit_up.py create mode 100644 backend/app/strategy/seeds/oversold_bounce.py create mode 100644 backend/app/strategy/seeds/oversold_reversal.py create mode 100644 backend/app/strategy/seeds/platform_consolidation_breakout.py create mode 100644 backend/app/strategy/seeds/pullback_ma20_bounce.py create mode 100644 backend/app/strategy/seeds/pullback_to_support.py create mode 100644 backend/app/strategy/seeds/rsi_midline_pullback.py create mode 100644 backend/app/strategy/seeds/strong_open.py create mode 100644 backend/app/strategy/seeds/trend_breakout.py create mode 100644 backend/app/strategy/seeds/volume_price_surge.py create mode 100644 backend/app/strategy/strategy_seeds.py create mode 100644 backend/tests/test_strategy_seeds.py diff --git a/backend/app/main.py b/backend/app/main.py index 3abd0d28..926d7f66 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -278,6 +278,15 @@ async def _application_lifespan(app: FastAPI): _screener_svc = ScreenerService(repo) _etf_screener_svc = ScreenerService(repo, asset_type="etf") + # 去内置化补全: 原内置策略按 ID 种入 custom (一次性, 已自行迁移的不覆盖)。 + # 必须在 StrategyEngine 创建前完成, 否则首载拿不到刚种入的文件。 + _seeded_strategies = 0 + try: + from app.strategy.strategy_seeds import seed_missing_builtins + + _seeded_strategies = seed_missing_builtins(store.data_dir) + except Exception as e: # noqa: BLE001 + logger.warning("strategy seeding failed (ignored): %s", e) strategy_dirs = [ Path(__file__).resolve().parent / "strategy" / "research", store.data_dir / "strategies" / "custom", @@ -298,6 +307,9 @@ async def _application_lifespan(app: FastAPI): if _seeded_signals: legacy_migration["seeded_signals"] = _seeded_signals logger.info("legacy migrate: seeded %d default signals", _seeded_signals) + if _seeded_strategies: + legacy_migration["seeded_strategies"] = _seeded_strategies + logger.info("legacy migrate: seeded %d builtin strategies", _seeded_strategies) archived = archive_unloadable_strategies(strategy_engine.load_errors()) if archived: strategy_engine.reload() diff --git a/backend/app/strategy/seeds/active_limit_gene.py b/backend/app/strategy/seeds/active_limit_gene.py new file mode 100644 index 00000000..004e85f7 --- /dev/null +++ b/backend/app/strategy/seeds/active_limit_gene.py @@ -0,0 +1,88 @@ +"""涨停基因活跃股 — 近期多次涨停的活跃标的池 (股性筛选, 非追板)""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) + +META = { + "id": "active_limit_gene", + "name": "涨停基因活跃股", + "description": "近60日涨停次数达标的活跃标的, 且当日未涨停、缩量休整 — 股性筛选池", + "tags": ["涨停", "活跃", "股性"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + { + "id": "min_limit_count", + "label": "近60日最少涨停次数", + "type": "int", + "default": 3, + "min": 2, + "max": 10, + "step": 1, + }, + { + "id": "vol_ratio_max", + "label": "当日量比上限 (休整)", + "type": "float", + "default": 1.2, + "min": 0.5, + "max": 3.0, + "step": 0.1, + }, + { + "id": "max_change_pct", + "label": "当日涨幅上限% (排除涨停)", + "type": "float", + "default": 7.0, + "min": 3.0, + "max": 15.0, + "step": 0.5, + }, + ], + "scoring": {"limit_up_count_60d": 0.4, "momentum_20d": 0.3, "turnover_ratio_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_active_limit_gene"] +EXIT_SIGNALS = ["signal_active_gene_cool"] +STOP_LOSS = -0.08 +MAX_HOLD_DAYS = 25 + + +class ActiveLimitGeneMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 70 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = matrix_feature(market, "limit_up_count_60d") >= int(params.get("min_limit_count", 3)) + # 当日未涨停 (休整日而非情绪顶点) 且量能收敛 + entry &= matrix_feature(market, "change_pct") < float(params.get("max_change_pct", 7.0)) / 100.0 + entry &= matrix_feature(market, "vol_ratio_5d") <= float(params.get("vol_ratio_max", 1.2)) + + # 出场: 股性冷却 (60日涨停计数回落到阈值下) + exit_ = matrix_feature(market, "limit_up_count_60d") < int(params.get("min_limit_count", 3)) - 1 + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_active_limit_gene",), + exit_signal_ids=("signal_active_gene_cool",), + ) + + +MATRIX_STRATEGY = ActiveLimitGeneMatrixStrategy() diff --git a/backend/app/strategy/seeds/boll_breakout.py b/backend/app/strategy/seeds/boll_breakout.py new file mode 100644 index 00000000..94c53365 --- /dev/null +++ b/backend/app/strategy/seeds/boll_breakout.py @@ -0,0 +1,75 @@ +"""布林突破 — 突破布林上轨 + 放量""" + +import numpy as np + +from app.backtest.matrix import MarketDataMatrix, SignalMatrix, make_signal_matrix, matrix_feature + +META = { + "id": "boll_breakout", + "name": "布林突破", + "description": "突破布林上轨 + 放量, 强势加速信号", + "tags": ["布林", "突破"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + { + "id": "require_boll_breakout", + "label": "要求突破布林上轨", + "type": "bool", + "default": True, + }, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.5, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + ], + "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_boll_breakout_upper"] +EXIT_SIGNALS = ["signal_boll_breakdown_lower"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class BollBreakoutMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + upper = matrix_feature(market, "boll_upper") + lower = matrix_feature(market, "boll_lower") + entry = np.ones(market.shape, dtype=bool) + if params.get("require_boll_breakout", True): + entry &= market.close > upper + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 1.5) + ) + exit_ = market.close < lower + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_boll_breakout_upper",), + exit_signal_ids=("signal_boll_breakdown_lower",), + ) + + +MATRIX_STRATEGY = BollBreakoutMatrixStrategy() diff --git a/backend/app/strategy/seeds/breakout_new_high_60d.py b/backend/app/strategy/seeds/breakout_new_high_60d.py new file mode 100644 index 00000000..679abb78 --- /dev/null +++ b/backend/app/strategy/seeds/breakout_new_high_60d.py @@ -0,0 +1,83 @@ +"""放量创60日新高 — 突破前期高点平台 + 量能确认""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "breakout_new_high_60d", + "name": "放量创60日新高", + "description": "收盘突破前60日最高价, 量比配合, 趋势强度确认", + "tags": ["突破", "新高"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "require_volume", "label": "要求量比配合", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.3, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + { + "id": "min_change_pct", + "label": "最低当日涨幅%", + "type": "float", + "default": 2.0, + "min": 0.0, + "max": 10.0, + "step": 0.5, + }, + ], + "scoring": {"momentum_20d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_breakout_high_60d"] +EXIT_SIGNALS = ["signal_break_ma20_lose"] +STOP_LOSS = -0.07 +MAX_HOLD_DAYS = 20 + + +class BreakoutNewHigh60dMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 70 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + prior_high = shift(matrix_feature(market, "high_60d"), 1) # 昨日及以前的60日高点 + entry = market.close > prior_high + entry &= matrix_feature(market, "change_pct") >= float(params.get("min_change_pct", 2.0)) / 100.0 + if params.get("require_volume", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float(params.get("vol_ratio_min", 1.3)) + ma20 = matrix_feature(market, "ma20") + exit_ = market.close < ma20 + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_breakout_high_60d",), + exit_signal_ids=("signal_break_ma20_lose",), + ) + + +MATRIX_STRATEGY = BreakoutNewHigh60dMatrixStrategy() diff --git a/backend/app/strategy/seeds/broken_board_recovery.py b/backend/app/strategy/seeds/broken_board_recovery.py new file mode 100644 index 00000000..d71170cc --- /dev/null +++ b/backend/app/strategy/seeds/broken_board_recovery.py @@ -0,0 +1,91 @@ +"""断板反包 — 涨停 + 放量 + 涨幅 >3%""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "broken_board_recovery", + "name": "断板反包", + "description": "连板≥2后断板1-2天, 出现放量反包信号", + "tags": ["涨停", "反包"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "require_limit_up", "label": "要求当日涨停", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.5, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + {"id": "use_change_filter", "label": "启用涨幅过滤", "type": "bool", "default": True}, + { + "id": "change_pct_min", + "label": "最低涨幅", + "type": "float", + "default": 0.03, + "min": 0.01, + "max": 0.10, + "step": 0.01, + }, + ], + "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_limit_up"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 10 + + +class BrokenBoardRecoveryMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume", "raw_close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("require_limit_up", True): + entry &= market.limit_up_locked.astype(bool) + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 1.5) + ) + if params.get("use_change_filter", True): + entry &= matrix_feature(market, "change_pct") > float( + params.get("change_pct_min", 0.03) + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_limit_up",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = BrokenBoardRecoveryMatrixStrategy() diff --git a/backend/app/strategy/seeds/bullish_alignment.py b/backend/app/strategy/seeds/bullish_alignment.py new file mode 100644 index 00000000..2bf70b57 --- /dev/null +++ b/backend/app/strategy/seeds/bullish_alignment.py @@ -0,0 +1,81 @@ +"""均线多头 — MA5>MA10>MA20>MA60 + 短期动量为正""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "bullish_alignment", + "name": "均线多头", + "description": "MA5>MA10>MA20>MA60多头排列 + 短期动量为正", + "tags": ["均线", "多头"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + { + "id": "require_ma_alignment", + "label": "要求均线多头排列", + "type": "bool", + "default": True, + }, + { + "id": "require_positive_momentum", + "label": "要求20日动量为正", + "type": "bool", + "default": True, + }, + ], + "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma_golden_5_20", "signal_ma_golden_20_60"] +EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 20 + + +class BullishAlignmentMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma5 = matrix_feature(market, "ma5") + ma10 = matrix_feature(market, "ma10") + ma20 = matrix_feature(market, "ma20") + ma60 = matrix_feature(market, "ma60") + entry = np.ones(market.shape, dtype=bool) + if params.get("require_ma_alignment", True): + entry &= (ma5 > ma10) & (ma10 > ma20) & (ma20 > ma60) + if params.get("require_positive_momentum", True): + entry &= matrix_feature(market, "momentum_20d") > 0 + ma_dead = (ma5 < ma20) & (shift(ma5, 1) >= shift(ma20, 1)) + ma20_breakdown = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + exit_ = ma_dead | ma20_breakdown + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(ma_dead, 0, np.where(ma20_breakdown, 1, -1)).astype(np.int16), + entry_signal_ids=("signal_ma_golden_5_20", "signal_ma_golden_20_60"), + exit_signal_ids=("signal_ma_dead_5_20", "signal_ma20_breakdown"), + ) + + +MATRIX_STRATEGY = BullishAlignmentMatrixStrategy() diff --git a/backend/app/strategy/seeds/consecutive_limit_ups.py b/backend/app/strategy/seeds/consecutive_limit_ups.py new file mode 100644 index 00000000..c8c9bfa9 --- /dev/null +++ b/backend/app/strategy/seeds/consecutive_limit_ups.py @@ -0,0 +1,64 @@ +"""连板股 — 涨停且连续涨停≥2天""" + +import numpy as np + +from app.backtest.matrix import MarketDataMatrix, SignalMatrix, make_signal_matrix, matrix_feature + +META = { + "id": "consecutive_limit_ups", + "name": "连板股", + "description": "当日涨停且连续涨停≥2天, 强势追涨", + "tags": ["涨停", "连板"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "require_limit_up", "label": "要求当日涨停", "type": "bool", "default": True}, + {"id": "use_boards_filter", "label": "启用连板数过滤", "type": "bool", "default": True}, + { + "id": "min_boards", + "label": "最少连板数", + "type": "int", + "default": 2, + "min": 1, + "max": 20, + "step": 1, + }, + ], + "scoring": {"consecutive_limit_ups": 0.5, "change_pct": 0.3, "amount": 0.2}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_limit_up"] +EXIT_SIGNALS = [] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 5 + + +class ConsecutiveLimitUpsMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"consecutive_limit_ups", "raw_close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("require_limit_up", True): + entry &= market.limit_up_locked.astype(bool) + if params.get("use_boards_filter", True): + entry &= matrix_feature(market, "consecutive_limit_ups") >= int( + params.get("min_boards", 2) + ) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + entry_signal_ids=("signal_limit_up",), + ) + + +MATRIX_STRATEGY = ConsecutiveLimitUpsMatrixStrategy() diff --git a/backend/app/strategy/seeds/high_turnover_surge.py b/backend/app/strategy/seeds/high_turnover_surge.py new file mode 100644 index 00000000..9b42369b --- /dev/null +++ b/backend/app/strategy/seeds/high_turnover_surge.py @@ -0,0 +1,88 @@ +"""高换手拉升 — 换手率 > 5% 且涨幅 > 3%, 资金活跃""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "high_turnover_surge", + "name": "高换手拉升", + "description": "换手率 > 5% 且涨幅 > 3%, 资金活跃", + "tags": ["换手率", "放量", "资金"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "use_turnover_filter", "label": "启用换手率过滤", "type": "bool", "default": True}, + { + "id": "min_turnover", + "label": "最低换手率%", + "type": "float", + "default": 5.0, + "min": 1.0, + "max": 20.0, + "step": 0.5, + }, + {"id": "use_change_filter", "label": "启用涨幅过滤", "type": "bool", "default": True}, + { + "id": "min_change", + "label": "最低涨幅%", + "type": "float", + "default": 3.0, + "min": 1.0, + "max": 10.0, + "step": 0.5, + }, + ], + "scoring": {"turnover_rate": 0.4, "change_pct": 0.3, "momentum_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_volume_surge"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 + + +class HighTurnoverSurgeMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "turnover_rate"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("use_turnover_filter", True): + entry &= matrix_feature(market, "turnover_rate") > float( + params.get("min_turnover", 5.0) + ) + if params.get("use_change_filter", True): + entry &= ( + matrix_feature(market, "change_pct") > float(params.get("min_change", 3.0)) / 100.0 + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_volume_surge",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = HighTurnoverSurgeMatrixStrategy() diff --git a/backend/app/strategy/seeds/limit_up_momentum.py b/backend/app/strategy/seeds/limit_up_momentum.py new file mode 100644 index 00000000..1cb7de23 --- /dev/null +++ b/backend/app/strategy/seeds/limit_up_momentum.py @@ -0,0 +1,75 @@ +"""连板接力 — 近2日涨停且今日涨幅 > 5%, 连板股追踪""" + +import numpy as np + +from app.backtest.matrix import MarketDataMatrix, SignalMatrix, make_signal_matrix, matrix_feature + +META = { + "id": "limit_up_momentum", + "name": "连板接力", + "description": "连板股 + 今日涨幅 > 5%, 连板接力追踪", + "tags": ["涨停", "连板", "接力"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "use_change_filter", "label": "启用涨幅过滤", "type": "bool", "default": True}, + { + "id": "min_change", + "label": "最低涨幅%", + "type": "float", + "default": 5.0, + "min": 2.0, + "max": 15.0, + "step": 0.5, + }, + {"id": "use_boards_filter", "label": "启用连板数过滤", "type": "bool", "default": True}, + { + "id": "min_boards", + "label": "最少连板", + "type": "int", + "default": 1, + "min": 1, + "max": 10, + "step": 1, + }, + ], + "scoring": {"consecutive_limit_ups": 0.4, "change_pct": 0.3, "amount": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_limit_up"] +EXIT_SIGNALS = [] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 5 + + +class LimitUpMomentumMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "consecutive_limit_ups"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("use_change_filter", True): + entry &= ( + matrix_feature(market, "change_pct") > float(params.get("min_change", 5.0)) / 100.0 + ) + if params.get("use_boards_filter", True): + entry &= matrix_feature(market, "consecutive_limit_ups") >= int( + params.get("min_boards", 1) + ) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + entry_signal_ids=("signal_limit_up",), + ) + + +MATRIX_STRATEGY = LimitUpMomentumMatrixStrategy() diff --git a/backend/app/strategy/seeds/long_lower_shadow_reversal.py b/backend/app/strategy/seeds/long_lower_shadow_reversal.py new file mode 100644 index 00000000..7ec27182 --- /dev/null +++ b/backend/app/strategy/seeds/long_lower_shadow_reversal.py @@ -0,0 +1,87 @@ +"""长下影反击 — 超跌后放量收长下影线, 多头承接确认""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) + +META = { + "id": "long_lower_shadow_reversal", + "name": "长下影反击", + "description": "近5日超跌后收长下影线且收盘收复实体, 下方承接强势的反转信号", + "tags": ["K线形态", "超跌", "反转"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + { + "id": "shadow_pct_min", + "label": "下影线最低长度% (相对昨收)", + "type": "float", + "default": 3.0, + "min": 1.0, + "max": 10.0, + "step": 0.5, + }, + { + "id": "drop_pct_max", + "label": "近5日累计跌幅下限% (负值)", + "type": "float", + "default": -5.0, + "min": -30.0, + "max": 0.0, + "step": 1.0, + }, + {"id": "require_recovery", "label": "要求收盘收复上半区", "type": "bool", "default": True}, + ], + "scoring": {"momentum_20d": 0.3, "vol_ratio_5d": 0.4, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_long_lower_shadow"] +EXIT_SIGNALS = ["signal_ma5_lose_after_shadow"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 + + +class LongLowerShadowReversalMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"open", "high", "low", "close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 20 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + prev_close = matrix_feature(market, "prev_close") + body_bot = np.minimum(market.open, market.close) + shadow_pct = (body_bot - market.low) / prev_close * 100.0 + + entry = shadow_pct >= float(params.get("shadow_pct_min", 3.0)) + entry &= matrix_feature(market, "momentum_5d") <= float(params.get("drop_pct_max", -5.0)) / 100.0 + entry &= matrix_feature(market, "vol_ratio_5d") >= 1.2 # 承接需有量 + if params.get("require_recovery", True): + # 收盘位于当日振幅上半部 (close_position ∈ [0,1]): 长下影反击常为低开回拉, + # 收盘仍可能低于开盘 (假阴线), 用位置而非阴阳判定收复力度 + entry &= matrix_feature(market, "close_position") >= 0.5 + + ma5 = matrix_feature(market, "ma5") + exit_ = market.close < ma5 + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_long_lower_shadow",), + exit_signal_ids=("signal_ma5_lose_after_shadow",), + ) + + +MATRIX_STRATEGY = LongLowerShadowReversalMatrixStrategy() diff --git a/backend/app/strategy/seeds/low_volatility_leader.py b/backend/app/strategy/seeds/low_volatility_leader.py new file mode 100644 index 00000000..167dd49f --- /dev/null +++ b/backend/app/strategy/seeds/low_volatility_leader.py @@ -0,0 +1,88 @@ +"""低波动龙头 — 正动量 + 低波动 + MA20上方""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "low_volatility_leader", + "name": "低波动龙头", + "description": "20日动量为正 + 年化波动 < 30% + MA20上方", + "tags": ["低波动", "龙头"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + { + "id": "require_positive_momentum", + "label": "要求20日动量为正", + "type": "bool", + "default": True, + }, + {"id": "use_volatility_filter", "label": "启用波动率过滤", "type": "bool", "default": True}, + { + "id": "vol_max", + "label": "最大年化波动", + "type": "float", + "default": 0.30, + "min": 0.05, + "max": 1.0, + "step": 0.01, + }, + { + "id": "require_above_ma20", + "label": "要求收盘价在MA20上方", + "type": "bool", + "default": True, + }, + ], + "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma20_breakout"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 30 + + +class LowVolatilityLeaderMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma20 = matrix_feature(market, "ma20") + entry = np.ones(market.shape, dtype=bool) + if params.get("require_positive_momentum", True): + entry &= matrix_feature(market, "momentum_20d") > 0 + if params.get("use_volatility_filter", True): + entry &= matrix_feature(market, "annual_vol_20d") < float(params.get("vol_max", 0.30)) + if params.get("require_above_ma20", True): + entry &= market.close > ma20 + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_ma20_breakout",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = LowVolatilityLeaderMatrixStrategy() diff --git a/backend/app/strategy/seeds/ma_convergence_breakout.py b/backend/app/strategy/seeds/ma_convergence_breakout.py new file mode 100644 index 00000000..0df76665 --- /dev/null +++ b/backend/app/strategy/seeds/ma_convergence_breakout.py @@ -0,0 +1,105 @@ +"""均线粘合突破 — MA5/10/20 挤压收敛后放量向上突破 (变盘启动点)""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "ma_convergence_breakout", + "name": "均线粘合突破", + "description": "MA5/10/20 粘合收敛后放量突破, 挤压释放的变盘启动点", + "tags": ["均线", "粘合", "突破"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + { + "id": "spread_pct_max", + "label": "粘合带宽上限% (三线极差/MA20)", + "type": "float", + "default": 2.5, + "min": 0.5, + "max": 8.0, + "step": 0.5, + }, + { + "id": "squeeze_days", + "label": "粘合持续天数", + "type": "int", + "default": 5, + "min": 3, + "max": 15, + "step": 1, + }, + { + "id": "min_change_pct", + "label": "突破日最低涨幅%", + "type": "float", + "default": 2.0, + "min": 0.0, + "max": 10.0, + "step": 0.5, + }, + {"id": "require_volume", "label": "要求量比配合", "type": "bool", "default": True}, + ], + "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma_convergence_breakout"] +EXIT_SIGNALS = ["signal_ma10_lose"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class MAConvergenceBreakoutMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + return 20 + int(params.get("squeeze_days", 5)) + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma5 = matrix_feature(market, "ma5") + ma10 = matrix_feature(market, "ma10") + ma20 = matrix_feature(market, "ma20") + band_top = np.maximum(np.maximum(ma5, ma10), ma20) + band_bot = np.minimum(np.minimum(ma5, ma10), ma20) + spread = np.abs(band_top - band_bot) / ma20 * 100.0 + + # 粘合: 近 squeeze_days 日 (含昨日, 不含今日) 带宽持续低于阈值 + days = max(2, int(params.get("squeeze_days", 5))) + tight = spread <= float(params.get("spread_pct_max", 2.5)) + squeeze = shift(tight, 1).astype(bool) + for k in range(2, days + 1): + squeeze &= shift(tight, k).astype(bool) + + # 今日变盘: 放量阳线脱离粘合带, 收盘站上三线上方 + entry = squeeze & (market.close > band_top) + entry &= matrix_feature(market, "change_pct") >= float(params.get("min_change_pct", 2.0)) / 100.0 + if params.get("require_volume", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= 1.2 + + exit_ = market.close < ma10 + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_ma_convergence_breakout",), + exit_signal_ids=("signal_ma10_lose",), + ) + + +MATRIX_STRATEGY = MAConvergenceBreakoutMatrixStrategy() diff --git a/backend/app/strategy/seeds/ma_golden_cross.py b/backend/app/strategy/seeds/ma_golden_cross.py new file mode 100644 index 00000000..82500ac6 --- /dev/null +++ b/backend/app/strategy/seeds/ma_golden_cross.py @@ -0,0 +1,87 @@ +"""MA金叉 — MA5上穿MA20 + 量能配合 + MA60上方""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "ma_golden_cross", + "name": "MA 金叉", + "description": "MA5上穿MA20当日触发, 量能配合", + "tags": ["均线", "金叉"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "require_ma_golden", "label": "要求MA5上穿MA20", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.2, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + { + "id": "require_above_ma60", + "label": "要求收盘价在MA60上方", + "type": "bool", + "default": True, + }, + ], + "scoring": {"momentum_20d": 0.5, "vol_ratio_5d": 0.3, "change_pct": 0.2}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma_golden_5_20"] +EXIT_SIGNALS = ["signal_ma_dead_5_20"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class MAGoldenCrossMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma5 = matrix_feature(market, "ma5") + ma20 = matrix_feature(market, "ma20") + golden = (ma5 > ma20) & (shift(ma5, 1) <= shift(ma20, 1)) + dead = (ma5 < ma20) & (shift(ma5, 1) >= shift(ma20, 1)) + entry = np.ones(market.shape, dtype=bool) + if params.get("require_ma_golden", True): + entry &= golden + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 1.2) + ) + if params.get("require_above_ma60", True): + entry &= market.close > matrix_feature(market, "ma60") + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=dead.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(dead, 0, -1).astype(np.int16), + entry_signal_ids=("signal_ma_golden_5_20",), + exit_signal_ids=("signal_ma_dead_5_20",), + ) + + +MATRIX_STRATEGY = MAGoldenCrossMatrixStrategy() diff --git a/backend/app/strategy/seeds/macd_below_zero_revival.py b/backend/app/strategy/seeds/macd_below_zero_revival.py new file mode 100644 index 00000000..e20a09a5 --- /dev/null +++ b/backend/app/strategy/seeds/macd_below_zero_revival.py @@ -0,0 +1,89 @@ +"""MACD 零下回升 — 股价新低而 DIF 拒绝新低 (底背离简化形态)""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "macd_below_zero_revival", + "name": "MACD 零下回升", + "description": "股价创阶段新低而 MACD DIF 拒绝新低并回升 (底背离简化), 零轴下方动能修复", + "tags": ["MACD", "背离", "超跌"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + { + "id": "low_window", + "label": "新低回看天数", + "type": "int", + "default": 20, + "min": 10, + "max": 60, + "step": 5, + }, + { + "id": "revive_days", + "label": "DIF 回升对比天数", + "type": "int", + "default": 10, + "min": 5, + "max": 20, + "step": 1, + }, + ], + "scoring": {"momentum_20d": 0.4, "change_pct": 0.3, "vol_ratio_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_macd_below_zero_revival"] +EXIT_SIGNALS = ["signal_macd_golden_above_zero"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 20 + + +class MACDBelowZeroRevivalMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + return int(params.get("low_window", 20)) + 40 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + win = max(5, int(params.get("low_window", 20))) + revive = max(3, int(params.get("revive_days", 10))) + dif = matrix_feature(market, "macd_dif") + + # 阶段新低: 今日收盘 <= 前 win 日 (不含今日) 的最低收盘 + prior_min = shift(market.close, 1) + for k in range(2, win + 1): + prior_min = np.fmin(prior_min, shift(market.close, k)) + new_low = market.close <= prior_min + + # 零下 + DIF 较 revive 日前抬升 (动能拒绝新低) + entry = new_low & (dif < 0) & (dif > shift(dif, revive)) + + # 出场: DIF 上穿零轴 (修复完成) + exit_ = (dif > 0) & (shift(dif, 1) <= 0) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_macd_below_zero_revival",), + exit_signal_ids=("signal_macd_golden_above_zero",), + ) + + +MATRIX_STRATEGY = MACDBelowZeroRevivalMatrixStrategy() diff --git a/backend/app/strategy/seeds/macd_golden.py b/backend/app/strategy/seeds/macd_golden.py new file mode 100644 index 00000000..a599a4a2 --- /dev/null +++ b/backend/app/strategy/seeds/macd_golden.py @@ -0,0 +1,99 @@ +"""MACD金叉放量 — MACD金叉当日 + 量能放大""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_ewm_adjust_false as ewm_adjust_false, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "macd_golden", + "name": "MACD 金叉放量", + "description": "MACD金叉当日 + 量能放大", + "tags": ["MACD", "金叉", "放量"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "require_macd_golden", "label": "要求MACD金叉", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.5, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + ], + "scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +ENTRY_SIGNALS = ["signal_macd_golden"] +EXIT_SIGNALS = ["signal_macd_dead"] +EXECUTION_BACKEND = "matrix_native" +STOP_LOSS = -0.07 +MAX_HOLD_DAYS = 20 + + +class MACDGoldenMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals( + self, + market: MarketDataMatrix, + params: dict, + ) -> SignalMatrix: + valid = np.isfinite(market.close) + ema12 = ewm_adjust_false(market.close, valid, span=12) + ema26 = ewm_adjust_false(market.close, valid, span=26) + dif = ema12 - ema26 + dif_valid = np.isfinite(dif) + dea = ewm_adjust_false(dif, dif_valid, span=9) + previous_dif = shift(dif, 1, dif_valid) + previous_dea = shift(dea, 1, np.isfinite(dea)) + golden = (dif > dea) & (previous_dif <= previous_dea) + dead = (dif < dea) & (previous_dif >= previous_dea) + + entry = ( + golden + if params.get("require_macd_golden", True) + else np.ones( + market.shape, + dtype=bool, + ) + ) + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 1.5) + ) + + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=dead.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(dead, 0, -1).astype(np.int16), + entry_signal_ids=("signal_macd_golden",), + exit_signal_ids=("signal_macd_dead",), + ) + + +MATRIX_STRATEGY = MACDGoldenMatrixStrategy() diff --git a/backend/app/strategy/seeds/n_day_low_reversal.py b/backend/app/strategy/seeds/n_day_low_reversal.py new file mode 100644 index 00000000..eb68c7d9 --- /dev/null +++ b/backend/app/strategy/seeds/n_day_low_reversal.py @@ -0,0 +1,80 @@ +"""新低反转 — 60日新低后收阳放量""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "n_day_low_reversal", + "name": "新低反转", + "description": "触及60日新低后当日收阳放量, 反转信号", + "tags": ["反转", "新低"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "require_n_day_low", "label": "要求60日新低", "type": "bool", "default": True}, + {"id": "require_bullish_candle", "label": "要求收阳", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.5, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + ], + "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_n_day_low"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class NDayLowReversalMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"open", "close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("require_n_day_low", True): + entry &= market.close <= matrix_feature(market, "low_60d") + if params.get("require_bullish_candle", True): + entry &= market.close > market.open + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 1.5) + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_n_day_low",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = NDayLowReversalMatrixStrategy() diff --git a/backend/app/strategy/seeds/near_limit_up.py b/backend/app/strategy/seeds/near_limit_up.py new file mode 100644 index 00000000..0eae2c52 --- /dev/null +++ b/backend/app/strategy/seeds/near_limit_up.py @@ -0,0 +1,93 @@ +"""逼近涨停 — 涨幅 > 7% 且距涨停 < 3%, 盘后策略""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "near_limit_up", + "name": "逼近涨停", + "description": "涨幅 > 7% 且距涨停 < 3%, 追涨信号", + "tags": ["涨停", "追涨"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "use_change_filter", "label": "启用涨幅过滤", "type": "bool", "default": True}, + { + "id": "min_change", + "label": "最低涨幅%", + "type": "float", + "default": 7.0, + "min": 3.0, + "max": 15.0, + "step": 1.0, + }, + { + "id": "use_limit_gap_filter", + "label": "启用距涨停空间过滤", + "type": "bool", + "default": True, + }, + { + "id": "limit_gap", + "label": "距涨停空间%", + "type": "float", + "default": 3.0, + "min": 1.0, + "max": 10.0, + "step": 0.5, + }, + ], + "scoring": {"change_pct": 0.5, "amount": 0.3, "momentum_5d": 0.2}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 5 + + +class NearLimitUpMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "price_limit_pct"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + change = matrix_feature(market, "change_pct") + entry = np.ones(market.shape, dtype=bool) + if params.get("use_change_filter", True): + entry &= change > float(params.get("min_change", 7.0)) / 100.0 + if params.get("use_limit_gap_filter", True): + limit_pct = matrix_feature(market, "price_limit_pct") + entry &= ( + change + >= limit_pct - float(params.get("limit_gap", 3.0)) / 100.0 + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = NearLimitUpMatrixStrategy() diff --git a/backend/app/strategy/seeds/oversold_bounce.py b/backend/app/strategy/seeds/oversold_bounce.py new file mode 100644 index 00000000..53655335 --- /dev/null +++ b/backend/app/strategy/seeds/oversold_bounce.py @@ -0,0 +1,88 @@ +"""超跌反弹 — RSI14 < 30 + 收阳 + 放量""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "oversold_bounce", + "name": "超跌反弹", + "description": "RSI14 < 30超卖区 + 当日收阳 + 放量, 抄底信号", + "tags": ["超跌", "反弹", "RSI"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "use_rsi_filter", "label": "启用RSI过滤", "type": "bool", "default": True}, + { + "id": "rsi_max", + "label": "RSI上限", + "type": "float", + "default": 30.0, + "min": 10.0, + "max": 50.0, + "step": 1.0, + }, + {"id": "require_bullish_candle", "label": "要求收阳", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 1.2, + "min": 0.5, + "max": 5.0, + "step": 0.1, + }, + ], + "scoring": {"change_pct": 0.3, "vol_ratio_5d": 0.3, "momentum_5d": 0.2, "rsi_14": 0.2}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 15 + + +class OversoldBounceMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"open", "close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("use_rsi_filter", True): + entry &= matrix_feature(market, "rsi_14") < float(params.get("rsi_max", 30.0)) + if params.get("require_bullish_candle", True): + entry &= market.close > market.open + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 1.2) + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = OversoldBounceMatrixStrategy() diff --git a/backend/app/strategy/seeds/oversold_reversal.py b/backend/app/strategy/seeds/oversold_reversal.py new file mode 100644 index 00000000..afca00dc --- /dev/null +++ b/backend/app/strategy/seeds/oversold_reversal.py @@ -0,0 +1,93 @@ +"""超跌反弹 — RSI14 < 30 + 涨幅 > 1% + 站上 MA5, 超卖反弹信号""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "oversold_reversal", + "name": "超跌反转", + "description": "RSI14 < 30超卖 + 涨幅 > 1% + 站上MA5, 超卖反转信号", + "tags": ["超跌", "反弹", "RSI"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "use_rsi_filter", "label": "启用RSI过滤", "type": "bool", "default": True}, + { + "id": "rsi_max", + "label": "RSI上限", + "type": "float", + "default": 30.0, + "min": 10.0, + "max": 50.0, + "step": 1.0, + }, + {"id": "use_change_filter", "label": "启用涨幅过滤", "type": "bool", "default": True}, + { + "id": "min_change", + "label": "最低涨幅%", + "type": "float", + "default": 1.0, + "min": 0.5, + "max": 5.0, + "step": 0.5, + }, + { + "id": "require_above_ma5", + "label": "要求收盘价在MA5上方", + "type": "bool", + "default": True, + }, + ], + "scoring": {"change_pct": 0.4, "rsi_14": 0.3, "vol_ratio_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 15 + + +class OversoldReversalMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("use_rsi_filter", True): + entry &= matrix_feature(market, "rsi_14") < float(params.get("rsi_max", 30.0)) + if params.get("use_change_filter", True): + entry &= ( + matrix_feature(market, "change_pct") > float(params.get("min_change", 1.0)) / 100.0 + ) + if params.get("require_above_ma5", True): + entry &= market.close > matrix_feature(market, "ma5") + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = OversoldReversalMatrixStrategy() diff --git a/backend/app/strategy/seeds/platform_consolidation_breakout.py b/backend/app/strategy/seeds/platform_consolidation_breakout.py new file mode 100644 index 00000000..0c3ba0a9 --- /dev/null +++ b/backend/app/strategy/seeds/platform_consolidation_breakout.py @@ -0,0 +1,99 @@ +"""平台缩量整理突破 — 窄幅横盘蓄势后放量突破平台上沿""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "platform_consolidation_breakout", + "name": "平台整理突破", + "description": "近N日窄幅横盘 (振幅收敛) 后放量突破平台上沿, 蓄势变盘", + "tags": ["形态", "平台", "突破"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + { + "id": "platform_days", + "label": "平台天数", + "type": "int", + "default": 10, + "min": 5, + "max": 30, + "step": 1, + }, + { + "id": "range_pct_max", + "label": "平台振幅上限%", + "type": "float", + "default": 8.0, + "min": 3.0, + "max": 20.0, + "step": 0.5, + }, + { + "id": "vol_ratio_min", + "label": "突破日最低量比", + "type": "float", + "default": 1.5, + "min": 1.0, + "max": 5.0, + "step": 0.1, + }, + ], + "scoring": {"vol_ratio_5d": 0.4, "momentum_20d": 0.3, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_platform_breakout"] +EXIT_SIGNALS = ["signal_platform_fail_ma20"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class PlatformConsolidationBreakoutMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"high", "low", "close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + return int(params.get("platform_days", 10)) + 10 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + days = max(4, int(params.get("platform_days", 10))) + + # 平台区间: 前 days 日 (不含今日) 的最高/最低 (滚动窗口平移) + prior_high = shift(market.high, 1) + prior_low = shift(market.low, 1) + for k in range(2, days + 1): + prior_high = np.fmax(prior_high, shift(market.high, k)) + prior_low = np.fmin(prior_low, shift(market.low, k)) + range_pct = (prior_high - prior_low) / market.close * 100.0 + + # 平台成立 + 今日放量突破平台上沿 + entry = range_pct <= float(params.get("range_pct_max", 8.0)) + entry &= market.close > prior_high + entry &= matrix_feature(market, "vol_ratio_5d") >= float(params.get("vol_ratio_min", 1.5)) + + exit_ = market.close < matrix_feature(market, "ma20") + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_platform_breakout",), + exit_signal_ids=("signal_platform_fail_ma20",), + ) + + +MATRIX_STRATEGY = PlatformConsolidationBreakoutMatrixStrategy() diff --git a/backend/app/strategy/seeds/pullback_ma20_bounce.py b/backend/app/strategy/seeds/pullback_ma20_bounce.py new file mode 100644 index 00000000..3412ede7 --- /dev/null +++ b/backend/app/strategy/seeds/pullback_ma20_bounce.py @@ -0,0 +1,90 @@ +"""均线回踩反弹 — 价格在 MA20 附近(±2%)且 MA 多头排列, 回踩买入""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "pullback_ma20_bounce", + "name": "均线回踩反弹", + "description": "价格在MA20附近(±2%)且MA5>MA20>MA60多头排列, 回踩买入", + "tags": ["回踩", "均线", "反弹"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "use_ma20_proximity", "label": "启用MA20附近过滤", "type": "bool", "default": True}, + { + "id": "ma_proximity", + "label": "MA偏离度%", + "type": "float", + "default": 2.0, + "min": 0.5, + "max": 5.0, + "step": 0.5, + }, + { + "id": "require_ma_alignment", + "label": "要求MA5>MA20>MA60", + "type": "bool", + "default": True, + }, + {"id": "require_positive_change", "label": "要求当日上涨", "type": "bool", "default": True}, + ], + "scoring": {"momentum_60d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma_golden_5_20"] +EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 15 + + +class PullbackMA20BounceMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma5 = matrix_feature(market, "ma5") + ma20 = matrix_feature(market, "ma20") + ma60 = matrix_feature(market, "ma60") + entry = np.ones(market.shape, dtype=bool) + if params.get("use_ma20_proximity", True): + proximity = float(params.get("ma_proximity", 2.0)) / 100.0 + entry &= (market.close > ma20 * (1.0 - proximity)) & ( + market.close < ma20 * (1.0 + proximity) + ) + if params.get("require_ma_alignment", True): + entry &= (ma5 > ma20) & (ma20 > ma60) + if params.get("require_positive_change", True): + entry &= matrix_feature(market, "change_pct") > 0 + ma20_breakdown = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + ma_dead = (ma5 < ma20) & (shift(ma5, 1) >= shift(ma20, 1)) + exit_ = ma20_breakdown | ma_dead + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(ma20_breakdown, 0, np.where(ma_dead, 1, -1)).astype(np.int16), + entry_signal_ids=("signal_ma_golden_5_20",), + exit_signal_ids=("signal_ma20_breakdown", "signal_ma_dead_5_20"), + ) + + +MATRIX_STRATEGY = PullbackMA20BounceMatrixStrategy() diff --git a/backend/app/strategy/seeds/pullback_to_support.py b/backend/app/strategy/seeds/pullback_to_support.py new file mode 100644 index 00000000..792aae55 --- /dev/null +++ b/backend/app/strategy/seeds/pullback_to_support.py @@ -0,0 +1,105 @@ +"""缩量回踩 — 回踩MA20附近 + 缩量 + 中期趋势向上""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "pullback_to_support", + "name": "缩量回踩", + "description": "回踩MA20附近 + 缩量 + 中期趋势向上", + "tags": ["回踩", "支撑"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "use_ma20_proximity", "label": "启用MA20附近过滤", "type": "bool", "default": True}, + { + "id": "ma_proximity", + "label": "均线偏离度", + "type": "float", + "default": 0.02, + "min": 0.01, + "max": 0.05, + "step": 0.005, + }, + {"id": "use_volume_filter", "label": "启用缩量过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_max", + "label": "最大量比", + "type": "float", + "default": 0.8, + "min": 0.2, + "max": 1.5, + "step": 0.1, + }, + { + "id": "require_above_ma60", + "label": "要求收盘价在MA60上方", + "type": "bool", + "default": True, + }, + { + "id": "require_positive_momentum", + "label": "要求20日动量为正", + "type": "bool", + "default": True, + }, + ], + "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma_golden_5_20"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 20 + + +class PullbackToSupportMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma20 = matrix_feature(market, "ma20") + entry = np.ones(market.shape, dtype=bool) + if params.get("use_ma20_proximity", True): + proximity = float(params.get("ma_proximity", 0.02)) + entry &= (market.close > ma20 * (1.0 - proximity)) & ( + market.close < ma20 * (1.0 + proximity) + ) + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") < float( + params.get("vol_ratio_max", 0.8) + ) + if params.get("require_above_ma60", True): + entry &= market.close > matrix_feature(market, "ma60") + if params.get("require_positive_momentum", True): + entry &= matrix_feature(market, "momentum_20d") > 0 + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_ma_golden_5_20",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = PullbackToSupportMatrixStrategy() diff --git a/backend/app/strategy/seeds/rsi_midline_pullback.py b/backend/app/strategy/seeds/rsi_midline_pullback.py new file mode 100644 index 00000000..d9fb1f0e --- /dev/null +++ b/backend/app/strategy/seeds/rsi_midline_pullback.py @@ -0,0 +1,98 @@ +"""RSI 中轴回踩 — 强趋势中 RSI 回落至 50 中轴附近而不破, 低吸点""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "rsi_midline_pullback", + "name": "RSI 中轴回踩", + "description": "多头趋势 (MA60 上方) 中 RSI 回落至 50 中轴区间企稳, 强势股低吸", + "tags": ["RSI", "趋势", "低吸"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + { + "id": "rsi_period", + "label": "RSI 周期", + "type": "int", + "default": 14, + "min": 6, + "max": 24, + "step": 2, + }, + { + "id": "mid_low", + "label": "中轴区间下沿", + "type": "float", + "default": 45.0, + "min": 30.0, + "max": 55.0, + "step": 1.0, + }, + { + "id": "mid_high", + "label": "中轴区间上沿", + "type": "float", + "default": 60.0, + "min": 45.0, + "max": 70.0, + "step": 1.0, + }, + ], + "scoring": {"momentum_20d": 0.4, "up_days_20d": 0.3, "vol_ratio_5d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_rsi_midline_pullback"] +EXIT_SIGNALS = ["signal_rsi_midline_fail"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class RSIMidlinePullbackMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + return int(params.get("rsi_period", 14)) + 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + rsi = matrix_feature(market, f"rsi_{int(params.get('rsi_period', 14))}") + lo = float(params.get("mid_low", 45.0)) + hi = float(params.get("mid_high", 60.0)) + + # 趋势前提: MA60 上方; 今日 RSI 落在中轴区间 + entry = market.close > matrix_feature(market, "ma60") + entry &= (rsi >= lo) & (rsi <= hi) + + # 回踩而非走坏: 近5日内出现过 RSI > hi+5 (强势记忆), 且今日未破中轴下沿 + was_strong = shift(rsi, 1) > hi + 5 + for k in range(2, 6): + was_strong |= shift(rsi, k) > hi + 5 + entry &= was_strong + + exit_ = market.close < matrix_feature(market, "ma20") + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_rsi_midline_pullback",), + exit_signal_ids=("signal_rsi_midline_fail",), + ) + + +MATRIX_STRATEGY = RSIMidlinePullbackMatrixStrategy() diff --git a/backend/app/strategy/seeds/strong_open.py b/backend/app/strategy/seeds/strong_open.py new file mode 100644 index 00000000..e00b6634 --- /dev/null +++ b/backend/app/strategy/seeds/strong_open.py @@ -0,0 +1,95 @@ +"""强势高开 — 高开 > 3% 且保持上涨, 集合竞价强势""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "strong_open", + "name": "强势高开", + "description": "高开 > 3% 且收盘高于开盘价, 集合竞价强势", + "tags": ["高开", "强势"], + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {"id": "use_open_gap_filter", "label": "启用高开过滤", "type": "bool", "default": True}, + { + "id": "min_open_gap", + "label": "最低高开%", + "type": "float", + "default": 3.0, + "min": 1.0, + "max": 10.0, + "step": 0.5, + }, + { + "id": "require_close_above_open", + "label": "要求收盘高于开盘", + "type": "bool", + "default": True, + }, + {"id": "use_change_filter", "label": "启用涨幅过滤", "type": "bool", "default": True}, + { + "id": "min_change", + "label": "最低涨幅%", + "type": "float", + "default": 3.0, + "min": 1.0, + "max": 10.0, + "step": 0.5, + }, + ], + "scoring": {"change_pct": 0.4, "amplitude": 0.2, "amount": 0.4}, + "order_by": "score", + "descending": True, + "limit": 50, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = [] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.05 +MAX_HOLD_DAYS = 10 + + +class StrongOpenMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"open", "close"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("use_open_gap_filter", True): + entry &= market.open > shift(market.close, 1) * ( + 1.0 + float(params.get("min_open_gap", 3.0)) / 100.0 + ) + if params.get("require_close_above_open", True): + entry &= market.close > market.open + if params.get("use_change_filter", True): + entry &= ( + matrix_feature(market, "change_pct") > float(params.get("min_change", 3.0)) / 100.0 + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = StrongOpenMatrixStrategy() diff --git a/backend/app/strategy/seeds/trend_breakout.py b/backend/app/strategy/seeds/trend_breakout.py new file mode 100644 index 00000000..6048adf4 --- /dev/null +++ b/backend/app/strategy/seeds/trend_breakout.py @@ -0,0 +1,93 @@ +"""趋势突破 — MA60上方 + 60日新高 + 放量""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "trend_breakout", + "name": "趋势突破", + "description": "MA60上方 + 60日新高 + 量能 ≥ 2倍均量", + "tags": ["趋势", "突破", "放量"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "basic_filter": { + "price_min": 5, + "price_max": 200, + "market_cap_min": 20e8, + "amount_min": 1e8, + "exclude_st": True, + "exclude_new_days": 60, + }, + "params": [ + { + "id": "require_above_ma60", + "label": "要求收盘价在MA60上方", + "type": "bool", + "default": True, + }, + {"id": "require_n_day_high", "label": "要求60日新高", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 2.0, + "min": 0.5, + "max": 10.0, + "step": 0.1, + }, + ], + "scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_n_day_high"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.08 +MAX_HOLD_DAYS = 20 + + +class TrendBreakoutMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + entry = np.ones(market.shape, dtype=bool) + if params.get("require_above_ma60", True): + entry &= market.close > matrix_feature(market, "ma60") + if params.get("require_n_day_high", True): + entry &= market.close >= matrix_feature(market, "high_60d") + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 2.0) + ) + ma20 = matrix_feature(market, "ma20") + exit_ = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_n_day_high",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = TrendBreakoutMatrixStrategy() diff --git a/backend/app/strategy/seeds/volume_price_surge.py b/backend/app/strategy/seeds/volume_price_surge.py new file mode 100644 index 00000000..02c08ef3 --- /dev/null +++ b/backend/app/strategy/seeds/volume_price_surge.py @@ -0,0 +1,81 @@ +"""量价齐升 — 突破MA20 + 放量 + 收阳""" + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + make_signal_matrix, + matrix_feature, +) +from app.backtest.matrix import ( + valid_shift as shift, +) + +META = { + "id": "volume_price_surge", + "name": "量价齐升", + "description": "突破MA20 + 放量 + 收阳", + "tags": ["量价", "突破"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "params": [ + {"id": "require_ma20_breakout", "label": "要求突破MA20", "type": "bool", "default": True}, + {"id": "use_volume_filter", "label": "启用量比过滤", "type": "bool", "default": True}, + { + "id": "vol_ratio_min", + "label": "最低量比", + "type": "float", + "default": 2.0, + "min": 0.5, + "max": 10.0, + "step": 0.1, + }, + {"id": "require_bullish_candle", "label": "要求收阳", "type": "bool", "default": True}, + ], + "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_ma20_breakout"] +EXIT_SIGNALS = ["signal_ma20_breakdown"] +STOP_LOSS = -0.06 +MAX_HOLD_DAYS = 15 + + +class VolumePriceSurgeMatrixStrategy: + def required_fields(self) -> frozenset[str]: + return frozenset({"open", "close", "volume"}) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + ma20 = matrix_feature(market, "ma20") + breakout = (market.close > ma20) & (shift(market.close, 1) <= shift(ma20, 1)) + breakdown = (market.close < ma20) & (shift(market.close, 1) >= shift(ma20, 1)) + entry = np.ones(market.shape, dtype=bool) + if params.get("require_ma20_breakout", True): + entry &= breakout + if params.get("use_volume_filter", True): + entry &= matrix_feature(market, "vol_ratio_5d") >= float( + params.get("vol_ratio_min", 2.0) + ) + if params.get("require_bullish_candle", True): + entry &= market.close > market.open + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=breakdown.astype(np.uint8), + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(breakdown, 0, -1).astype(np.int16), + entry_signal_ids=("signal_ma20_breakout",), + exit_signal_ids=("signal_ma20_breakdown",), + ) + + +MATRIX_STRATEGY = VolumePriceSurgeMatrixStrategy() diff --git a/backend/app/strategy/strategy_seeds.py b/backend/app/strategy/strategy_seeds.py new file mode 100644 index 00000000..e7765247 --- /dev/null +++ b/backend/app/strategy/strategy_seeds.py @@ -0,0 +1,54 @@ +"""策略种子 — 去内置化迁移补全: 原内置策略按 ID 种入用户自定义目录。 + +与 custom_signals.seed_if_empty 同族机制 (仅空判断 vs 按 ID 补缺的取舍 +见各 docstring): 升级用户的 data/strategies/custom 往往已有自己的策略, +但丢失了原 25 个内置策略 —— 因此按文件名逐一补缺, 已存在(含用户已自行 +迁移的同名策略)一律不覆盖; .seeded_v1 标记保证一次性, 删除后重启不复活。 +""" +from __future__ import annotations + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +SEEDS_DIR = Path(__file__).resolve().parent / "seeds" + + +def seed_missing_builtins(data_dir: Path) -> int: + """把内置策略种子按文件名补入 data/strategies/custom; 返回种入数量。 + + - 幂等: ``.seeded_v1`` 标记存在 (无论目录后来是否被清空) 不再种入。 + - 仅写缺失的同名文件, 已有一律不覆盖: 全新环境拿到全部 25 个; + 已自行迁移过的老环境零写入; 只迁移了一部分的按缺口补全。 + - 写入前逐一过 _validate_safety (种子随包分发, 纵深防御), + 校验失败跳过该文件不阻断启动。 + """ + custom_dir = data_dir / "strategies" / "custom" + custom_dir.mkdir(parents=True, exist_ok=True) + marker = custom_dir / ".seeded_v1" + if marker.exists(): + return 0 + + from app.strategy.ai_generator import AIStrategyGenerator + + written = 0 + for seed in sorted(SEEDS_DIR.glob("*.py")): + # 跳过 _ 前缀 (包的 __init__.py 等): 引擎把 _*.py 当依赖文件, 不是策略 + if seed.name.startswith("_"): + continue + target = custom_dir / seed.name + if target.exists(): + continue + code = seed.read_text(encoding="utf-8") + try: + AIStrategyGenerator._validate_safety(code) + except (ValueError, SyntaxError) as e: + logger.warning("strategy seed %s skipped (validation): %s", seed.name, e) + continue + target.write_text(code, encoding="utf-8") + written += 1 + marker.write_text(f"seeded: {written}\n", encoding="utf-8") + if written: + logger.info("strategy seeds: wrote %d builtin strategies to custom", written) + return written diff --git a/backend/tests/test_strategy_seeds.py b/backend/tests/test_strategy_seeds.py new file mode 100644 index 00000000..ed1f02f0 --- /dev/null +++ b/backend/tests/test_strategy_seeds.py @@ -0,0 +1,58 @@ +"""策略种子 seed_missing_builtins — 去内置化补全回归。 + +原 25 个内置策略在去内置化后仅存在于 tests/fixtures 与部分老用户的 +custom 目录, 升级用户丢失。此处回归种入语义: + - 空目录一次写入全部 25 个, 标记防重; + - 已有同名文件一律不覆盖 (用户自行迁移过的零写入); + - 删除后重启不复活; + - 种入的文件能被引擎真实加载。 +""" +from __future__ import annotations + +from app.strategy.strategy_seeds import SEEDS_DIR, seed_missing_builtins + +_EXPECTED_COUNT = len([f for f in SEEDS_DIR.glob("*.py") if not f.name.startswith("_")]) + + +def test_seed_writes_all_and_marks(tmp_path): + n = seed_missing_builtins(tmp_path) + assert n == _EXPECTED_COUNT == 25 + custom = tmp_path / "strategies" / "custom" + assert len(list(custom.glob("*.py"))) == 25 + assert (custom / ".seeded_v1").exists() + # 幂等: 标记存在, 再次启动零写入 + assert seed_missing_builtins(tmp_path) == 0 + + +def test_seed_never_overwrites_existing(tmp_path): + custom = tmp_path / "strategies" / "custom" + custom.mkdir(parents=True) + # 用户已自行迁移过 1 个 (内容改成自定义版本) + 1 个自己的策略 + (custom / "boll_breakout.py").write_text("# 用户自定义版本\n", encoding="utf-8") + (custom / "my_own.py").write_text("# 用户自己的策略\n", encoding="utf-8") + + n = seed_missing_builtins(tmp_path) + + assert n == _EXPECTED_COUNT - 1 + assert (custom / "boll_breakout.py").read_text(encoding="utf-8") == "# 用户自定义版本\n" + assert (custom / "my_own.py").read_text(encoding="utf-8") == "# 用户自己的策略\n" + assert len(list(custom.glob("*.py"))) == _EXPECTED_COUNT + 1 + + +def test_seed_no_resurrect_after_user_deletes(tmp_path): + seed_missing_builtins(tmp_path) + custom = tmp_path / "strategies" / "custom" + for f in custom.glob("*.py"): + f.unlink() + assert seed_missing_builtins(tmp_path) == 0 + assert len(list(custom.glob("*.py"))) == 0 + + +def test_seeded_strategies_load_in_engine(tmp_path): + from app.strategy.engine import StrategyEngine + + seed_missing_builtins(tmp_path) + engine = StrategyEngine(strategy_dirs=[tmp_path / "strategies" / "custom"]) + + assert not engine.load_errors(), engine.load_errors() + assert len(engine.list_strategies()) == 25 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 17ead9cc..2d72337f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2255,7 +2255,7 @@ export interface SectorRotationUniverseItem { excluded?: boolean } -/** 页面包元数据 — data/ext_pages/{id}/page.json, 后端源 app/services/ext_pages.py */ +/** 页面插件元数据 — data/ext_pages/{id}/page.json, 后端源 app/services/ext_pages.py */ export interface RuntimePagePack { id: string enabled: boolean @@ -2933,7 +2933,7 @@ export const api = { const data = await request<{ strategies: StrategyDetail[] load_errors?: StrategyLoadError[] - legacy_migration?: { seeded_signals?: number; archived_files?: string[] } + legacy_migration?: { seeded_signals?: number; seeded_strategies?: number; archived_files?: string[] } }>( `/api/strategies?${assetType ? `asset_type=${assetType}&` : ''}${timeframe !== 'all' ? `timeframe=${timeframe}` : ''}`, ) @@ -3382,7 +3382,7 @@ export const api = { analysisMenus: () => request<{ items: AnalysisMenu[] }>('/api/analysis-menus'), - // ===== 页面包 (运行时扩展页面, 见 docs/page-sdk.md) ===== + // ===== 页面插件 (运行时扩展页面, 见 docs/page-sdk.md) ===== extPagesList: () => request('/api/ext-pages'), diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 7ac8214b..c8b1cea1 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -265,7 +265,7 @@ export function Screener() { prune(allStrategyIds) }, [allStrategyIds, prune, strategies.isError, strategies.isSuccess]) - // 去内置化一次性迁移提示: 后端启动时自动种入默认信号/归档失效策略, + // 去内置化一次性迁移提示: 后端启动时自动种入原内置策略/默认信号/归档失效策略, // 摘要只在首次见到时提示一次 (localStorage 记录迁移指纹)。 const legacyMigration = strategies.data?.legacy_migration useEffect(() => { @@ -277,6 +277,9 @@ export function Screener() { localStorage.setItem(key, fingerprint) } catch { /* 隐私模式等存储不可用: 退化为每次提示 */ } const parts: string[] = [] + if (legacyMigration.seeded_strategies) { + parts.push(`已补回 ${legacyMigration.seeded_strategies} 个原内置策略为自定义策略(可在策略页编辑或删除)`) + } if (legacyMigration.seeded_signals) { parts.push(`已将 ${legacyMigration.seeded_signals} 个原内置信号转为自定义信号(可在信号库编辑)`) } diff --git a/packaging/tsp.spec b/packaging/tsp.spec index 4393d4d6..8488c871 100644 --- a/packaging/tsp.spec +++ b/packaging/tsp.spec @@ -32,6 +32,7 @@ ROOT = Path(SPECPATH).parent FRONTEND_DIST = str(ROOT / "frontend" / "dist") TIERS_YAML = str(ROOT / "tiers.yaml") RESEARCH_TEMPLATE = str(ROOT / "backend" / "app" / "strategy" / "research") +# 内置策略种子目录 (strategy_seeds 按路径读文本, frozen 下需落盘, 同 research) # 图标按平台选: Windows 用 brand/icon.ico, macOS 用 brand/icon.icns — # 两者均由 brand/png/logo-tile-light-512.png 派生 (浅色圆角卡版, 品牌资产唯一来源, # 见 brand/README 接入指引), 无需 CI 现场生成。 @@ -164,6 +165,8 @@ datas += [(TIERS_YAML, ".")] datas += [(str(ROOT / "frontend" / "package.json"), ".")] # 研究模板 → app/strategy/research/ (importlib 动态加载, 不能进 PYZ; 挖掘功能依赖) datas += [(RESEARCH_TEMPLATE, "app/strategy/research")] +# 策略种子 → app/strategy/seeds/ (启动时按路径读取种入 custom, 不进 PYZ) +datas += [(str(ROOT / "backend" / "app" / "strategy" / "seeds"), "app/strategy/seeds")] # ── 排除不需要的重型依赖 (主包不含 vectorbt 回测链) ────────────────── excludes = [