diff --git a/README.md b/README.md index 308ebdd..8680a44 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ | 模块         | 一句话 | 详见    | | :--------------- | :--------------------------------------------------------------------- | :-------------------------------- | | 🔀 **能力路由** | 多数据集(日K/除权/实时/分钟/盘口/财务,持续扩展)按源能力独立路由,任选组合 | [custom-data-source.md](./docs/custom-data-source.md) | -| 🔍 **选股引擎** | 18 个内置策略 + 分钟策略 + 自定义信号 + AI 生成,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) | +| 🔍 **选股引擎** | 25 个内置策略 + 分钟策略 + 自定义信号 + AI 生成,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) | | 📊 **指标流水线** | MA/EMA/MACD/RSI/KDJ/布林/量比等 68 列指标与信号,一次扫表落盘 enriched Parquet | [features.md](./docs/features.md) | | 🧪 **回测研究** | 因子/策略/分钟回测 + 财务快照因子(点时口径),T+1/费用/滑点约束,结果可导出 | [features.md](./docs/features.md) | | ⛏️ **因子挖掘** | 嵌套样本外搜索多因子排名组合,与自有策略对照,候选库显式发布、永不自动上线 | [mining.md](./docs/mining.md) | @@ -335,7 +335,7 @@ PORT=3018 # 服务端口 | [docs/configuration.md](./docs/configuration.md) | 所有 `.env` 配置项详解(数据源、AI、服务、密码、数据目录) | | [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) | | [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、能力路由契约、YAML 配置与 mock 联调示例 | -| [docs/strategy.md](./docs/strategy.md) | 策略体系(18 内置策略 + 三种扩展方式 + 文件结构) | +| [docs/strategy.md](./docs/strategy.md) | 策略体系(25 内置策略 + 三种扩展方式 + 文件结构) | | [docs/mining.md](./docs/mining.md) | 因子与策略挖掘口径、防泄漏、任务隔离和发布边界 | | [docs/market-phase.md](./docs/market-phase.md) | 市场情绪周期 6 阶段与概念/行业主线识别的口径与设计 | | [docs/plugin-development.md](./docs/plugin-development.md) | 数据源插件开发规范(以 stock-sdk / fuyao 为参考实现) | diff --git a/backend/app/strategy/builtin/active_limit_gene.py b/backend/app/strategy/builtin/active_limit_gene.py new file mode 100644 index 0000000..004e85f --- /dev/null +++ b/backend/app/strategy/builtin/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/builtin/breakout_new_high_60d.py b/backend/app/strategy/builtin/breakout_new_high_60d.py new file mode 100644 index 0000000..679abb7 --- /dev/null +++ b/backend/app/strategy/builtin/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/builtin/long_lower_shadow_reversal.py b/backend/app/strategy/builtin/long_lower_shadow_reversal.py new file mode 100644 index 0000000..565bbae --- /dev/null +++ b/backend/app/strategy/builtin/long_lower_shadow_reversal.py @@ -0,0 +1,90 @@ +"""长下影反击 — 超跌后放量收长下影线, 多头承接确认""" + +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": "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/builtin/ma_convergence_breakout.py b/backend/app/strategy/builtin/ma_convergence_breakout.py new file mode 100644 index 0000000..0df7666 --- /dev/null +++ b/backend/app/strategy/builtin/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/builtin/macd_below_zero_revival.py b/backend/app/strategy/builtin/macd_below_zero_revival.py new file mode 100644 index 0000000..e20a09a --- /dev/null +++ b/backend/app/strategy/builtin/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/builtin/platform_consolidation_breakout.py b/backend/app/strategy/builtin/platform_consolidation_breakout.py new file mode 100644 index 0000000..0c3ba0a --- /dev/null +++ b/backend/app/strategy/builtin/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/builtin/rsi_midline_pullback.py b/backend/app/strategy/builtin/rsi_midline_pullback.py new file mode 100644 index 0000000..d9fb1f0 --- /dev/null +++ b/backend/app/strategy/builtin/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/tests/backtest/test_matrix_strategy.py b/backend/tests/backtest/test_matrix_strategy.py index f0cab1e..d3b83e5 100644 --- a/backend/tests/backtest/test_matrix_strategy.py +++ b/backend/tests/backtest/test_matrix_strategy.py @@ -320,7 +320,7 @@ def test_builtin_matrix_strategies_use_their_declared_formula_modules(): ) # 分钟形态策略 (minute_red_streak) 已迁至自定义策略目录, 内置策略全部 matrix 后端 - assert len(strategy_files) == 19 + assert len(strategy_files) == 26 for strategy_path in strategy_files: strategy = StrategyEngine._load_file(strategy_path) assert strategy.execution_backend == "matrix_native" @@ -790,7 +790,7 @@ def test_registered_builtin_matrix_strategies_share_one_cache_profile(): if s.execution_backend != "minute_filter" ) - assert len(strategies) == 19 + assert len(strategies) == 26 assert all(strategy.execution_backend == "matrix_native" for strategy in strategies) assert profile.warmup_bars > 0 assert profile.forward_bars == max(int(strategy.max_hold_days or 0) for strategy in strategies) diff --git a/docs/strategy.md b/docs/strategy.md index a63e454..8972204 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -8,13 +8,13 @@ ## 内置策略 -**18 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): +**25 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): | 类型 | 代表策略 | | :---------- | :------------------------------------------------------- | -| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 | -| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 · 接近涨停 | -| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 · 回踩支撑 · 强势开盘 | +| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 · 均线粘合突破 · 平台整理突破 · 放量创60日新高 | +| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 · 接近涨停 · 涨停基因活跃股 | +| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 · 回踩支撑 · 强势开盘 · MACD 零下回升 · 长下影反击 · RSI 中轴回踩 | 内置目录 `backend/app/strategy/builtin/` 还包含一个仅供挖掘 worker 使用的受控因子排名研究模板。它不出现在普通选股列表,也不能通过普通策略 API 直接运行或保存 override;挖掘结果发布时会生成独立策略。详见 [因子与策略挖掘](./mining.md)。