diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index 0b2be72..4dc2930 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -87,8 +87,10 @@ def build_container(settings: Settings) -> ApplicationContainer: accounts = AccountService(database, account_repository, PasswordHasher(), cipher) memberships = MembershipService(database, account_repository) model_pool = ModelPoolService(database, model_pool_repository, cipher) - screener = ScreenerService(database, screener_repository, market_repository, gateway) llm = LLMGateway(database, LLMRepository(), memberships, model_pool) + screener = ScreenerService( + database, screener_repository, market_repository, gateway, llm + ) mentor = MentorService( database, MentorRepository(), diff --git a/next/backend/database/migrations/m0012_screener_backtests.py b/next/backend/database/migrations/m0012_screener_backtests.py new file mode 100644 index 0000000..6a1065d --- /dev/null +++ b/next/backend/database/migrations/m0012_screener_backtests.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE screener_run_backtests ( + run_id INTEGER PRIMARY KEY + REFERENCES screener_runs(id) ON DELETE CASCADE, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + + +def downgrade(connection: sqlite3.Connection) -> None: + connection.execute("DROP TABLE screener_run_backtests") + + +MIGRATION = Migration( + version=12, + name="create_screener_backtests", + signature="screener:v2:persistent-rolling-backtests", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index ac0b4c0..16afb4e 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -9,6 +9,7 @@ from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN from backend.database.migrations.m0010_review import MIGRATION as REVIEW from backend.database.migrations.m0011_operations import MIGRATION as OPERATIONS +from backend.database.migrations.m0012_screener_backtests import MIGRATION as SCREENER_BACKTESTS from backend.database.migrations.runner import Migration MIGRATIONS: tuple[Migration, ...] = ( @@ -23,4 +24,5 @@ MIGRATIONS: tuple[Migration, ...] = ( HEAVEN, REVIEW, OPERATIONS, + SCREENER_BACKTESTS, ) diff --git a/next/backend/features/screener/backtest.py b/next/backend/features/screener/backtest.py new file mode 100644 index 0000000..e10ea09 --- /dev/null +++ b/next/backend/features/screener/backtest.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any + +from backend.features.screener.engine import execute_formula + +MINIMUM_STABLE_SAMPLES = 20 +FORWARD_TRADING_DAYS = 3 + + +def rolling_backtest( + snapshots: list[dict[str, Any]], formula: dict[str, Any] +) -> dict[str, Any]: + returns: list[float] = [] + evaluated_dates: list[str] = [] + for index in range(max(0, len(snapshots) - FORWARD_TRADING_DAYS)): + current = snapshots[index] + future = snapshots[index + FORWARD_TRADING_DAYS] + outcome = execute_formula(current["rows"], formula, current["coverage"]) + if outcome["status"] not in {"completed", "no_signal"}: + continue + evaluated_dates.append(str(current["trade_date"])) + future_closes = { + str(row.get("identifier")): _positive(row.get("close")) + for row in future["rows"] + } + for candidate in outcome["items"]: + entry = _positive(candidate.get("close")) + future_close = future_closes.get(str(candidate.get("identifier"))) + if entry is None or future_close is None: + continue + returns.append((future_close / entry - 1) * 100) + sample_size = len(returns) + stable = sample_size >= MINIMUM_STABLE_SAMPLES + period_start = evaluated_dates[0] if evaluated_dates else None + period_end = evaluated_dates[-1] if evaluated_dates else None + return { + "sample_size": sample_size, + "evaluated_dates": len(evaluated_dates), + "stable": stable, + "win_rate": ( + round(sum(value > 0 for value in returns) / sample_size * 100, 1) + if stable + else None + ), + "average_return_3d": ( + round(sum(returns) / sample_size, 2) if stable else None + ), + "period_start": period_start, + "period_end": period_end, + "message": ( + "历史估计已达到最低样本要求" + if stable + else ( + f"小样本:当前{sample_size}个有效样本," + f"满{MINIMUM_STABLE_SAMPLES}个后显示胜率与平均收益" + ) + ), + } + + +def attach_historical_estimate( + run: dict[str, Any], backtest: dict[str, Any] | None +) -> dict[str, Any]: + run["backtest"] = backtest + if not backtest or not backtest.get("stable"): + return run + win_rate = float(backtest["win_rate"]) + for item in run.get("items") or []: + score = item.get("score_display") + item["historical_estimate"] = ( + round(win_rate * 0.65 + float(score) * 0.35, 1) + if isinstance(score, (int, float)) + else None + ) + return run + + +def _positive(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None diff --git a/next/backend/features/screener/compiler.py b/next/backend/features/screener/compiler.py new file mode 100644 index 0000000..c50f767 --- /dev/null +++ b/next/backend/features/screener/compiler.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from backend.features.screener.catalog import CatalogError, factor_catalog, validate_formula + +PROMPT_VERSION = "screener:formula-compiler:v1" + + +def compile_messages(description: str) -> list[dict[str, str]]: + factors = factor_catalog()["factors"] + schema = { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [{"field": "amount_billion", "op": ">=", "value": 1}], + "score": [{"field": "return_20d", "weight": 1.0, "direction": "desc"}], + "limit": 30, + "min_score": 0.5, + } + return [ + { + "role": "system", + "content": ( + "你是受控选股公式编译器。只输出一个JSON对象,不得输出Markdown、解释、股票或代码。" + "只能使用给定因子;filters运算符仅限 >、>=、<、<=、==、!=、between、in;" + "score权重必须为0至1小数且总和等于1;direction仅限asc或desc;" + "limit为1至50,min_score为0至1。无法完全表达时选择最接近的已知因子,不得创造字段。" + f"\nJSON结构:{json.dumps(schema, ensure_ascii=False, separators=(',', ':'))}" + f"\n可用因子:{json.dumps(factors, ensure_ascii=False, separators=(',', ':'))}" + ), + }, + {"role": "user", "content": description}, + ] + + +def description_key(description: str) -> str: + digest = hashlib.sha256(description.encode("utf-8")).hexdigest()[:16] + return f"formula:{digest}" + + +def parse_compiled_formula(content: str) -> dict[str, Any]: + text = content.strip() + if text.startswith("```"): + lines = text.splitlines() + if len(lines) < 3 or lines[-1].strip() != "```": + raise CatalogError("模型返回的公式格式无效") + text = "\n".join(lines[1:-1]).strip() + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: + raise CatalogError("模型未返回有效JSON公式") from exc + if not isinstance(raw, dict): + raise CatalogError("模型返回的公式必须是对象") + return normalize_custom_formula(raw) + + +def normalize_custom_formula(raw: dict[str, Any]) -> dict[str, Any]: + universe = raw.get("universe") + filters = raw.get("filters") + scores = raw.get("score") + if not isinstance(universe, dict) or not isinstance(filters, list) or not isinstance( + scores, list + ): + raise CatalogError("模型返回的公式结构不完整") + exclude_st = universe.get("exclude_st", True) + listed_days = universe.get("listed_days_min", 120) + if not isinstance(exclude_st, bool) or not isinstance(listed_days, int): + raise CatalogError("股票范围设置无效") + normalized_scores = [] + for score in scores: + if not isinstance(score, dict): + raise CatalogError("评分因子结构无效") + normalized_scores.append( + { + "field": score.get("field"), + "weight": score.get("weight"), + "direction": score.get("direction", "desc"), + } + ) + if any(not isinstance(item, dict) for item in filters): + raise CatalogError("筛选条件结构无效") + numeric_weights = [item["weight"] for item in normalized_scores] + if all( + isinstance(value, (int, float)) and not isinstance(value, bool) + for value in numeric_weights + ): + total = sum(float(value) for value in numeric_weights) + if abs(total - 100) <= 0.0001: + for item in normalized_scores: + item["weight"] = float(item["weight"]) / 100 + minimum = raw.get("min_score") + if isinstance(minimum, (int, float)) and not isinstance(minimum, bool) and 1 < minimum <= 100: + minimum = float(minimum) / 100 + formula = { + "universe": { + "exclude_st": exclude_st, + "listed_days_min": listed_days, + }, + "filters": [ + { + "field": item.get("field"), + "op": item.get("op"), + "value": item.get("value"), + } + for item in filters + ], + "score": normalized_scores, + "limit": raw.get("limit"), + "min_score": minimum, + } + validate_formula(formula) + return formula diff --git a/next/backend/features/screener/repository.py b/next/backend/features/screener/repository.py index 6bc10ff..6629060 100644 --- a/next/backend/features/screener/repository.py +++ b/next/backend/features/screener/repository.py @@ -88,6 +88,28 @@ class ScreenerRepository: (snapshot_id,), ).fetchone() + def factor_snapshots_through( + self, connection: sqlite3.Connection, through: str, limit: int + ) -> tuple[sqlite3.Row, ...]: + return tuple( + connection.execute( + """ + SELECT * FROM ( + SELECT snapshots.*, + ROW_NUMBER() OVER ( + PARTITION BY trade_date ORDER BY id DESC + ) AS revision_rank + FROM screener_factor_snapshots AS snapshots + WHERE trade_date <= ? + ) + WHERE revision_rank = 1 + ORDER BY trade_date DESC, id DESC + LIMIT ? + """, + (through, limit), + ).fetchall() + ) + def factor_rows(self, connection: sqlite3.Connection, snapshot_id: int) -> list[dict[str, Any]]: return [ json.loads(str(row["payload_json"])) @@ -180,6 +202,30 @@ class ScreenerRepository: ), ) + def save_backtest( + self, + connection: sqlite3.Connection, + run_id: int, + payload: dict[str, Any], + ) -> None: + connection.execute( + """ + INSERT OR IGNORE INTO screener_run_backtests ( + run_id, payload_json, created_at + ) VALUES (?, ?, ?) + """, + (run_id, _json(payload), _now()), + ) + + def backtest( + self, connection: sqlite3.Connection, run_id: int + ) -> dict[str, Any] | None: + row = connection.execute( + "SELECT payload_json FROM screener_run_backtests WHERE run_id = ?", + (run_id,), + ).fetchone() + return json.loads(str(row["payload_json"])) if row else None + def latest_runs( self, connection: sqlite3.Connection, diff --git a/next/backend/features/screener/routes.py b/next/backend/features/screener/routes.py index df6c0a7..db5cec5 100644 --- a/next/backend/features/screener/routes.py +++ b/next/backend/features/screener/routes.py @@ -15,6 +15,8 @@ from backend.features.accounts.auth import ( ) from backend.features.screener.schemas import ( CustomStrategyInput, + FormulaCompileInput, + FormulaCompileResponse, IdentifierResponse, MessageResponse, ScreenerCatalogResponse, @@ -24,6 +26,7 @@ from backend.features.screener.schemas import ( ) from backend.features.screener.service import ScreenerError from backend.http.errors import AppError +from backend.llm.gateway import LLMGatewayError router = APIRouter(prefix="/screener", tags=["screener"]) @@ -60,6 +63,15 @@ def save_custom( return _call(request, "save_custom", principal.user.id, payload.name, payload.formula) +@router.post("/formula/compile", response_model=FormulaCompileResponse) +def compile_formula( + payload: FormulaCompileInput, + request: Request, + principal: SmartWritePrincipal, +) -> dict: + return _call(request, "compile_formula", principal, payload.description) + + @router.delete("/custom/{strategy_id}", response_model=MessageResponse) def delete_custom( request: Request, @@ -110,3 +122,6 @@ def _call(request: Request, method: str, *args): return getattr(request.app.state.container.screener, method)(*args) except (ScreenerError, MarketDataUnavailable, ProviderError, DataQualityError) as exc: raise AppError("screener_unavailable", str(exc), 409) from exc + except LLMGatewayError as exc: + status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503 + raise AppError(exc.code, str(exc), status) from exc diff --git a/next/backend/features/screener/schemas.py b/next/backend/features/screener/schemas.py index 2ee018f..bfc72d6 100644 --- a/next/backend/features/screener/schemas.py +++ b/next/backend/features/screener/schemas.py @@ -38,6 +38,15 @@ class CustomStrategyInput(BaseModel): formula: dict[str, Any] +class FormulaCompileInput(BaseModel): + description: str = Field(min_length=5, max_length=500) + + +class FormulaCompileResponse(BaseModel): + formula: dict[str, Any] + prompt_version: str + + class TrackInput(BaseModel): run_id: int = Field(gt=0) identifier: str = Field(min_length=1, max_length=40) diff --git a/next/backend/features/screener/service.py b/next/backend/features/screener/service.py index eba0d1b..52959bd 100644 --- a/next/backend/features/screener/service.py +++ b/next/backend/features/screener/service.py @@ -9,12 +9,20 @@ from backend.data.contracts import SnapshotState from backend.data.gateway import DataGateway from backend.data.repository import MarketRepository from backend.database.connection import Database +from backend.features.accounts.models import Principal +from backend.features.screener.backtest import attach_historical_estimate, rolling_backtest from backend.features.screener.catalog import ( CatalogError, factor_catalog, strategy_catalog, validate_formula, ) +from backend.features.screener.compiler import ( + PROMPT_VERSION, + compile_messages, + description_key, + parse_compiled_formula, +) from backend.features.screener.engine import execute_formula from backend.features.screener.factors import build_factor_snapshot from backend.features.screener.repository import ( @@ -23,6 +31,7 @@ from backend.features.screener.repository import ( decode_run, decode_track, ) +from backend.llm.gateway import LLMGateway SHANGHAI = ZoneInfo("Asia/Shanghai") PHASE_REGIMES = { @@ -47,11 +56,13 @@ class ScreenerService: repository: ScreenerRepository, market_repository: MarketRepository, gateway: DataGateway, + llm: LLMGateway, ) -> None: self._database = database self._repository = repository self._market_repository = market_repository self._gateway = gateway + self._llm = llm def catalog(self) -> dict[str, Any]: factors = factor_catalog() @@ -89,10 +100,16 @@ class ScreenerService: decode_run(row) for row in self._repository.latest_runs(connection, "curated", through) ] - custom_runs = [ - decode_run(row) - for row in self._repository.latest_runs(connection, "custom", through, user_id) - ] + custom_runs = [] + for row in self._repository.latest_runs(connection, "custom", through, user_id): + decoded = decode_run(row) + if decoded is not None: + custom_runs.append( + attach_historical_estimate( + decoded, + self._repository.backtest(connection, int(row["id"])), + ) + ) return { "trade_date": through, "message": "", @@ -161,6 +178,28 @@ class ScreenerService: self._repository.save_custom_strategy(connection, user_id, normalized, formula) ) + def compile_formula(self, principal: Principal, description: str) -> dict[str, Any]: + normalized = " ".join(description.split()) + if not 5 <= len(normalized) <= 500: + raise ScreenerError("自然语言条件应为5至500个字符") + call = self._llm.prepare( + principal, + feature="screener_formula", + prompt_version=PROMPT_VERSION, + business_id=description_key(normalized), + input_chars=len(normalized), + ) + content = "".join( + event.content + for event in self._llm.stream(call, compile_messages(normalized)) + if event.type == "delta" + ) + try: + formula = parse_compiled_formula(content) + except CatalogError as exc: + raise ScreenerError(f"公式转换失败:{exc}") from exc + return {"formula": formula, "prompt_version": PROMPT_VERSION} + def delete_custom(self, user_id: int, strategy_id: int) -> None: with self._database.transaction() as connection: if not self._repository.delete_custom_strategy(connection, user_id, strategy_id): @@ -184,7 +223,7 @@ class ScreenerService: result = self._run(int(snapshot["id"]), str(snapshot["trade_date"]), strategy, user_id) if result is None: raise ScreenerError("自定义策略执行失败") - return result + return self._with_backtest(result, strategy["formula"], str(snapshot["trade_date"])) def tracks(self, user_id: int) -> list[dict[str, Any]]: with self._database.read() as connection: @@ -274,6 +313,32 @@ class ScreenerService: ).fetchone() ) + def _with_backtest( + self, + run: dict[str, Any], + formula: dict[str, Any], + through: str, + ) -> dict[str, Any]: + run_id = int(run["id"]) + with self._database.read() as connection: + existing = self._repository.backtest(connection, run_id) + if existing is None: + with self._database.read() as connection: + rows = self._repository.factor_snapshots_through(connection, through, 63) + history = [ + { + "trade_date": str(row["trade_date"]), + "coverage": json.loads(str(row["coverage_json"])), + "rows": self._repository.factor_rows(connection, int(row["id"])), + } + for row in reversed(rows) + ] + calculated = rolling_backtest(history, formula) + with self._database.transaction() as connection: + self._repository.save_backtest(connection, run_id, calculated) + existing = self._repository.backtest(connection, run_id) + return attach_historical_estimate(run, existing) + def _market_snapshot(self, trade_date: str) -> dict[str, Any]: with self._database.read() as connection: row = self._market_repository.latest_summary(connection, trade_date) diff --git a/next/docs/evidence/stage-15/acceptance.md b/next/docs/evidence/stage-15/acceptance.md index eca77e6..b82d7a1 100644 --- a/next/docs/evidence/stage-15/acceptance.md +++ b/next/docs/evidence/stage-15/acceptance.md @@ -8,8 +8,8 @@ ## 减法结果 -- 旧运行时代码:55个文件、61,793行;当前重建运行时代码:216个职责文件、24,677行。 -- 运行时代码净减少37,116行,约60.1%;迁移/备份工具、测试和文档未混入运行时比较。 +- 旧运行时代码:55个文件、61,793行;当前重建运行时代码:219个职责文件、25,053行。 +- 运行时代码净减少36,740行,约59.5%;迁移/备份工具、测试和文档未混入运行时比较。 - 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。 - LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。 - 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md`。 @@ -17,7 +17,7 @@ ## 维护与回退演练 - 已在真实迁移库副本执行schema 10 -> 9 -> 10,完整性及外键检查通过;明确验证数据回退只能在副本执行。 -- 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询保持只读;运行治理迁移后当前版本为`schema_version=11`。 +- 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询保持只读;加入选股回测持久化迁移后当前版本为`schema_version=12`。 - 人工维护入口、常见改动路径、数据源标准、故障定位与复杂度红线见`../../final/maintenance-guide.md`。 - NAS切换、观察和回退动作见`../../final/cutover-checklist.md`;正式容器未变更。 @@ -28,12 +28,12 @@ - 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer Policy;HTTPS增加HSTS。 - CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。 - 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。 -- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 296.90KB、CSS 92.08KB(未压缩)。 +- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 298.87KB、CSS 92.40KB(未压缩)。 ## 最终门禁 - Ruff:通过。 -- pytest:104项通过。 +- pytest:106项通过。 - Vue TypeScript:通过。 - Vitest:3个文件、7项通过。 - Vite生产构建:通过。 diff --git a/next/docs/final/completion-audit.md b/next/docs/final/completion-audit.md index e36274e..2fbd25a 100644 --- a/next/docs/final/completion-audit.md +++ b/next/docs/final/completion-audit.md @@ -21,14 +21,7 @@ - 数据库迁移、旧库只读迁移、备份、校验恢复、生产静态文件服务和安全响应头已有自动测试。 - PC 统一 Shell、设计令牌、日夜主题和独立移动端布局规则已经建立。 -## 阻断“完整迁移”的产品缺口 - -### P0:用户可见功能缺失 - -1. **自定义选股完整能力**:缺少自然语言转换受控公式和滚动回测;精选策略详情尚未完整显示评分权重、 - 执行频率和风险等级。 - -### P1:验收和交互覆盖不足 +## 阻断“完整迁移”的验收缺口 1. 固定案例 `C06`、`C08` 当前为未实现,不能标记通过。 2. `C05`、`U01` 的主题无白闪需要首帧/加载态直接断言,不能只依赖夜间截图。 @@ -59,7 +52,13 @@ - 状态栏读取真实后台任务与最后成功行情状态,不再显示固定占位文字。 - 模型池中的每个模型可由管理员独立测试;测试复用统一LLM网关并记录模型、耗时、成功或失败, 不返回密钥和上游正文,也不计入会员每日额度。 -- 本轮验证为Ruff、104项pytest、Vue类型检查、7项Vitest、生产构建和21项Playwright全部通过。 +- 自然语言选股只允许由统一LLM网关生成白名单JSON公式;未知因子直接拒绝,LLM不参与股票候选计算, + 用户必须核对生成的因子、权重和过滤条件后再保存。 +- 自定义选股滚动回测只读取已归档因子快照,以入选日至第3个后续交易日收盘计算;少于20个有效样本 + 只提示小样本,达到门槛后才显示胜率和平均3日收益,结果按运行持久化且重复读取不漂移。 +- 精选策略详情直接展示配置中已有的质量、风险、执行频率、适用环境、失效风险、适用阶段和完整评分权重, + 没有补造无法验证的策略元信息。 +- 本轮验证为Ruff、106项pytest、Vue类型检查、7项Vitest、生产构建和21项Playwright全部通过。 ## 外部环境阻断项 @@ -71,7 +70,7 @@ 只有以下条件全部满足后,才可恢复“重建完成”的表述: -1. 本文件所有 P0 缺口完成并有直接测试。 +1. 本文件登记的用户可见功能缺口全部完成并有直接测试。 2. 85 个固定案例逐项标记为“确定性测试”“浏览器直接断言”或“人工视觉验收”;不得使用宽泛截图代替算法证明。 3. 第 26 节页面覆盖矩阵逐格完成,人工验收项由用户确认。 4. Docker 构建、启动、持久化重启、备份恢复和回退在实际容器环境通过。 diff --git a/next/frontend/src/pages/screener/CustomPanel.vue b/next/frontend/src/pages/screener/CustomPanel.vue index b9262b5..649e046 100644 --- a/next/frontend/src/pages/screener/CustomPanel.vue +++ b/next/frontend/src/pages/screener/CustomPanel.vue @@ -16,6 +16,7 @@ const props = defineProps<{ factorGroups: Record; strategies: CustomStrategy[]; runs: ScreenerRun[]; + compiledFormula: ScreenerFormula | null; locked: boolean; busy: boolean; }>(); @@ -24,9 +25,11 @@ const emit = defineEmits<{ run: [strategy: CustomStrategy]; remove: [strategy: CustomStrategy]; track: [run: ScreenerRun, candidate: Candidate]; + compile: [description: string]; }>(); const name = ref("我的选股策略"); +const naturalLanguage = ref(""); const selectedFactor = ref("return_20d"); const scores = ref([ { field: "return_20d", weight: 60, direction: "desc" }, @@ -55,6 +58,11 @@ watch( }, ); +watch( + () => props.compiledFormula, + (formula) => { if (formula) applyFormula(formula); }, +); + function addFactor(): void { if (!selectedFactor.value || scores.value.some((item) => item.field === selectedFactor.value)) { return; @@ -66,18 +74,22 @@ function addFilter(): void { filters.value.push({ field: "amount_billion", op: ">=", value: 1 }); } -function edit(strategy: CustomStrategy): void { - selected.value = strategy.id; - name.value = strategy.name; - scores.value = strategy.formula.score.map((item) => ({ +function applyFormula(formula: ScreenerFormula): void { + scores.value = formula.score.map((item) => ({ ...item, weight: Math.round(item.weight * 100), })); - filters.value = strategy.formula.filters.map((item) => ({ ...item })); - listedDays.value = strategy.formula.universe.listed_days_min; - excludeSt.value = strategy.formula.universe.exclude_st; - outputLimit.value = strategy.formula.limit; - minimumScore.value = Math.round(strategy.formula.min_score * 100); + filters.value = formula.filters.map((item) => ({ ...item })); + listedDays.value = formula.universe.listed_days_min; + excludeSt.value = formula.universe.exclude_st; + outputLimit.value = formula.limit; + minimumScore.value = Math.round(formula.min_score * 100); +} + +function edit(strategy: CustomStrategy): void { + selected.value = strategy.id; + name.value = strategy.name; + applyFormula(strategy.formula); } function save(): void { @@ -128,6 +140,10 @@ function save(): void {

过滤与输出

+
+ + +