feat(migrate): 旧数据目录一次性自动迁移 — 信号自动种入 + 失效策略自动归档

老环境升级后进入策略页逐文件报错刷屏、信号库为空, 需要用户手动搬运。
改为启动时自动完成, 前端只提示一次:

- 信号种子: data/user_data/custom_signals 为空且从未种过(.seeded_v1
  标记防重)时写入 20 个默认定义(原内置信号, 带 signal_* 列名), 写入
  即用户数据可编辑可删; 用户已有自定义信号时不掺入只落标记; 全删后
  重启不复活
- 失效策略归档: 引擎加载后把「内置残留」类失败(composite 子策略断链/
  import 已删内置模块/引用不存在的策略)移入同目录 _archive_unloadable/
  (引擎只扫顶层 *.py), 归档后 reload; 语法错误等普通失败不归档仍走
  原提示; 同名碰撞追加序号不覆盖
- 提示一次: /api/strategies 透出 legacy_migration 摘要, 前端按迁移
  指纹 localStorage 防重, 一条 toast 说明种入/归档结果

单一事实源: app/strategy/signal_seeds.py (tests/fixtures JSON 同内容,
tests/signal_seeds.py 改为从此读取)。回归: 6 个新迁移测试 + 后端 66
passed + 前端 vitest 125 + tsc 0; 本机实启验证 66 策略/0 错误/不重复
种入(标记落盘)
This commit is contained in:
shy3130
2026-10-10 22:15:17 +08:00
parent 9fdf7266a3
commit da51ff5711
9 changed files with 444 additions and 12 deletions
+6 -1
View File
@@ -343,7 +343,12 @@ def list_strategies(
s = engine.get(sid)
overrides = all_overrides.get(sid)
result.append(_strategy_detail(s, overrides, engine))
return {"strategies": result, "load_errors": engine.load_errors()}
payload: dict = {"strategies": result, "load_errors": engine.load_errors()}
# 去内置化一次性迁移摘要 (启动时执行过才有): 前端提示一次后本地记忆
migration = getattr(request.app.state, "legacy_migration", None)
if migration:
payload["legacy_migration"] = migration
return payload
@router.get("/{strategy_id}")
+21
View File
@@ -263,6 +263,27 @@ async def _application_lifespan(app: FastAPI):
strategy_dirs=strategy_dirs,
override_loader=lambda sid: strategy_config.load_override(store.data_dir, sid),
)
# 去内置化一次性迁移: ①信号目录为空时种入 20 个默认定义;
# ②内置残留类加载失败 (composite 断链 / import 已删模块) 的策略文件
# 自动归档出扫描目录并重载。摘要挂到 app.state 供前端提示一次。
legacy_migration: dict = {}
try:
from app.strategy import custom_signals as _cs
from app.strategy.legacy_migrate import archive_unloadable_strategies
seeded = _cs.seed_if_empty(store.data_dir)
if seeded:
legacy_migration["seeded_signals"] = seeded
logger.info("legacy migrate: seeded %d default signals", seeded)
archived = archive_unloadable_strategies(strategy_engine.load_errors())
if archived:
strategy_engine.reload()
legacy_migration["archived_files"] = archived
logger.info("legacy migrate: archived %d unloadable files", len(archived))
if legacy_migration:
app.state.legacy_migration = legacy_migration
except Exception as e: # noqa: BLE001
logger.warning("legacy migrate failed (ignored): %s", e)
app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
+23
View File
@@ -755,6 +755,29 @@ def invalidate_intraday_cache() -> None:
_names_cache: dict[Path, tuple[object, dict[str, str]]] = {}
def seed_if_empty(data_dir: Path) -> int:
"""去内置化迁移: 信号目录为空且从未种子化时, 写入 20 个默认信号定义。
- 幂等: ``.seeded_v1`` 标记存在 (无论目录后来是否被用户清空) 不再种入,
用户删除全部信号后重启不会"复活"。
- 返回写入的定义数 (0 = 未执行)。
"""
from app.strategy.signal_seeds import SEED_SIGNALS
d = _dir(data_dir)
marker = d / ".seeded_v1"
if marker.exists():
return 0
if any(d.glob("*.json")):
# 用户已有信号定义 (如从旧环境拷贝): 不掺入种子, 仅落标记防重
marker.write_text("skipped: user signals present\n", encoding="utf-8")
return 0
for sig in SEED_SIGNALS:
save_one(data_dir, sig)
marker.write_text("seeded: 20\n", encoding="utf-8")
return len(SEED_SIGNALS)
def signal_names(data_dir: Path) -> dict[str, str]:
"""自定义信号列名 (csg_/csgi_) → 用户命名的映射, 带目录指纹缓存。
+66
View File
@@ -0,0 +1,66 @@
"""去内置化迁移 — 旧数据目录的一次性自动清理。
内置策略移除后, 老环境 data/strategies/ 里的部分用户文件加载失败
(composite 引用的内置子策略不存在、import 已删除的内置模块)。策略页
会逐文件报错刷屏。本模块在启动时把「不可恢复」的失败文件移入归档
目录 (引擎只扫顶层 *.py, 子目录不参与加载), 让报错消失、目录干净;
归档不删除, 用户可随时找回。
分类保守: 只归档明确由「内置世界残留」引起的失败, 语法错误等普通
失败仍走原有 load_errors 提示, 绝不误归档用户编辑中的文件。
"""
from __future__ import annotations
import logging
import re
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
ARCHIVE_DIRNAME = "_archive_unloadable"
# 归档条件: 错误信息命中任一模式 = 「内置残留」类失败
_ARCHIVE_PATTERNS = (
re.compile(r"app\.strategy\.builtin"), # import 已删除的内置模块 (垫片之外的)
re.compile(r"子策略.*不存在"), # composite 断链
re.compile(r"composite strategy.*不存在"),
re.compile(r"unknown strategy"), # META/引用指向不存在的策略
)
def is_legacy_unloadable(error: str) -> bool:
"""错误信息是否属于「内置残留」类不可恢复失败。"""
return any(p.search(error) for p in _ARCHIVE_PATTERNS)
def archive_unloadable_strategies(load_errors: list[dict]) -> list[str]:
"""把内置残留类失败文件移入 data/strategies/<dir>/_archive_unloadable/。
load_errors 为引擎 load_errors() 的 [{file, error}]; 返回被归档的
文件名列表 (空 = 无需归档)。文件缺失/移动失败时跳过并告警, 不抛错。
"""
archived: list[str] = []
for item in load_errors:
f = Path(str(item.get("file", "")))
error = str(item.get("error", ""))
if not f.exists() or not is_legacy_unloadable(error):
continue
# 只处理 data/strategies/ 下的文件 (研究模板等代码文件不动)
if "strategies" not in f.parts:
continue
try:
dest_dir = f.parent / ARCHIVE_DIRNAME
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f.name
# 同名碰撞: 追加序号, 不覆盖既有归档
i = 1
while dest.exists():
dest = dest_dir / f"{f.stem}_{i}{f.suffix}"
i += 1
shutil.move(str(f), str(dest))
archived.append(f.name)
logger.info("legacy migrate: 归档失效策略文件 %s (%s)", f.name, error[:120])
except OSError as e:
logger.warning("legacy migrate: 归档 %s 失败: %s", f, e)
return archived
+241
View File
@@ -0,0 +1,241 @@
"""去内置化迁移的默认信号种子 (一次性写入用户数据目录)。
仅在 ``data/user_data/custom_signals`` 为空且从未种子化时写入一次
(``.seeded_v1`` 标记防重); 写入后即为普通用户定义, 可编辑可删除。
单一事实源: tests/fixtures/custom_signals 的 JSON 与此同内容。
"""
from __future__ import annotations
SEED_SIGNALS: list[dict] = [
{'id': 'boll_breakdown_lower',
'name': '跌破布林下轨',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close',
'op': '<',
'right': 'field:boll_lower',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_boll_breakdown_lower'},
{'id': 'boll_breakout_upper',
'name': '突破布林上轨',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close',
'op': '>',
'right': 'field:boll_upper',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_boll_breakout_upper'},
{'id': 'broken_limit_up',
'name': '炸板',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'raw_high', 'op': '>', 'right': '0', 'leftDays': 0, 'rightDays': 0},
{'left': 'raw_close',
'op': '<',
'right': 'field:limit_up_price',
'leftDays': 0,
'rightDays': 0},
{'left': 'raw_high',
'op': '>=',
'right': 'field:limit_up_price',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_broken_limit_up'},
{'id': 'limit_down',
'name': '跌停',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'raw_close', 'op': '>', 'right': '0', 'leftDays': 0, 'rightDays': 0},
{'left': 'raw_close',
'op': '<=',
'right': 'field:limit_down_price',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_limit_down'},
{'id': 'limit_down_recovery',
'name': '跌停翘板',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'raw_low', 'op': '>', 'right': '0', 'leftDays': 0, 'rightDays': 0},
{'left': 'raw_close',
'op': '>',
'right': 'field:limit_down_price',
'leftDays': 0,
'rightDays': 0},
{'left': 'raw_low',
'op': '<=',
'right': 'field:limit_down_price',
'leftDays': 0,
'rightDays': 0},
{'left': 'close', 'op': '>', 'right': 'field:open', 'leftDays': 0, 'rightDays': 0}],
'column': 'signal_limit_down_recovery'},
{'id': 'limit_up',
'name': '涨停',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'raw_close', 'op': '>', 'right': '0', 'leftDays': 0, 'rightDays': 0},
{'left': 'raw_close',
'op': '>=',
'right': 'field:limit_up_price',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_limit_up'},
{'id': 'ma10_breakdown',
'name': '跌破MA10',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close', 'op': '<', 'right': 'field:ma10', 'leftDays': 0, 'rightDays': 0},
{'left': 'close',
'op': '>=',
'right': 'field:ma10',
'leftDays': 1,
'rightDays': 1}],
'column': 'signal_ma10_breakdown'},
{'id': 'ma10_breakout',
'name': '突破MA10',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close', 'op': '>', 'right': 'field:ma10', 'leftDays': 0, 'rightDays': 0},
{'left': 'close',
'op': '<=',
'right': 'field:ma10',
'leftDays': 1,
'rightDays': 1}],
'column': 'signal_ma10_breakout'},
{'id': 'ma20_breakdown',
'name': '跌破MA20',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close', 'op': '<', 'right': 'field:ma20', 'leftDays': 0, 'rightDays': 0},
{'left': 'close',
'op': '>=',
'right': 'field:ma20',
'leftDays': 1,
'rightDays': 1}],
'column': 'signal_ma20_breakdown'},
{'id': 'ma20_breakout',
'name': '突破MA20',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close', 'op': '>', 'right': 'field:ma20', 'leftDays': 0, 'rightDays': 0},
{'left': 'close',
'op': '<=',
'right': 'field:ma20',
'leftDays': 1,
'rightDays': 1}],
'column': 'signal_ma20_breakout'},
{'id': 'ma5_breakdown',
'name': '跌破MA5',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close', 'op': '<', 'right': 'field:ma5', 'leftDays': 0, 'rightDays': 0},
{'left': 'close', 'op': '>=', 'right': 'field:ma5', 'leftDays': 1, 'rightDays': 1}],
'column': 'signal_ma5_breakdown'},
{'id': 'ma5_breakout',
'name': '突破MA5',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close', 'op': '>', 'right': 'field:ma5', 'leftDays': 0, 'rightDays': 0},
{'left': 'close', 'op': '<=', 'right': 'field:ma5', 'leftDays': 1, 'rightDays': 1}],
'column': 'signal_ma5_breakout'},
{'id': 'ma_dead_5_20',
'name': 'MA5下穿MA20',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'ma5', 'op': '<', 'right': 'field:ma20', 'leftDays': 0, 'rightDays': 0},
{'left': 'ma5', 'op': '>=', 'right': 'field:ma20', 'leftDays': 1, 'rightDays': 1}],
'column': 'signal_ma_dead_5_20'},
{'id': 'ma_golden_20_60',
'name': 'MA20上穿MA60',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'ma20', 'op': '>', 'right': 'field:ma60', 'leftDays': 0, 'rightDays': 0},
{'left': 'ma20', 'op': '<=', 'right': 'field:ma60', 'leftDays': 1, 'rightDays': 1}],
'column': 'signal_ma_golden_20_60'},
{'id': 'ma_golden_5_20',
'name': 'MA5上穿MA20',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'ma5', 'op': '>', 'right': 'field:ma20', 'leftDays': 0, 'rightDays': 0},
{'left': 'ma5', 'op': '<=', 'right': 'field:ma20', 'leftDays': 1, 'rightDays': 1}],
'column': 'signal_ma_golden_5_20'},
{'id': 'macd_dead',
'name': 'MACD死叉',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'macd_dif',
'op': '<',
'right': 'field:macd_dea',
'leftDays': 0,
'rightDays': 0},
{'left': 'macd_dif',
'op': '>=',
'right': 'field:macd_dea',
'leftDays': 1,
'rightDays': 1}],
'column': 'signal_macd_dead'},
{'id': 'macd_golden',
'name': 'MACD金叉',
'kind': 'both',
'conditions': [{'left': 'macd_dif',
'op': '>',
'right': 'field:macd_dea',
'leftDays': 0,
'rightDays': 0},
{'left': 'macd_dif',
'op': '<=',
'right': 'field:macd_dea',
'leftDays': 1,
'rightDays': 1}],
'enabled': True,
'timeframe': 'daily',
'min_bars': 0,
'column': 'signal_macd_golden'},
{'id': 'n_day_high',
'name': '60日新高',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close',
'op': '>=',
'right': 'field:high_60d',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_n_day_high'},
{'id': 'n_day_low',
'name': '60日新低',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'close',
'op': '<=',
'right': 'field:low_60d',
'leftDays': 0,
'rightDays': 0}],
'column': 'signal_n_day_low'},
{'id': 'volume_surge',
'name': '放量',
'kind': 'both',
'timeframe': 'daily',
'enabled': True,
'conditions': [{'left': 'vol_ratio_5d', 'op': '>=', 'right': '2', 'leftDays': 0, 'rightDays': 0}],
'column': 'signal_volume_surge'},
]
+4 -4
View File
@@ -14,10 +14,10 @@ FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "custom_signals"
def load_seed_definitions() -> list[dict]:
return [
json.loads(p.read_text(encoding="utf-8"))
for p in sorted(FIXTURE_DIR.glob("*.json"))
]
"""单一事实源: app.strategy.signal_seeds (fixtures JSON 与其同内容)。"""
from app.strategy.signal_seeds import SEED_SIGNALS
return SEED_SIGNALS
def install_pipeline_caches(monkeypatch) -> list[dict]:
+56 -5
View File
@@ -1,10 +1,11 @@
"""旧数据目录升级的宽容降级 — 去内置化迁移配套。
"""旧数据目录升级的宽容降级与一次性自动迁移 — 去内置化配套。
内置策略移除后, 老环境的用户数据 (data/strategies/ 里的自定义文件、浏览器
策略池) 仍引用旧世界: 文件 import ``app.strategy.builtin.factor_rank_research``
断链逐条 toast; 策略池残留内置 ID 让 /run_all 整体 404。此处回归:
内置策略移除后, 老环境的用户数据仍引用旧世界。此处回归:
1. builtin.factor_rank_research 兼容垫片透明转发到 research 模块;
2. /run_all 混合未知 ID → 跳过并回 skipped_unknown; 全部未知才 404。
2. /run_all 混合未知 ID → 跳过并回 skipped_unknown; 全部未知才 404;
3. 信号种子 seed_if_empty: 空目录一次写入 20 个默认定义, 标记防重,
用户已有定义/再次启动不重复种入;
4. 失效策略文件归档: 仅「内置残留」类错误归档, 语法错误不动。
"""
from __future__ import annotations
@@ -93,3 +94,53 @@ def test_run_all_all_unknown_still_404(monkeypatch, tmp_path):
with pytest.raises(HTTPException) as exc:
screener_api.run_all(request, {"strategy_ids": ["gone_only"], "as_of": "2026-07-15"})
assert exc.value.status_code == 404
# ── 信号种子一次性迁移 ─────────────────────────────────────
def test_seed_if_empty_writes_once_and_marks(tmp_path):
from app.strategy import custom_signals
n = custom_signals.seed_if_empty(tmp_path)
assert n == 20
files = list((tmp_path / "user_data" / "custom_signals").glob("*.json"))
assert len(files) == 20
# 标记存在 → 二次调用不再种入 (即使清空目录也不复活)
for f in files:
f.unlink()
assert custom_signals.seed_if_empty(tmp_path) == 0
assert not list((tmp_path / "user_data" / "custom_signals").glob("*.json"))
def test_seed_if_empty_skips_when_user_signals_present(tmp_path):
from app.strategy import custom_signals
d = tmp_path / "user_data" / "custom_signals"
d.mkdir(parents=True)
(d / "my_own.json").write_text("{}", encoding="utf-8")
assert custom_signals.seed_if_empty(tmp_path) == 0
assert [f.name for f in d.glob("*.json")] == ["my_own.json"]
# ── 失效策略文件归档 ───────────────────────────────────────
def test_archive_only_legacy_unloadable_files(tmp_path):
from app.strategy.legacy_migrate import archive_unloadable_strategies
custom = tmp_path / "data" / "strategies" / "custom"
custom.mkdir(parents=True)
broken_composite = custom / "combo_old.py"
broken_composite.write_text("# composite", encoding="utf-8")
syntax_err = custom / "editing_wip.py"
syntax_err.write_text("# wip", encoding="utf-8")
archived = archive_unloadable_strategies([
{"file": str(broken_composite), "error": "composite strategy combo_old 引用的子策略 'boll_breakout' 不存在"},
{"file": str(syntax_err), "error": "SyntaxError: unexpected EOF"},
{"file": str(custom / "missing.py"), "error": "composite strategy x 引用的子策略 'y' 不存在"},
])
assert archived == ["combo_old.py"]
assert not broken_composite.exists()
assert syntax_err.exists() # 普通失败不归档
archive_dir = custom / "_archive_unloadable"
assert (archive_dir / "combo_old.py").exists()
+6 -2
View File
@@ -2842,10 +2842,14 @@ export const api = {
// timeframe='all' 时不传参数 → 后端不过滤周期, 返回日线+分钟合并列表
screenerStrategies: async (assetType?: 'stock' | 'etf' | 'index', timeframe: '1d' | '1m' | 'all' = '1d') => {
const data = await request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>(
const data = await request<{
strategies: StrategyDetail[]
load_errors?: StrategyLoadError[]
legacy_migration?: { seeded_signals?: number; archived_files?: string[] }
}>(
`/api/strategies?${assetType ? `asset_type=${assetType}&` : ''}${timeframe !== 'all' ? `timeframe=${timeframe}` : ''}`,
)
return { presets: data.strategies, load_errors: data.load_errors }
return { presets: data.strategies, load_errors: data.load_errors, legacy_migration: data.legacy_migration }
},
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string, assetType: 'stock' | 'etf' = 'stock', timeframe: '1d' | '1m' = '1d') =>
request<ScreenerResult>('/api/screener/run_preset', {
+21
View File
@@ -265,6 +265,27 @@ export function Screener() {
prune(allStrategyIds)
}, [allStrategyIds, prune, strategies.isError, strategies.isSuccess])
// 去内置化一次性迁移提示: 后端启动时自动种入默认信号/归档失效策略,
// 摘要只在首次见到时提示一次 (localStorage 记录迁移指纹)。
const legacyMigration = strategies.data?.legacy_migration
useEffect(() => {
if (!legacyMigration) return
const fingerprint = JSON.stringify(legacyMigration)
const key = 'tsp-legacy-migration-v1'
try {
if (localStorage.getItem(key) === fingerprint) return
localStorage.setItem(key, fingerprint)
} catch { /* 隐私模式等存储不可用: 退化为每次提示 */ }
const parts: string[] = []
if (legacyMigration.seeded_signals) {
parts.push(`已将 ${legacyMigration.seeded_signals} 个原内置信号转为自定义信号(可在信号库编辑)`)
}
if (legacyMigration.archived_files?.length) {
parts.push(`已归档 ${legacyMigration.archived_files.length} 个无法加载的旧策略文件(位于 data/strategies/*/_archive_unloadable/)`)
}
if (parts.length) toast(`旧版数据自动迁移完成:${parts.join(';')}`, 'success')
}, [legacyMigration])
// 策略文件加载失败时提示用户(避免"策略静默消失"被误判为正常)。
// 多个失败聚合为一条, 避免旧数据目录升级后 (内置模块移除/叠加断链) 刷屏。
const loadErrors = strategies.data?.load_errors ?? []