Merge pull request #255 from SummerCaptain/feat/ai-strategy-draft-gate

feat(strategy): AI 策略保存为 research_only 草稿 + publish 端点
This commit is contained in:
wshy
2026-09-06 20:53:25 +08:00
committed by GitHub
7 changed files with 434 additions and 36 deletions
+84 -3
View File
@@ -188,6 +188,7 @@ def _strategy_detail(
"description": description or s.meta.get("description", ""),
"tags": s.meta.get("tags", []),
"source": s.source,
"research_only": s.meta.get("research_only", False),
"execution_backend": s.execution_backend,
"asset_types": s.meta.get("asset_types", ["stock"]),
"timeframes": s.meta.get("timeframes", ["1d"]),
@@ -307,14 +308,17 @@ def list_strategies(
request: Request,
asset_type: str | None = None,
timeframe: str | None = None,
include_research: bool = False,
):
engine = _get_engine(request)
data_dir = _data_dir(request)
all_overrides = strategy_config.list_overrides(data_dir)
result = []
for meta in engine.list_strategies():
if meta.get("research_only"):
# include_research=True 时返回 research_only 草稿(供前端「草稿」分区展示/发布)。
# 默认 False 保持既有行为: 草稿不进公开列表。
for meta in engine.list_strategies(include_research=include_research):
if meta.get("research_only") and not include_research:
continue
if asset_type and asset_type not in meta.get("asset_types", ["stock"]):
continue
@@ -560,7 +564,11 @@ def _set_meta_string_field(block: str, field: str, value: str) -> str:
)
if count:
return next_block
return _insert_meta_field(block, field, _py_string(value))
def _insert_meta_field(block: str, field: str, value_repr: str) -> str:
"""在 META 字典末尾(闭合 `}` 之前)插入一个字段。value_repr 已是 Python 源码。"""
lines = block.splitlines(keepends=True)
key_indent = None
for line in lines:
@@ -585,7 +593,33 @@ def _set_meta_string_field(block: str, field: str, value: str) -> str:
newline = lines[i][len(body):]
lines[i] = body.rstrip() + "," + newline
break
lines.insert(insert_at, f'{key_indent}"{field}": {_py_string(value)},\n')
lines.insert(insert_at, f'{key_indent}"{field}": {value_repr},\n')
return "".join(lines)
def _set_meta_bool_field(code: str, field: str, value: bool) -> str:
"""设置 META 里的布尔字段(纯文本改写, 不执行代码): 存在则替换, 不存在则追加。"""
found = find_meta_assignment(code)
if found is None:
raise ValueError("找不到 META 字典")
meta_node = found[1]
lines = code.splitlines(keepends=True)
start = meta_node.lineno - 1
end = meta_node.end_lineno or meta_node.lineno
block = "".join(lines[start:end])
value_repr = "True" if value else "False"
key_pattern = re.compile(
rf"(?m)^(\s*[\"']{re.escape(field)}[\"']\s*:\s*)(?:True|False|[\"'][^\"'\n]*[\"'])"
)
next_block, count = key_pattern.subn(
lambda m: f"{m.group(1)}{value_repr}",
block,
count=1,
)
if not count:
next_block = _insert_meta_field(block, field, value_repr)
lines[start:end] = next_block.splitlines(keepends=True)
return "".join(lines)
@@ -732,6 +766,13 @@ def _save_strategy_code(req: StrategyCodeSaveRequest, request: Request, *, legac
path.parent.mkdir(parents=True, exist_ok=True)
prepared = _prepare_strategy_code(req)
# AI 新建策略默认草稿态(research_only=True): 不进公开列表、不可运行, 需显式 publish。
# 仅 create 注入; update 保留既有 research_only, 避免静默取消已发布状态。
if expected_source == "ai" and (legacy_ai_path or req.mode == "create"):
prepared["code"] = _set_meta_bool_field(prepared["code"], "research_only", True)
prepared["meta"] = AIStrategyGenerator._extract_meta(prepared["code"])
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
path.write_text(prepared["code"], encoding="utf-8")
@@ -763,6 +804,7 @@ def _save_strategy_code(req: StrategyCodeSaveRequest, request: Request, *, legac
"source": expected_source,
"path": str(path),
"meta": prepared["meta"],
"research_only": prepared["meta"].get("research_only", False),
}
@@ -1062,6 +1104,45 @@ async def ai_save(req: AISaveRequest, request: Request):
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/{strategy_id}/publish")
def publish_ai_strategy(strategy_id: str, request: Request):
"""把 research_only 的 AI 草稿策略翻转为公开(research_only=False)。
门 = 人的显式动作: 只有 AI 来源且仍处于草稿态的策略才能被发布。
发布后即进入公开列表、可 run、可监控。
"""
sid = _validate_strategy_id(strategy_id)
engine = _get_engine(request)
try:
s = engine.get(sid)
except ValueError as e:
raise HTTPException(status_code=404, detail=f"策略 {sid} 不存在") from e
if s.source != "ai":
raise HTTPException(status_code=400, detail="仅 AI 策略可经发布端点上线")
if not s.meta.get("research_only"):
raise HTTPException(status_code=400, detail="该策略已是公开状态")
path = s.file_path
if path is None:
raise HTTPException(status_code=400, detail="策略源文件路径无效, 无法发布")
previous_code = path.read_text(encoding="utf-8")
path.write_text(_set_meta_bool_field(previous_code, "research_only", False), encoding="utf-8")
try:
engine.reload()
loaded = engine.get(sid)
if loaded.meta.get("research_only"):
raise ValueError("发布后策略仍为草稿态")
except Exception as e:
_restore_strategy_file(path, previous_code)
engine.reload()
raise HTTPException(status_code=500, detail=f"策略发布失败: {e}") from e
_invalidate_strategy_runtime(request)
return {"ok": True, "strategy_id": sid}
@router.delete("/{strategy_id}")
def delete_strategy(strategy_id: str, request: Request):
"""删除自定义策略 — 清除源文件、运行时注册和关联状态。内置策略不可删除。"""
+88
View File
@@ -0,0 +1,88 @@
"""AI 策略草稿门最小 demo — 验证「保存即草稿 → 显式 publish → 才公开」。
运行方式(在 backend/ 目录下, 已安装依赖):
python -m scripts.demo_ai_strategy_gate
不启动服务, 直接调用内部函数(与单测同款 SimpleNamespace 请求桩), 全程落在临时目录。
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from types import SimpleNamespace
from app.api.strategy import (
StrategyCodeSaveRequest,
_save_strategy_code,
publish_ai_strategy,
)
from app.strategy.engine import StrategyEngine
_CODE = '''"""demo 策略"""
import polars as pl
META = {
"id": "ai_demo",
"name": "demo",
"description": "demo",
"tags": [],
"params": [],
"scoring": {},
}
ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return pl.lit(True)
'''
def _request(data_dir: Path, engine: StrategyEngine):
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine)))
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
engine = StrategyEngine(strategy_dirs=[data_dir / "strategies" / "custom",
data_dir / "strategies" / "ai"])
request = _request(data_dir, engine)
print("== 1. 保存 AI 策略(默认草稿) ==")
result = _save_strategy_code(StrategyCodeSaveRequest(
strategy_id="ai_demo", target_source="ai", mode="create",
code=_CODE, name="demo",
), request)
public = [m["id"] for m in engine.list_strategies() if not m.get("research_only")]
print(f" research_only={result['research_only']} (期望 True)")
print(f" 公开列表={public} (期望不含 ai_demo)")
print("== 2. 显式 publish ==")
print(" ", publish_ai_strategy("ai_demo", request))
print(f" research_only={engine.get('ai_demo').meta['research_only']} (期望 False)")
print("== 3. 重复 publish 应被拒 ==")
try:
publish_ai_strategy("ai_demo", request)
except Exception as exc: # noqa: BLE001
print(f" 被拒: {exc}")
print("== 4. 自定义策略 publish 应被拒 ==")
_save_strategy_code(StrategyCodeSaveRequest(
strategy_id="custom_demo", target_source="custom", mode="create",
code=_CODE.replace("ai_demo", "custom_demo"), name="custom",
), request)
try:
publish_ai_strategy("custom_demo", request)
except Exception as exc: # noqa: BLE001
print(f" 被拒: {exc}")
print("\n全部通过")
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
"""AI 策略草稿门测试 — 保存即 research_only 草稿, 显式 publish 才公开。
覆盖:
1. AI 策略保存后为草稿态(research_only=True), 不进公开列表
2. 自定义策略不受门控(零回归)
3. publish 翻转草稿 → 公开
4. publish 拒绝非 AI 策略 / 已公开策略
5. _set_meta_bool_field 的插入与替换两条路径
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api.strategy import (
StrategyCodeSaveRequest,
_save_strategy_code,
_set_meta_bool_field,
publish_ai_strategy,
)
from app.strategy.ai_generator import AIStrategyGenerator
from app.strategy.engine import StrategyEngine
def _code(strategy_id: str, name: str = "测试策略") -> str:
return f'''"""测试策略"""
import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{name}",
"description": "测试描述",
"tags": ["测试"],
"params": [],
"scoring": {{}},
}}
ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
RULES = """
1. 测试规则一
2. 测试规则二
3. 测试规则三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return pl.lit(True)
'''
def _request(tmp_path):
ai_dir = tmp_path / "strategies" / "ai"
custom_dir = tmp_path / "strategies" / "custom"
engine = StrategyEngine(strategy_dirs=[custom_dir, ai_dir])
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine)))
def _save_ai(tmp_path, sid: str) -> dict:
"""保存一个 AI 来源的新建策略, 返回 (request, save_result)。"""
request = _request(tmp_path)
req = StrategyCodeSaveRequest(
strategy_id=sid,
target_source="ai",
mode="create",
code=_code("wrong"),
name="AI 草稿",
)
return request, _save_strategy_code(req, request)
def test_ai_strategy_saved_as_research_only_draft(tmp_path):
request, result = _save_ai(tmp_path, "ai_draft")
assert result["ok"] is True
assert result["research_only"] is True
assert request.app.state.strategy_engine.get("ai_draft").meta["research_only"] is True
# 草稿态不进公开列表(list_strategies 对 research_only 过滤)
public_ids = {
meta["id"]
for meta in request.app.state.strategy_engine.list_strategies()
if not meta.get("research_only")
}
assert "ai_draft" not in public_ids
def test_custom_strategy_not_gated(tmp_path):
request = _request(tmp_path)
req = StrategyCodeSaveRequest(
strategy_id="custom_draft",
target_source="custom",
mode="create",
code=_code("wrong"),
name="自定义策略",
)
result = _save_strategy_code(req, request)
assert result["research_only"] is False
assert request.app.state.strategy_engine.get("custom_draft").meta.get("research_only") is not True
def test_publish_ai_strategy_flips_to_public(tmp_path):
request, _ = _save_ai(tmp_path, "ai_draft")
result = publish_ai_strategy("ai_draft", request)
assert result == {"ok": True, "strategy_id": "ai_draft"}
assert request.app.state.strategy_engine.get("ai_draft").meta["research_only"] is False
def test_publish_rejects_non_ai_strategy(tmp_path):
request = _request(tmp_path)
req = StrategyCodeSaveRequest(
strategy_id="custom_pub",
target_source="custom",
mode="create",
code=_code("wrong"),
name="自定义策略",
)
_save_strategy_code(req, request)
with pytest.raises(HTTPException) as exc_info:
publish_ai_strategy("custom_pub", request)
assert exc_info.value.status_code == 400
assert "AI 策略" in exc_info.value.detail
def test_publish_rejects_already_public(tmp_path):
request, _ = _save_ai(tmp_path, "ai_draft")
publish_ai_strategy("ai_draft", request)
with pytest.raises(HTTPException) as exc_info:
publish_ai_strategy("ai_draft", request)
assert exc_info.value.status_code == 400
assert "已是公开状态" in exc_info.value.detail
def test_set_meta_bool_field_insert_then_replace():
code = 'META = {\n "id": "x",\n}\n'
inserted = _set_meta_bool_field(code, "research_only", True)
assert '"research_only": True' in inserted
assert AIStrategyGenerator._extract_meta(inserted)["research_only"] is True
replaced = _set_meta_bool_field(inserted, "research_only", False)
assert '"research_only": False' in replaced
assert '"research_only": True' not in replaced
assert AIStrategyGenerator._extract_meta(replaced)["research_only"] is False
@@ -180,7 +180,7 @@ MATRIX_STRATEGY = CustomMatrixStrategy()
interface Props {
open: boolean
onClose: () => void
onSavedId?: (id: string) => void | Promise<void>
onSavedId?: (id: string, researchOnly?: boolean) => void | Promise<void>
mode?: 'create' | 'modify'
existingStrategyIds?: ReadonlySet<string>
}
@@ -374,7 +374,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
const target = mode === 'modify' ? source : (tab === 'custom' ? 'custom' : 'ai')
const id = resolveStrategyId(target)
setStrategyId(id); setSource(target)
await api.strategySaveCodeV2({
const savedResult = await api.strategySaveCodeV2({
strategy_id: id,
code: draftCode,
target_source: target,
@@ -387,7 +387,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
const genRules = parseRules(draftCode)
const finalRules = (genRules || rules).trim()
if (finalRules) { const saved = storage.strategyRules.get({}); saved[id] = finalRules; storage.strategyRules.set(saved) }
await onSavedId?.(id)
await onSavedId?.(id, savedResult.research_only)
setTimeout(() => onClose(), 1000)
} catch (e: any) { setError(String(e?.message ?? '保存失败')) }
setSaving(false)
@@ -54,13 +54,15 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
const [importing, setImporting] = useState(false)
const [importError, setImportError] = useState('')
const [importMsg, setImportMsg] = useState('')
const [publishingId, setPublishingId] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const loadStrategies = useCallback(async () => {
setLoading(true)
try {
// 不按周期过滤: 日线+分钟策略合并展示, 分钟策略以徽章区分
const d = await api.strategyList(undefined, 'all')
// include_research=true 同时拉取 research_only 草稿, 供 AI 标签「草稿」分区展示/发布
const d = await api.strategyList(undefined, 'all', true)
setAllStrategies(d.strategies)
} catch {
setAllStrategies([])
@@ -86,10 +88,16 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
const invalidPoolCount = draftPool.length - validDraft.length
const available = useMemo(
() => allStrategies.filter(s => !draftPool.includes(s.id)),
() => allStrategies.filter(s => !s.research_only && !draftPool.includes(s.id)),
[allStrategies, draftPool]
)
// research_only 草稿(AI 来源)单独列出, 供「发布」操作; 不进待选列表
const drafts = useMemo(
() => allStrategies.filter(s => s.research_only),
[allStrategies]
)
// 按 Tab 分组过滤待选
const filteredAvailable = useMemo(() => {
if (activeTab === 'all') return available
@@ -124,6 +132,20 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
})
}, [filteredAvailable])
// 发布 research_only 草稿 → 刷新后进入公开列表
const handlePublish = useCallback(async (id: string) => {
setPublishingId(id); setImportError(''); setImportMsg('')
try {
await api.strategyPublish(id)
await loadStrategies()
setImportMsg(`已发布: ${id}`)
} catch (e: any) {
setImportError(String(e?.message ?? '发布失败'))
} finally {
setPublishingId(null)
}
}, [loadStrategies])
const handleImportFile = useCallback(async (file: File) => {
setImporting(true); setImportError(''); setImportMsg('')
try {
@@ -143,7 +165,10 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
})
await loadStrategies()
setActiveTab(result.source === 'ai' ? 'ai' : 'custom')
setImportMsg(`已导入到${result.source === 'ai' ? 'AI' : '自定义'}策略: ${result.strategy_id}`)
const srcLabel = result.source === 'ai'
? (result.research_only ? 'AI 草稿(发布后可用)' : 'AI 策略')
: '自定义策略'
setImportMsg(`已导入到${srcLabel}: ${result.strategy_id}`)
} catch (e: any) {
setImportError(String(e?.message ?? '导入失败'))
} finally {
@@ -243,9 +268,44 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<div className="flex-1 overflow-y-auto px-2 py-2">
{activeTab === 'ai' && drafts.length > 0 && (
<div className="mb-3">
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[10px] font-medium text-muted">稿</span>
<span className="text-[9px] text-muted">{drafts.length} </span>
</div>
<div className="space-y-0.5">
{drafts.map(s => (
<div
key={s.id}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-btn border border-purple-500/15 bg-purple-500/5"
>
<span className="flex-1 min-w-0">
<span className="text-[12px] text-foreground block truncate">
{s.name} <span className="text-[10px] text-muted font-mono">{s.id}</span>
</span>
<span className="text-[10px] text-muted truncate block">{s.description}</span>
</span>
<button
onClick={() => handlePublish(s.id)}
disabled={publishingId === s.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[10px] text-purple-400 border border-purple-500/25 bg-purple-500/10 hover:bg-purple-500/20 disabled:opacity-50 transition-colors cursor-pointer shrink-0"
>
{publishingId === s.id && <Loader2 className="h-3 w-3 animate-spin" />}
</button>
</div>
))}
</div>
</div>
)}
{activeTab === 'ai' && drafts.length > 0 && (
<div className="px-1 mb-1 text-[10px] font-medium text-muted"></div>
)}
<div className="space-y-0.5">
{filteredAvailable.length === 0 ? (
<div className="flex items-center justify-center h-full text-[11px] text-muted">
<div className="flex items-center justify-center h-24 text-[11px] text-muted">
{available.length === 0 ? '全部已加入策略池' : '此分组无待选策略'}
</div>
) : filteredAvailable.map(s => (
@@ -272,6 +332,7 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
))}
</div>
</div>
</div>
{/* 右侧: 已选 (Reorder.Group 纵向拖拽) */}
<div className="flex flex-col min-h-0">
+8 -1
View File
@@ -717,6 +717,7 @@ export interface StrategyDetail {
description: string
tags: string[]
source: 'builtin' | 'custom' | 'ai' | 'composite'
research_only?: boolean
execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite' | 'minute_filter'
asset_types: string[]
timeframes: string[]
@@ -764,6 +765,7 @@ export interface StrategyCodeSaveResult {
source: 'ai' | 'custom' | 'composite'
path: string
meta: Record<string, any>
research_only?: boolean
}
// ===== Custom Signals (自定义信号) =====
@@ -3256,10 +3258,11 @@ export const api = {
},
// ===== Strategy Engine =====
strategyList: (assetType?: 'stock' | 'etf', timeframe: '1d' | '1m' | 'all' = '1d') => {
strategyList: (assetType?: 'stock' | 'etf', timeframe: '1d' | '1m' | 'all' = '1d', includeResearch = false) => {
const params = new URLSearchParams()
if (assetType) params.set('asset_type', assetType)
if (timeframe && timeframe !== 'all') params.set('timeframe', timeframe)
if (includeResearch) params.set('include_research', 'true')
const qs = params.toString()
return request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>(
`/api/strategies${qs ? `?${qs}` : ''}`,
@@ -3269,6 +3272,10 @@ export const api = {
strategyGet: (id: string) =>
request<StrategyDetail>(`/api/strategies/${id}`),
/** 发布 research_only 的 AI 草稿策略(翻转为公开) */
strategyPublish: (strategyId: string) =>
request<{ ok: boolean; strategy_id: string }>(`/api/strategies/${encodeURIComponent(strategyId)}/publish`, { method: 'POST' }),
strategyRun: (strategyId: string, params?: Record<string, any>, asOf?: string, pool?: string[]) =>
request<ScreenerResult>('/api/strategies/run', {
method: 'POST',
+6 -1
View File
@@ -1100,7 +1100,12 @@ export function Screener() {
onClose={() => setShowBuilder(false)}
mode={builderMode}
existingStrategyIds={allStrategyIds}
onSavedId={async id => {
onSavedId={async (id, researchOnly) => {
if (researchOnly) {
// AI 策略保存为 research_only 草稿, 不进入策略池, 提示用户去策略池发布
toast('AI 策略已保存为草稿,请在策略池「AI」标签发布后使用', 'success')
return
}
const data = await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
if (!data.presets.some(s => s.id === id)) {
throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`)