feat(migrate): 内置策略按 ID 种入 custom — 修复升级丢失 25 个策略

启动时按文件名补缺, 不覆盖已有, 标记防复活; 桌面打包 datas 落盘,
前端迁移 toast 同步提示。
This commit is contained in:
shy3130
2026-10-15 12:27:59 +08:00
parent db300cad45
commit a292f7bc58
31 changed files with 2349 additions and 4 deletions
+12
View File
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
+99
View File
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
+95
View File
@@ -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()
@@ -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()
@@ -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()
+54
View File
@@ -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
+58
View File
@@ -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
+3 -3
View File
@@ -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<RuntimePagePack[]>('/api/ext-pages'),
+4 -1
View File
@@ -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} 个原内置信号转为自定义信号(可在信号库编辑)`)
}
+3
View File
@@ -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 = [