mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(screener): 新建策略「默认基础参数」配置与一键应用全部策略
- 策略页头部新增配置入口: 编辑默认基础过滤(价格/流通市值/成交额/换手/板块/ST), localStorage 持久化, 内置默认与原模板一致 - 新建策略两条路径生效: 自定义/matrix 模板注入 + AI 生成 step1 提示词注入(未配置时提示词逐字节不变) - 对话框内一键应用: 二次确认后分批 PATCH 到全部股票策略, 只动 basic_filter 不碰各策略参数/评分, 应用即所见并同步存为默认配置
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
输出要求:
|
||||
|
||||
@@ -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 }: {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user