mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
feat(migrate): 内置策略按 ID 种入 custom — 修复升级丢失 25 个策略
启动时按文件名补缺, 不覆盖已有, 标记防复活; 桌面打包 datas 落盘, 前端迁移 toast 同步提示。
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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} 个原内置信号转为自定义信号(可在信号库编辑)`)
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user