diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 4d094331..c9c6f738 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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) diff --git a/backend/app/strategy/prompt_builder.py b/backend/app/strategy/prompt_builder.py index 11181338..498be4b2 100644 --- a/backend/app/strategy/prompt_builder.py +++ b/backend/app/strategy/prompt_builder.py @@ -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} 输出要求: diff --git a/backend/tests/test_ai_generator_prompt.py b/backend/tests/test_ai_generator_prompt.py index 8443100e..b22d3401 100644 --- a/backend/tests/test_ai_generator_prompt.py +++ b/backend/tests/test_ai_generator_prompt.py @@ -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 = {}", "调整参数和评分") diff --git a/frontend/src/components/screener/DefaultStrategyParamsDialog.test.tsx b/frontend/src/components/screener/DefaultStrategyParamsDialog.test.tsx new file mode 100644 index 00000000..37bf56ec --- /dev/null +++ b/frontend/src/components/screener/DefaultStrategyParamsDialog.test.tsx @@ -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) => ({ 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) => 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( + + {})} /> + , + ) + }) +} + +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) +}) diff --git a/frontend/src/components/screener/DefaultStrategyParamsDialog.tsx b/frontend/src/components/screener/DefaultStrategyParamsDialog.tsx new file mode 100644 index 00000000..19fa5fcb --- /dev/null +++ b/frontend/src/components/screener/DefaultStrategyParamsDialog.tsx @@ -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(() => 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 = (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 ( + + {/* 标题 */} +
+
+ + 默认基础参数 +
+ +
+ + {/* 内容 */} +
+

+ 之后新建的策略(含 AI 生成)将默认使用以下基础过滤参数;已存在的策略不受影响,可用下方「一键应用」批量同步,或逐策略修改。 +

+
+ set('price_min', v)} onMaxChange={v => set('price_max', v)} unit="元" step="1" /> + set('float_cap_min', v != null ? v * 1e8 : null)} onMaxChange={v => set('float_cap_max', v != null ? v * 1e8 : null)} unit="亿" step="5" /> + set('amount_min', v != null ? v * 1e8 : null)} onMaxChange={v => set('amount_max', v != null ? v * 1e8 : null)} unit="亿" step="0.5" /> + set('turnover_min', v)} onMaxChange={v => set('turnover_max', v)} unit="%" step="0.5" /> +
+ 板块 +
+ {ALL_BOARDS.map(b => { + const active = bf.boards.includes(b) + return ( + + ) + })} +
+
+
+ ST + +
+
+
+ + {/* 一键应用到全部存量策略 */} +
+ {applyPhase === 'idle' && ( + + )} + {applyPhase === 'confirm' && ( +
+ + 将覆盖全部 {strategyCount} 个策略的基础参数(只动基础过滤,不碰各策略的参数/评分),重跑后生效。 + + + +
+ )} + {applyPhase === 'applying' && ( +
+ + 应用中 {applyProgress.done}/{applyProgress.total} + {applyProgress.failed > 0 && ({applyProgress.failed} 失败)} +
+ )} +
+ + {/* 底部按钮 */} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx index e2499773..b246cb19 100644 --- a/frontend/src/components/screener/StrategyBuilderDialog.tsx +++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx @@ -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(() => 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') { diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx index 966b4fc3..753c2398 100644 --- a/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -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 }: { diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index da5e707d..ecc3c488 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -23,6 +23,20 @@ function kv(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('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('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'),