feat(screener): 新建策略「默认基础参数」配置与一键应用全部策略

- 策略页头部新增配置入口: 编辑默认基础过滤(价格/流通市值/成交额/换手/板块/ST), localStorage 持久化, 内置默认与原模板一致
- 新建策略两条路径生效: 自定义/matrix 模板注入 + AI 生成 step1 提示词注入(未配置时提示词逐字节不变)
- 对话框内一键应用: 二次确认后分批 PATCH 到全部股票策略, 只动 basic_filter 不碰各策略参数/评分, 应用即所见并同步存为默认配置
This commit is contained in:
shy3130
2026-09-21 13:46:32 +08:00
parent 2488fdc9ed
commit b6a7367262
8 changed files with 464 additions and 23 deletions
+4 -1
View File
@@ -12,7 +12,7 @@ import re
from dataclasses import asdict
from datetime import date
from pathlib import Path
from typing import Literal
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -548,6 +548,8 @@ class BuildRequest(BaseModel):
rules: str = ""
strategy_id: str = ""
execution_backend: Literal["polars_expr", "matrix_native"] = "polars_expr"
# 用户「默认基础参数」(策略页设置): 生成代码的 META.basic_filter 优先采用
basic_filter: dict[str, Any] | None = None
# step2 字段
current_code: str = ""
instruction: str = ""
@@ -881,6 +883,7 @@ def _build_prompt(req: BuildRequest) -> str:
req.rules,
req.strategy_id,
req.execution_backend,
basic_filter=req.basic_filter,
)
if req.step == 2:
return build_step2(req.current_code, req.instruction)
+14 -1
View File
@@ -22,6 +22,11 @@ def _load_doc(name: str) -> str:
DIRECTION_CN = {"long": "做多", "short": "做空", "monitor": "监控"}
def _format_basic_filter(basic_filter: dict) -> str:
"""dict → Python 字面量单行 (True/False/None 保持 Python 语法, 供 AI 直接抄进 META)。"""
return repr(dict(basic_filter))
def build_step1(
name: str,
description: str,
@@ -29,13 +34,21 @@ def build_step1(
rules: str,
strategy_id: str = "",
execution_backend: str = "polars_expr",
basic_filter: dict | None = None,
) -> str:
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分)
注意: 生成规范已在 ai_generator.py 的 system prompt 中加载,
此处只拼用户输入以降低网关超时概率。
basic_filter: 用户「默认基础参数」(策略页设置); None = 不约束, AI 自行默认。
"""
id_line = f"\n策略ID(必须使用此ID):{strategy_id}" if strategy_id else ""
bf_line = (
f"\n用户默认基础过滤参数(META 的 basic_filter 必须严格使用以下值,"
f"除非策略规则与之明确冲突并在代码注释中说明):\n{_format_basic_filter(basic_filter)}\n"
if basic_filter
else ""
)
return f"""请根据以下用户输入生成完整策略代码:
@@ -43,7 +56,7 @@ def build_step1(
策略描述:{description}
选股方向:{DIRECTION_CN.get(direction, direction)}
执行后端:{execution_backend}
策略规则:
{bf_line}策略规则:
{rules}
输出要求:
+26
View File
@@ -31,6 +31,32 @@ def test_build_step1_keeps_user_prompt_compact():
assert len(prompt) < 1000
def test_build_step1_without_basic_filter_keeps_prompt_unchanged_shape():
"""basic_filter=None: 提示词不含默认基础过滤段 (既有行为零变化)。"""
prompt = build_step1("测试策略", "测试描述", "long", "1. 规则", "")
assert "默认基础过滤" not in prompt
assert "策略规则:\n1. 规则" in prompt
def test_build_step1_injects_user_default_basic_filter():
"""basic_filter 传入: 提示词要求 META.basic_filter 严格采用用户默认值。"""
bf = {
"price_min": 3, "price_max": 100,
"float_cap_min": 20e8, "float_cap_max": None,
"exclude_st": False, "boards": ["沪主板", "深主板"],
}
prompt = build_step1("测试策略", "测试描述", "long", "1. 规则", "", basic_filter=bf)
assert "默认基础过滤" in prompt
assert "'price_min': 3" in prompt
# Python 字面量语义 (True/False/None), 而非 JSON 的 true/false/null
assert "'exclude_st': False" in prompt
assert "'float_cap_max': None" in prompt
assert "'boards': ['沪主板', '深主板']" in prompt
def test_build_step2_uses_runtime_meta_constraints():
prompt = build_step2("META = {}", "调整参数和评分")
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
// 「默认基础参数」: 对话框编辑保存写 localStorage; 纯函数把默认参数渲染进
// 新建策略模板的 META.basic_filter 段 (Python 字面量)。
// 「一键应用到全部策略」: 二次确认后逐策略 PATCH basic_filter 并存为默认配置。
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DefaultStrategyParamsDialog, BUILTIN_DEFAULT_BASIC_FILTER, loadDefaultBasicFilter } from './DefaultStrategyParamsDialog'
import { buildCustomTemplate, renderBasicFilterPy } from './StrategyBuilderDialog'
import { storage } from '@/lib/storage'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
vi.mock('@/components/Toast', () => ({ toast: vi.fn() }))
const mockPatchConfig = vi.fn(async (_id: string, _overrides: Record<string, unknown>) => ({ ok: true }))
vi.mock('@/lib/api', () => ({
api: {
screenerStrategies: vi.fn(async () => ({
presets: [
{ id: 'strategy_a', timeframes: ['1d'] },
{ id: 'strategy_b', timeframes: ['1m'] },
],
})),
strategyPatchConfig: (id: string, ov: Record<string, unknown>) => mockPatchConfig(id, ov),
},
}))
let root: Root | null = null
const container = document.createElement('div')
document.body.appendChild(container)
// 渲染前预取策略列表进缓存: query 同步命中, 避免 jsdom 下异步解析时序抖动
async function render(props: { show: boolean; onClose?: () => void }) {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
await queryClient.fetchQuery({
queryKey: [...QK.screenerStrategies('all'), 'all'],
queryFn: () => api.screenerStrategies(undefined, 'all'),
})
root = createRoot(container)
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<DefaultStrategyParamsDialog show={props.show} onClose={props.onClose ?? (() => {})} />
</QueryClientProvider>,
)
})
}
beforeEach(() => {
localStorage.clear()
mockPatchConfig.mockClear()
})
afterEach(() => {
if (root) { act(() => { root!.unmount() }); root = null }
container.innerHTML = ''
})
it('renders nothing when show=false', async () => {
await render({ show: false })
expect(container.textContent).toBe('')
})
it('shows the basic filter fields when open', async () => {
await render({ show: true })
expect(container.textContent).toContain('默认基础参数')
expect(container.textContent).toContain('价格')
expect(container.textContent).toContain('流通市值')
expect(container.textContent).toContain('板块')
expect(container.textContent).toContain('排除')
})
it('save persists to localStorage and closes', async () => {
const onClose = vi.fn()
await render({ show: true, onClose })
// 改价格下限: 第一对 min/max 输入 (RangeField 价格)
// React 受控 input 需经原生 value setter + input 事件触发 onChange
const input = container.querySelector('input[type="number"]') as HTMLInputElement
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
nativeSetter.call(input, '3')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
const saveBtn = [...container.querySelectorAll('button')].find(b => b.textContent?.includes('保存'))!
act(() => { saveBtn.click() })
const saved = storage.defaultStrategyBasicFilter.get(null)
expect(saved?.price_min).toBe(3)
expect(onClose).toHaveBeenCalledTimes(1)
expect(loadDefaultBasicFilter().price_min).toBe(3)
})
it('reset restores builtin defaults', async () => {
storage.defaultStrategyBasicFilter.set({ ...BUILTIN_DEFAULT_BASIC_FILTER, price_min: 100 })
await render({ show: true })
const resetBtn = [...container.querySelectorAll('button')].find(b => b.textContent?.includes('恢复默认'))!
act(() => { resetBtn.click() })
expect((container.querySelector('input[type="number"]') as HTMLInputElement).value).toBe('5')
})
// ===== 模板注入 (纯函数) =====
it('renderBasicFilterPy emits Python literals for the template META', () => {
const py = renderBasicFilterPy({
...BUILTIN_DEFAULT_BASIC_FILTER,
price_min: 3, exclude_st: false, float_cap_max: null,
})
expect(py).toContain('"price_min": 3, "price_max": 200,')
expect(py).toContain('"exclude_st": False,')
expect(py).toContain('"float_cap_max": None,')
expect(py).toContain('"boards": ["沪主板", "深主板", "创业板", "科创板"],')
})
it('buildCustomTemplate embeds custom defaults into the code template', () => {
const code = buildCustomTemplate({ ...BUILTIN_DEFAULT_BASIC_FILTER, price_min: 3, turnover_min: 2 })
expect(code).toContain('"basic_filter": {')
expect(code).toContain('"price_min": 3,')
expect(code).toContain('"turnover_min": 2,')
expect(code).toContain('def filter(df: pl.DataFrame, params: dict) -> pl.Expr:')
})
// ===== 一键应用到全部策略 =====
it('apply button shows strategy count after list loads', async () => {
await render({ show: true })
const btn = [...container.querySelectorAll('button')].find(b => b.textContent?.includes('一键应用'))!
expect(btn.textContent).toContain('2')
// 未点击确认前不发起任何 PATCH
expect(mockPatchConfig).not.toHaveBeenCalled()
})
it('apply requires confirm, then patches every strategy with current values', async () => {
await render({ show: true })
// 第一步: 点「一键应用」→ 出现二次确认, 尚未请求
const applyBtn = [...container.querySelectorAll('button')].find(b => b.textContent?.includes('一键应用'))!
await act(async () => { applyBtn.click() })
expect(mockPatchConfig).not.toHaveBeenCalled()
expect(container.textContent).toContain('确认覆盖')
// 第二步: 确认 → 逐策略 PATCH, 值为当前表单值 (改过 price_min=3)
const input = container.querySelector('input[type="number"]') as HTMLInputElement
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
nativeSetter.call(input, '3')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
const confirmBtn = [...container.querySelectorAll('button')].find(b => b.textContent?.includes('确认覆盖'))!
await act(async () => { confirmBtn.click() })
await act(async () => {})
expect(mockPatchConfig).toHaveBeenCalledTimes(2)
const ids = mockPatchConfig.mock.calls.map(c => c[0])
expect(ids).toEqual(['strategy_a', 'strategy_b'])
for (const call of mockPatchConfig.mock.calls) {
expect(call[1]).toEqual({ basic_filter: { ...BUILTIN_DEFAULT_BASIC_FILTER, price_min: 3 } })
}
// 应用即所见: 同时存为默认配置
expect(storage.defaultStrategyBasicFilter.get(null)?.price_min).toBe(3)
})
@@ -0,0 +1,196 @@
// 新建策略「默认基础参数」设置: 策略页右上角齿轮入口。
// 保存后, 之后新建的策略 (自定义模板 + AI 生成) 的 META.basic_filter 默认使用这套值。
// 「一键应用到全部策略」: 二次确认后把当前参数 PATCH 到所有股票策略的 override
// (只动 basic_filter, 不碰各策略已调的 params/评分), 并同步存为默认配置。
import { useEffect, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { SlidersHorizontal, RotateCcw, Save, Zap } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { toast } from '@/components/Toast'
import { storage, type DefaultStrategyBasicFilter } from '@/lib/storage'
import { color } from '@/lib/colors'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { RangeField, ALL_BOARDS } from './StrategySettingsDialog'
/** 与自定义策略模板内置默认一致 (CUSTOM_TEMPLATE 的 basic_filter 段) */
export const BUILTIN_DEFAULT_BASIC_FILTER: DefaultStrategyBasicFilter = {
price_min: 5, price_max: 200,
float_cap_min: 30e8, float_cap_max: 1500e8,
amount_min: null, amount_max: null,
turnover_min: 1, turnover_max: null,
exclude_st: true,
boards: ['沪主板', '深主板', '创业板', '科创板'],
}
export function loadDefaultBasicFilter(): DefaultStrategyBasicFilter {
const saved = storage.defaultStrategyBasicFilter.get(null)
return saved ? { ...BUILTIN_DEFAULT_BASIC_FILTER, ...saved } : { ...BUILTIN_DEFAULT_BASIC_FILTER }
}
interface Props {
show: boolean
onClose: () => void
}
export function DefaultStrategyParamsDialog({ show, onClose }: Props) {
const [bf, setBf] = useState<DefaultStrategyBasicFilter>(() => loadDefaultBasicFilter())
// 一键应用: idle → confirm (二次确认) → applying (分批 PATCH) → idle
const [applyPhase, setApplyPhase] = useState<'idle' | 'confirm' | 'applying'>('idle')
const [applyProgress, setApplyProgress] = useState({ done: 0, total: 0, failed: 0 })
const qc = useQueryClient()
// 全部股票策略 (与策略页同一 query key, 复用缓存); 仅弹窗打开时启用
const strategiesQuery = useQuery({
queryKey: [...QK.screenerStrategies('all'), 'all'],
queryFn: () => api.screenerStrategies(undefined, 'all'),
enabled: show,
})
const strategyCount = strategiesQuery.data?.presets?.length ?? 0
// 重新打开时重置表单与一键应用状态
useEffect(() => {
if (show) {
setBf(loadDefaultBasicFilter())
setApplyPhase('idle')
setApplyProgress({ done: 0, total: 0, failed: 0 })
}
}, [show])
if (!show) return null
const set = <K extends keyof DefaultStrategyBasicFilter>(key: K, value: DefaultStrategyBasicFilter[K]) =>
setBf(prev => ({ ...prev, [key]: value }))
const save = () => {
storage.defaultStrategyBasicFilter.set(bf)
toast('默认基础参数已保存,之后新建的策略将默认使用', 'success')
onClose()
}
const applyToAll = async () => {
const ids = (strategiesQuery.data?.presets ?? []).map(p => p.id)
if (!ids.length) return
setApplyPhase('applying')
setApplyProgress({ done: 0, total: ids.length, failed: 0 })
// 小并发分批 PATCH, 只写 basic_filter; 后端 _strip_defaults 会剥掉与
// 策略 META 默认相同的键, 各策略已调的 params/评分不受影响
let failed = 0
const CHUNK = 8
for (let i = 0; i < ids.length; i += CHUNK) {
const results = await Promise.allSettled(
ids.slice(i, i + CHUNK).map(id => api.strategyPatchConfig(id, { basic_filter: { ...bf } })),
)
failed += results.filter(r => r.status === 'rejected').length
setApplyProgress({ done: Math.min(i + CHUNK, ids.length), total: ids.length, failed })
}
// 应用即所见: 当前参数同时存为默认配置, 新建策略与存量策略保持一致
storage.defaultStrategyBasicFilter.set(bf)
qc.invalidateQueries({ queryKey: ['screener-strategies'] })
setApplyPhase('idle')
if (failed === 0) {
toast(`已应用到全部 ${ids.length} 个策略,并保存为默认配置;重跑后生效`, 'success')
} else {
toast(`已应用 ${ids.length - failed}/${ids.length} 个策略(${failed} 个失败),并保存为默认配置`, 'error')
}
}
return (
<Modal
onClose={onClose}
labelledBy="default-strategy-params-title"
overlayClassName="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
panelClassName="w-[460px] max-w-[92vw] max-h-[86vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* 标题 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-accent" />
<span id="default-strategy-params-title" className="text-sm font-semibold text-foreground">默认基础参数</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated text-muted hover:text-foreground transition-colors cursor-pointer" title="关闭">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<p className="text-[11px] text-muted leading-relaxed">
之后新建的策略(含 AI 生成)将默认使用以下基础过滤参数;已存在的策略不受影响,可用下方「一键应用」批量同步,或逐策略修改。
</p>
<div className="space-y-2">
<RangeField label="价格" minVal={bf.price_min} maxVal={bf.price_max} onMinChange={v => set('price_min', v)} onMaxChange={v => set('price_max', v)} unit="元" step="1" />
<RangeField label="流通市值" minVal={bf.float_cap_min != null ? bf.float_cap_min / 1e8 : null} maxVal={bf.float_cap_max != null ? bf.float_cap_max / 1e8 : null} onMinChange={v => set('float_cap_min', v != null ? v * 1e8 : null)} onMaxChange={v => set('float_cap_max', v != null ? v * 1e8 : null)} unit="亿" step="5" />
<RangeField label="成交额" minVal={bf.amount_min != null ? bf.amount_min / 1e8 : null} maxVal={bf.amount_max != null ? bf.amount_max / 1e8 : null} onMinChange={v => set('amount_min', v != null ? v * 1e8 : null)} onMaxChange={v => set('amount_max', v != null ? v * 1e8 : null)} unit="亿" step="0.5" />
<RangeField label="换手率" minVal={bf.turnover_min} maxVal={bf.turnover_max} onMinChange={v => set('turnover_min', v)} onMaxChange={v => set('turnover_max', v)} unit="%" step="0.5" />
<div className="flex items-start gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right pt-0.5">板块</span>
<div className="flex flex-wrap gap-0.5">
{ALL_BOARDS.map(b => {
const active = bf.boards.includes(b)
return (
<button key={b} onClick={() => { const next = active ? bf.boards.filter(x => x !== b) : [...bf.boards, b]; set('boards', next.length === 0 ? [...ALL_BOARDS] : next) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${active ? `${color.select.border} ${color.select.bgLight} ${color.select.text}` : `border-border bg-base text-muted ${color.select.borderHover}`}`}>{b}</button>
)
})}
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">ST</span>
<button onClick={() => set('exclude_st', !bf.exclude_st)}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${bf.exclude_st ? 'border-danger/40 bg-danger/10 text-danger' : 'border-border bg-base text-muted hover:border-danger/30'}`}>{bf.exclude_st ? '排除' : '包含'}</button>
</div>
</div>
</div>
{/* 一键应用到全部存量策略 */}
<div className="px-4 py-3 border-t border-border/50">
{applyPhase === 'idle' && (
<button onClick={() => setApplyPhase('confirm')} disabled={!strategyCount}
title="把当前参数写入全部股票策略的基础参数(不影响各策略已调的其他配置)"
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn text-xs font-medium
border border-amber-500/40 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20
transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed">
<Zap className="h-3 w-3" />
一键应用到全部策略{strategyCount ? `(${strategyCount} 个)` : ''}
</button>
)}
{applyPhase === 'confirm' && (
<div className="flex items-center gap-2">
<span className="flex-1 text-[11px] text-muted leading-tight">
将覆盖全部 <span className="text-amber-500 font-medium">{strategyCount}</span> 个策略的基础参数(只动基础过滤,不碰各策略的参数/评分),重跑后生效。
</span>
<button onClick={() => setApplyPhase('idle')}
className="px-2.5 py-1.5 rounded-btn text-xs text-secondary border border-border hover:text-foreground transition-colors cursor-pointer shrink-0">取消</button>
<button onClick={applyToAll}
className="px-3 py-1.5 rounded-btn text-xs font-medium text-white bg-amber-500 hover:bg-amber-500/90 transition-colors cursor-pointer shrink-0">确认覆盖</button>
</div>
)}
{applyPhase === 'applying' && (
<div className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-btn text-xs text-muted border border-border/50 bg-elevated/50">
<span className="h-3 w-3 rounded-full border-2 border-amber-500/30 border-t-amber-500 animate-spin" />
应用中 {applyProgress.done}/{applyProgress.total}
{applyProgress.failed > 0 && <span className="text-danger">({applyProgress.failed} 失败)</span>}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/50">
<button onClick={() => setBf({ ...BUILTIN_DEFAULT_BASIC_FILTER })}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-btn text-xs text-muted border border-border hover:text-foreground hover:border-accent/40 transition-colors cursor-pointer">
<RotateCcw className="h-3 w-3" />
恢复默认
</button>
<div className="flex items-center gap-2">
<button onClick={onClose} className="px-3 py-1.5 rounded-btn text-xs text-secondary border border-border hover:text-foreground transition-colors cursor-pointer">取消</button>
<button onClick={save}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-btn text-xs font-medium text-white bg-accent hover:bg-accent/90 transition-colors cursor-pointer">
<Save className="h-3 w-3" />
保存
</button>
</div>
</div>
</Modal>
)
}
@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { Modal } from '@/components/Modal'
import { X, Sparkles, Save, Loader2, ChevronLeft, ChevronRight, AlertTriangle, Settings2, FileText, Copy, Check, Terminal } from 'lucide-react'
import { api, friendlyStreamError } from '@/lib/api'
import type { AiIterateRound } from '@/lib/api'
import { storage } from '@/lib/storage'
import { storage, type DefaultStrategyBasicFilter } from '@/lib/storage'
import { cn } from '@/lib/cn'
import { ALL_BOARDS } from './StrategySettingsDialog'
import { loadDefaultBasicFilter } from './DefaultStrategyParamsDialog'
// ===== 工具函数 =====
@@ -94,7 +96,24 @@ const DIRECTIONS = [
// ===== 组件 =====
const CUSTOM_TEMPLATE = `"""策略简短描述"""
/** 渲染 basic_filter 的 Python 字面量段 (缩进 8 空格, 与模板 META 对齐) */
export function renderBasicFilterPy(bf: DefaultStrategyBasicFilter): string {
const py = (v: number | boolean | null) => (v === null ? 'None' : v === true ? 'True' : v === false ? 'False' : String(v))
const boards = bf.boards.length ? bf.boards : [...ALL_BOARDS]
return [
` "price_min": ${py(bf.price_min)}, "price_max": ${py(bf.price_max)},`,
` "market_cap_min": None,`,
` "float_cap_min": ${py(bf.float_cap_min)}, "float_cap_max": ${py(bf.float_cap_max)},`,
` "amount_min": ${py(bf.amount_min)}, "amount_max": ${py(bf.amount_max)},`,
` "turnover_min": ${py(bf.turnover_min)}, "turnover_max": ${py(bf.turnover_max)},`,
` "exclude_st": ${py(bf.exclude_st)}, "exclude_new_days": 30,`,
` "boards": [${boards.map(b => `"${b}"`).join(', ')}],`,
].join('\n')
}
/** 自定义策略代码模板: basic_filter 段用「默认基础参数」填充 */
export function buildCustomTemplate(bf: DefaultStrategyBasicFilter): string {
return `"""策略简短描述"""
import polars as pl
META = {
@@ -105,13 +124,7 @@ META = {
"asset_types": ["stock"],
"timeframes": ["1d"],
"basic_filter": {
"price_min": 5, "price_max": 200,
"market_cap_min": None,
"float_cap_min": 30e8, "float_cap_max": 1500e8,
"amount_min": None,
"turnover_min": 1,
"exclude_st": True, "exclude_new_days": 30,
"boards": ["沪主板", "深主板", "创业板", "科创板"],
${renderBasicFilterPy(bf)}
},
"params": [],
"scoring": {
@@ -140,8 +153,9 @@ def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
& (pl.col("volume") > pl.col("vol_ma5") * 1.5)
)
`
}
const MATRIX_TEMPLATE = `"""矩阵原生策略示例"""
const MATRIX_TEMPLATE_FN = (bf: DefaultStrategyBasicFilter) => `"""矩阵原生策略示例"""
import numpy as np
from app.backtest.matrix import MarketDataMatrix, SignalMatrix, make_signal_matrix, matrix_feature
@@ -153,13 +167,7 @@ META = {
"asset_types": ["stock"],
"timeframes": ["1d"],
"basic_filter": {
"price_min": 5, "price_max": 200,
"market_cap_min": None,
"float_cap_min": 30e8, "float_cap_max": 1500e8,
"amount_min": None,
"turnover_min": 1,
"exclude_st": True, "exclude_new_days": 30,
"boards": ["沪主板", "深主板", "创业板", "科创板"],
${renderBasicFilterPy(bf)}
},
"params": [],
"scoring": {},
@@ -199,6 +207,11 @@ interface Props {
export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create', existingStrategyIds }: Props) {
// 根据 mode 选择存储 key
const draftStore = mode === 'modify' ? storage.strategyModify : storage.strategyDraft
// 新建策略的默认基础参数 (策略页「默认基础参数」设置; 打开构建器时取最新)
const [defaultBF, setDefaultBF] = useState<DefaultStrategyBasicFilter>(() => loadDefaultBasicFilter())
useEffect(() => { if (open) setDefaultBF(loadDefaultBasicFilter()) }, [open])
const CUSTOM_TEMPLATE = useMemo(() => buildCustomTemplate(defaultBF), [defaultBF])
const MATRIX_TEMPLATE = useMemo(() => MATRIX_TEMPLATE_FN(defaultBF), [defaultBF])
const [step, setStep] = useState(1)
const [tab, setTab] = useState<'ai' | 'custom'>('ai')
const [customCopied, setCustomCopied] = useState(false)
@@ -331,7 +344,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
const id = resolveStrategyId('ai')
setStrategyId(id); setSource('ai'); setPreviewTab('code')
let finalResult: any = null
for await (const evt of api.strategyBuildStream(1, { name: name.trim(), description: description.trim(), direction, execution_backend: executionBackend, rules: rules.trim(), strategy_id: id })) {
for await (const evt of api.strategyBuildStream(1, { name: name.trim(), description: description.trim(), direction, execution_backend: executionBackend, rules: rules.trim(), strategy_id: id, basic_filter: defaultBF })) {
if (evt.type === 'delta') {
setCode(prev => prev + evt.content)
} else if (evt.type === 'error') {
@@ -78,7 +78,7 @@ function Section({ icon: Icon, title, accent, defaultOpen = true, children, extr
// ===== 区间字段(最小 ~ 最大) =====
function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: {
export function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: {
label: string
minVal: any
maxVal: any
@@ -113,7 +113,7 @@ function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, ste
}
// 板块标签
const ALL_BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所']
export const ALL_BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所']
// 策略参数字段
function ParamField({ def, value, onChange }: {
+17
View File
@@ -23,6 +23,20 @@ function kv<T>(key: string) {
}
}
/** 新建策略默认基础过滤参数 (与策略 META.basic_filter 字段/单位一致: 价格元, 市值/成交额元, 换手%) */
export interface DefaultStrategyBasicFilter {
price_min: number | null
price_max: number | null
float_cap_min: number | null
float_cap_max: number | null
amount_min: number | null
amount_max: number | null
turnover_min: number | null
turnover_max: number | null
exclude_st: boolean
boards: string[]
}
export const storage = {
/** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'),
@@ -104,6 +118,9 @@ export const storage = {
/** 策略创建草稿(新建专用) */
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string; source?: 'ai' | 'custom' } | null>('strategy-draft'),
/** 新建策略默认基础过滤参数 (策略页「默认基础参数」设置; null=未自定义, 用内置默认) */
defaultStrategyBasicFilter: kv<DefaultStrategyBasicFilter | null>('default-strategy-basic-filter'),
/** 策略修改草稿(AI修改专用,不影响创建按钮) */
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string; source?: 'ai' | 'custom' } | null>('strategy-modify'),