mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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}")
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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_) → 用户命名的映射, 带目录指纹缓存。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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'},
|
||||
]
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
Reference in New Issue
Block a user