mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
修复策略文件无法删除问题
This commit is contained in:
+83
-18
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from dataclasses import asdict
|
||||
@@ -25,6 +26,7 @@ from app.strategy.monitor import StrategyMonitorService
|
||||
from app.strategy.prompt_builder import build_step1, build_step2
|
||||
|
||||
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +58,57 @@ def _invalidate_strategy_runtime(request: Request) -> None:
|
||||
monitor_engine.invalidate_strategy_state()
|
||||
|
||||
|
||||
def _cleanup_deleted_strategy(request: Request, strategy_id: str) -> list[str]:
|
||||
"""尽力清理删除后的派生状态, 清理失败不应把已成功的源文件删除变成 500。"""
|
||||
from app.services import preferences
|
||||
from app.strategy import monitor_rules
|
||||
|
||||
data_dir = _data_dir(request)
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
strategy_config.delete_override(data_dir, strategy_id)
|
||||
except Exception as e:
|
||||
warnings.append(f"覆盖配置清理失败: {e}")
|
||||
|
||||
try:
|
||||
_invalidate_strategy_runtime(request)
|
||||
except Exception as e:
|
||||
warnings.append(f"运行缓存清理失败: {e}")
|
||||
|
||||
try:
|
||||
monitored_ids = preferences.get_strategy_monitor_ids()
|
||||
if strategy_id in monitored_ids:
|
||||
preferences.set_realtime_monitor_config({
|
||||
"strategy_monitor_ids": [sid for sid in monitored_ids if sid != strategy_id],
|
||||
})
|
||||
except Exception as e:
|
||||
warnings.append(f"监控偏好清理失败: {e}")
|
||||
|
||||
try:
|
||||
rules_changed = False
|
||||
for rule in monitor_rules.load_all(data_dir):
|
||||
if (
|
||||
rule.get("type") == "strategy"
|
||||
and rule.get("strategy_id") == strategy_id
|
||||
and rule.get("enabled", True)
|
||||
):
|
||||
rule = dict(rule)
|
||||
rule["enabled"] = False
|
||||
monitor_rules.save_one(data_dir, rule)
|
||||
rules_changed = True
|
||||
|
||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
||||
if rules_changed and monitor_engine is not None:
|
||||
monitor_engine.set_rules(monitor_rules.load_all(data_dir))
|
||||
except Exception as e:
|
||||
warnings.append(f"关联监控清理失败: {e}")
|
||||
|
||||
for warning in warnings:
|
||||
logger.warning("delete strategy %s: %s", strategy_id, warning)
|
||||
return warnings
|
||||
|
||||
|
||||
def _safe(result_dict: dict) -> dict:
|
||||
rows = result_dict.get("rows", [])
|
||||
for r in rows:
|
||||
@@ -763,34 +816,46 @@ async def ai_save(req: AISaveRequest, request: Request):
|
||||
|
||||
@router.delete("/{strategy_id}")
|
||||
def delete_strategy(strategy_id: str, request: Request):
|
||||
"""删除自定义策略 — 清除 .py 文件 + overrides + 热重载。内置策略不可删除。"""
|
||||
"""删除自定义策略 — 清除源文件、运行时注册和关联状态。内置策略不可删除。"""
|
||||
|
||||
engine = _get_engine(request)
|
||||
try:
|
||||
s = engine.get(strategy_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在") from e
|
||||
|
||||
if s.source == "builtin":
|
||||
raise HTTPException(status_code=403, detail="内置策略不可删除")
|
||||
|
||||
data_dir = _data_dir(request)
|
||||
path = s.file_path
|
||||
previous_code = path.read_text(encoding="utf-8") if path and path.exists() else None
|
||||
if path and path.exists():
|
||||
path.unlink()
|
||||
try:
|
||||
engine.reload()
|
||||
except Exception as e:
|
||||
if path is not None and previous_code is not None:
|
||||
path.write_text(previous_code, encoding="utf-8")
|
||||
engine.reload()
|
||||
raise HTTPException(status_code=400, detail=f"策略删除失败: {e}") from e
|
||||
data_dir = _data_dir(request)
|
||||
if path is None or s.source not in {"custom", "ai"}:
|
||||
raise HTTPException(status_code=400, detail="策略源文件路径无效, 无法删除")
|
||||
|
||||
override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
|
||||
override_path.unlink(missing_ok=True)
|
||||
_invalidate_strategy_runtime(request)
|
||||
return {"ok": True}
|
||||
try:
|
||||
allowed_dir = (data_dir / "strategies" / s.source).resolve()
|
||||
resolved_path = path.resolve()
|
||||
except (OSError, RuntimeError) as e:
|
||||
raise HTTPException(status_code=409, detail=f"无法访问策略文件: {e}") from e
|
||||
if not resolved_path.is_relative_to(allowed_dir):
|
||||
raise HTTPException(status_code=400, detail="策略源文件不在用户策略目录, 拒绝删除")
|
||||
|
||||
try:
|
||||
resolved_path.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
reason = e.strerror or str(e)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"无法删除策略文件 {resolved_path.name}: {reason}。"
|
||||
"请确认数据目录可写; Docker 部署请检查数据卷不是只读挂载。"
|
||||
),
|
||||
) from e
|
||||
|
||||
# 删除只影响当前策略, 全量 reload 会让其他损坏或重复 ID 的文件阻塞本次删除。
|
||||
engine.unregister(strategy_id)
|
||||
warnings = _cleanup_deleted_strategy(request, strategy_id)
|
||||
return {"ok": True, "warnings": warnings}
|
||||
|
||||
|
||||
# ── 监控 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -419,6 +419,17 @@ class StrategyEngine:
|
||||
def has(self, strategy_id: str) -> bool:
|
||||
return strategy_id in self._strategies
|
||||
|
||||
def unregister(self, strategy_id: str) -> bool:
|
||||
"""从运行时注册表移除单个策略, 不重新加载其他策略文件。"""
|
||||
if strategy_id not in self._strategies:
|
||||
return False
|
||||
strategies = dict(self._strategies)
|
||||
strategies.pop(strategy_id)
|
||||
self._strategies = strategies
|
||||
with self._realtime_matrix_lock:
|
||||
self._realtime_matrices.clear()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def validate_context(strategy: StrategyDef, context: StrategyDataContext) -> None:
|
||||
asset_types = strategy.meta.get("asset_types", ["stock"])
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.strategy import delete_strategy
|
||||
from app.services import preferences
|
||||
from app.strategy import monitor_rules
|
||||
from app.strategy.engine import StrategyEngine
|
||||
|
||||
|
||||
def _strategy_code(strategy_id: str) -> str:
|
||||
return f'''import polars as pl
|
||||
META = {{
|
||||
"id": "{strategy_id}",
|
||||
"name": "{strategy_id}",
|
||||
"asset_types": ["stock"],
|
||||
"timeframes": ["1d"],
|
||||
}}
|
||||
def filter(df, params):
|
||||
return pl.lit(True)
|
||||
'''
|
||||
|
||||
|
||||
class _MonitorEngine:
|
||||
def __init__(self) -> None:
|
||||
self.invalidations = 0
|
||||
self.rules: list[dict] = []
|
||||
|
||||
def invalidate_strategy_state(self) -> None:
|
||||
self.invalidations += 1
|
||||
|
||||
def set_rules(self, rules: list[dict]) -> None:
|
||||
self.rules = rules
|
||||
|
||||
|
||||
def _request(data_dir: Path, engine: StrategyEngine, monitor: _MonitorEngine | None = None):
|
||||
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
||||
state = SimpleNamespace(repo=repo, strategy_engine=engine, monitor_engine=monitor)
|
||||
return SimpleNamespace(app=SimpleNamespace(state=state))
|
||||
|
||||
|
||||
def test_delete_strategy_is_not_blocked_by_another_broken_file(monkeypatch, tmp_path):
|
||||
custom_dir = tmp_path / "strategies" / "custom"
|
||||
custom_dir.mkdir(parents=True)
|
||||
strategy_path = custom_dir / "target.py"
|
||||
strategy_path.write_text(_strategy_code("target"), encoding="utf-8")
|
||||
engine = StrategyEngine(strategy_dirs=[custom_dir])
|
||||
|
||||
# 模拟用户目录中另有一份损坏策略。旧删除逻辑的全量 reload 会因此回滚并返回 500。
|
||||
broken_path = custom_dir / "broken.py"
|
||||
broken_path.write_text("this is not valid python", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="strategy reload failed"):
|
||||
engine.reload()
|
||||
assert engine.has("target")
|
||||
|
||||
override_path = tmp_path / "user_data" / "strategy_overrides" / "target.json"
|
||||
override_path.parent.mkdir(parents=True)
|
||||
override_path.write_text("{}", encoding="utf-8")
|
||||
cache_path = tmp_path / "user_data" / "strategy_cache.json"
|
||||
cache_path.write_text("{}", encoding="utf-8")
|
||||
|
||||
rule = monitor_rules.normalize({
|
||||
"id": "mr_target",
|
||||
"name": "策略监控 · target",
|
||||
"type": "strategy",
|
||||
"strategy_id": "target",
|
||||
"scope": "all",
|
||||
"conditions": [],
|
||||
})
|
||||
monitor_rules.save_one(tmp_path, rule)
|
||||
|
||||
preference_updates: list[dict] = []
|
||||
monkeypatch.setattr(preferences, "get_strategy_monitor_ids", lambda: ["target", "other"])
|
||||
monkeypatch.setattr(
|
||||
preferences,
|
||||
"set_realtime_monitor_config",
|
||||
lambda config: preference_updates.append(config) or config,
|
||||
)
|
||||
monitor = _MonitorEngine()
|
||||
|
||||
result = delete_strategy("target", _request(tmp_path, engine, monitor))
|
||||
|
||||
assert result == {"ok": True, "warnings": []}
|
||||
assert not strategy_path.exists()
|
||||
assert broken_path.exists()
|
||||
assert not engine.has("target")
|
||||
assert not override_path.exists()
|
||||
assert not cache_path.exists()
|
||||
assert preference_updates == [{"strategy_monitor_ids": ["other"]}]
|
||||
saved_rule = monitor_rules.load_one(tmp_path, "mr_target")
|
||||
assert saved_rule is not None and saved_rule["enabled"] is False
|
||||
assert monitor.invalidations == 1
|
||||
assert monitor.rules and monitor.rules[0]["enabled"] is False
|
||||
|
||||
|
||||
def test_delete_strategy_reports_read_only_volume_without_unregistering(monkeypatch, tmp_path):
|
||||
custom_dir = tmp_path / "strategies" / "custom"
|
||||
custom_dir.mkdir(parents=True)
|
||||
strategy_path = custom_dir / "target.py"
|
||||
strategy_path.write_text(_strategy_code("target"), encoding="utf-8")
|
||||
engine = StrategyEngine(strategy_dirs=[custom_dir])
|
||||
request = _request(tmp_path, engine)
|
||||
|
||||
original_unlink = Path.unlink
|
||||
|
||||
def blocked_unlink(path: Path, *args, **kwargs):
|
||||
if path == strategy_path:
|
||||
raise PermissionError(30, "Read-only file system", str(path))
|
||||
return original_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", blocked_unlink)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
delete_strategy("target", request)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "Docker" in exc_info.value.detail
|
||||
assert "只读挂载" in exc_info.value.detail
|
||||
assert strategy_path.exists()
|
||||
assert engine.has("target")
|
||||
Reference in New Issue
Block a user