feat(factors): 因子生命周期状态下发 + 发现页三视图重构 (批量/单因子/挖掘)

- 因子注册表按 id 合并下发完整生命周期 status (draft/active/watch/retired),
  registry 的 stability 降为其二元投影; asset_type 过滤适用资产
- FactorDiscovery 重构为 批量/单因子/挖掘 三视图 (MiningWorkbench),
  标的列表解析兼容中英文逗号/分号/顿号/空白分隔
- FactorLibrary/FactorEditor/回测页配套调整

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-25 17:31:16 +08:00
parent 6c8805204e
commit 7f6cad0f8f
13 changed files with 1109 additions and 420 deletions
+49 -7
View File
@@ -1,25 +1,39 @@
"""因子注册表 API — 因子库 (P1) + 公式校验/试算 (P2) + 自定义/复合因子 CRUD (P3)。"""
from __future__ import annotations
from datetime import date, timedelta
import contextlib
import json
from collections.abc import Iterator
from datetime import timedelta
import polars as pl
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.market_time import cn_today
from app.factors import store
from app.factors.dsl import FACTOR_COLUMN, compile_formula
from app.factors.registry import all_factors, unregister_factor
from app.market_time import cn_today
router = APIRouter(prefix="/api/factors", tags=["factors"])
@router.get("")
def list_factors(asset_type: str | None = Query(default=None, pattern="^(stock|etf)$")) -> dict:
"""注册表因子列表; asset_type 过滤适用资产 (财务因子仅股票)。"""
def list_factors(request: Request, asset_type: str | None = Query(default=None, pattern="^(stock|etf)$")) -> dict:
"""注册表因子列表; asset_type 过滤适用资产 (财务因子仅股票)。
附带生命周期 status (draft/active/watch/retired, 仅盘上定义的自定义/复合
因子有): registry 的 stability 是 status 的二元投影 (active→stable, 其余
→experimental), 管理界面需要完整状态, 在此按 id 合并下发。
"""
specs = all_factors(asset_type=asset_type)
status_by_id: dict[str, str] = {}
# 数据目录不可用时退化为不带 status — 内置因子列表不因此不可用
with contextlib.suppress(HTTPException):
status_by_id = {
str(definition.get("id")): str(definition.get("status", "draft"))
for definition in store.load_all(_data_dir(request))
}
return {
"factors": [
{
@@ -35,6 +49,7 @@ def list_factors(asset_type: str | None = Query(default=None, pattern="^(stock|e
"pit": spec.pit,
"asset_types": sorted(spec.asset_types),
"stability": spec.stability,
"status": status_by_id.get(spec.id),
"scale_free": spec.scale_free,
"dependencies": sorted(spec.dependencies),
}
@@ -357,6 +372,33 @@ def update_custom_factor(factor_id: str, req: CustomFactorUpdateRequest, request
return {"ok": True, "id": factor_id, "version": target["version"], "status": target["status"]}
def _iter_json_strings(node) -> Iterator[str]:
"""递归产出 JSON 树中的所有字符串键与值 (用于精确等值匹配)。"""
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for key, value in node.items():
yield key
yield from _iter_json_strings(value)
elif isinstance(node, list):
for item in node:
yield from _iter_json_strings(item)
def _strategy_references_factor(text: str, factor_id: str) -> bool:
"""策略 JSON 是否引用该因子 — 解析后对字符串键值精确等值匹配。
历史实现为子串包含 (`factor_id in text`), id 互为前缀时误报
(删 uf_a 被 uf_ab 的引用挡住), 把无害删除逼成危险的强删路径。
JSON 损坏时回退子串匹配 (fail-closed: 宁可误拒不可漏放)。
"""
try:
parsed = json.loads(text)
except ValueError:
return factor_id in text
return any(value == factor_id for value in _iter_json_strings(parsed))
def _find_references(data_dir, factor_id: str) -> list[str]:
"""扫描策略与复合因子定义中的引用 (删除前 fail-closed 检查)。"""
references: list[str] = []
@@ -365,10 +407,10 @@ def _find_references(data_dir, factor_id: str) -> list[str]:
for file in strategies_dir.glob("*.json"):
try:
text = file.read_text(encoding="utf-8")
if factor_id in text:
references.append(f"strategies/{file.name}")
except OSError:
continue
if _strategy_references_factor(text, factor_id):
references.append(f"strategies/{file.name}")
for definition in store.load_all(data_dir):
if str(definition.get("id")) == factor_id:
continue
+5
View File
@@ -197,6 +197,11 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
from app.factors.store import load_into_registry
load_into_registry(data_dir)
# 扩展数据因子 (ext_) 与 uf_/cf_ 同理只在主进程注册; 不同步时挖掘
# 请求携带的 ext 因子会因注册表缺失被拒 (unknown mining factors)。
from app.factors.ext_factors import ensure_synced
ensure_synced(data_dir)
strategy_engine = StrategyEngine(
strategy_dirs=_strategy_dirs(data_dir),
override_loader=lambda sid: strategy_config.load_override(data_dir, sid),
+34
View File
@@ -484,3 +484,37 @@ def test_cjk_string_field_end_to_end(data_dir):
assert col in frame.columns
out = custom_signals.inject(frame, custom_signals.build_expressions([sig]))
assert out["csg_robot"].to_list() == [True]
# ── worker 子进程注册表同步 (线上回归) ────────────────────
def test_mining_worker_registry_must_include_ext_factors(data_dir):
"""回归: 挖掘 worker 只 load_into_registry (uf_/cf_) 时注册表缺 ext 因子,
_decode_runtime_request 直接报 unknown mining factors (线上实测)。
worker 入口在 load_into_registry 后必须再 ensure_synced; 这里锁定两个状态
的 factor_columns_view 差异, 防止未来把 ensure_synced 从 worker 里删掉。
"""
from app.backtest.mining_runtime import _decode_runtime_request
from app.factors.registry import factor_columns_view
from app.factors.store import load_into_registry
# 配置 id 本身带 ext_ 前缀 → 列名双前缀 (与线上报错的 id 形态一致)
_mk_config(data_dir, cid="ext_auction")
col = ext_factors.ext_column_name("ext_auction", "hot")
assert col == "ext_ext_auction_hot"
# 旧 worker 行为: 只加载 uf_/cf_ → 校验拒绝
load_into_registry(data_dir)
assert col not in {item["id"] for item in factor_columns_view()}
payload = {"run_id": "r1", "request": {"factor_names": [col]}}
with pytest.raises(ValueError, match="unknown mining factors"):
_decode_runtime_request(payload, data_dir, strategy_engine=None)
# 修复后的 worker 行为: 同步 ext 因子 → 校验放行 (后续解码错误不算失败)
ext_factors.ensure_synced(data_dir)
assert col in {item["id"] for item in factor_columns_view()}
try:
_decode_runtime_request(payload, data_dir, strategy_engine=None)
except ValueError as exc:
assert "unknown mining factors" not in str(exc)
+39
View File
@@ -334,3 +334,42 @@ def test_delete_with_strategy_reference_requires_force(tmp_path, cleanup_registr
assert forced.status_code == 200
assert forced.json()["removed_references"] == ["strategies/my_strategy.json"]
assert get_factor("uf_strat_ref") is None
def test_list_factors_exposes_lifecycle_status(tmp_path, cleanup_registry):
"""列表接口须下发完整生命周期 status (draft/active/watch/retired)。
stability 只是 status 的二元投影 (active→stable, 其余→experimental),
管理界面的状态列/筛选靠完整 status; 内置因子无盘上定义, status 为 null。
"""
import json as _json
from types import SimpleNamespace
from app.factors import store as factor_store
definition = {
"id": "uf_status_probe", "kind": "custom", "label": "状态探针",
"group": "自定义", "formula": "rank(-ts_sum(change_pct, 5))",
"description": "", "direction": "none", "version": 1, "status": "watch",
"created_at": factor_store._now(), "updated_at": factor_store._now(),
}
factor_store.register_definition(definition)
cleanup_registry.add("uf_status_probe")
factor_dir = tmp_path / "user_data" / "custom_factors"
factor_dir.mkdir(parents=True)
(factor_dir / "uf_status_probe.json").write_text(
_json.dumps(definition, ensure_ascii=False), encoding="utf-8",
)
app = FastAPI()
app.include_router(router)
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
client = TestClient(app)
payload = client.get("/api/factors").json()
probe = next(item for item in payload["factors"] if item["id"] == "uf_status_probe")
assert probe["status"] == "watch"
assert probe["stability"] == "experimental" # 二元投影保留, 向后兼容
builtin = next(item for item in payload["factors"] if item["kind"] == "base")
assert builtin["status"] is None
@@ -78,3 +78,58 @@ def test_delete_missing_factor_returns_404(env):
response = client.delete("/api/factors/custom/uf_not_there_at_all")
assert response.status_code == 404
# ── 引用检查按 JSON 精确匹配 (不按子串) ─────────────────────
def test_delete_ignores_prefix_id_references(env, tmp_path):
"""策略引用的是更长的前缀 id (uf_..._v2) 时, 不得挡住本因子的删除。
历史子串实现会把这类删除误判为「有引用」, 逼用户走危险的强删路径。
"""
client, factor_path = env
(tmp_path / "strategies" / "s1.json").unlink() # env 自带的精确引用, 本组用例不需要
(tmp_path / "strategies" / "s_prefix.json").write_text(
json.dumps({"id": "s_prefix", "factors": [f"{FACTOR_ID}_v2"]}, ensure_ascii=False),
encoding="utf-8",
)
response = client.delete(f"/api/factors/custom/{FACTOR_ID}")
assert response.status_code == 200
assert response.json()["removed_references"] == []
assert not factor_path.exists()
def test_delete_counts_exact_reference_anywhere_in_json(env, tmp_path):
"""精确引用无论出现在哪个字段 (含嵌套) 都要命中。"""
client, _ = env
(tmp_path / "strategies" / "s1.json").unlink()
(tmp_path / "strategies" / "s_nested.json").write_text(
json.dumps(
{"id": "s_nested", "rules": [{"factor": FACTOR_ID, "weight": 1.0}]},
ensure_ascii=False,
),
encoding="utf-8",
)
response = client.delete(f"/api/factors/custom/{FACTOR_ID}")
assert response.status_code == 409
assert response.json()["detail"]["references"] == ["strategies/s_nested.json"]
def test_delete_malformed_strategy_json_falls_back_to_substring(env, tmp_path):
"""策略 JSON 损坏时回退子串匹配 — fail-closed, 宁可误拒不可漏放。"""
client, _ = env
(tmp_path / "strategies" / "s1.json").unlink()
(tmp_path / "strategies" / "s_broken.json").write_text(
'{"id": "s_broken", "factors": [' + FACTOR_ID, # 截断的非法 JSON
encoding="utf-8",
)
response = client.delete(f"/api/factors/custom/{FACTOR_ID}")
assert response.status_code == 409
assert response.json()["detail"]["references"] == ["strategies/s_broken.json"]
+18 -18
View File
@@ -1,12 +1,12 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'emscripten'",
"python_full_version == '3.14.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'emscripten'",
"python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.14' and sys_platform == 'emscripten'",
@@ -128,7 +128,7 @@ name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'win32'" },
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
@@ -255,7 +255,7 @@ name = "clr-loader"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "sys_platform == 'win32'" },
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" }
wheels = [
@@ -1395,7 +1395,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -1862,7 +1862,7 @@ name = "pyobjc-framework-cocoa"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" }
wheels = [
@@ -1881,8 +1881,8 @@ name = "pyobjc-framework-quartz"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" }
wheels = [
@@ -1901,8 +1901,8 @@ name = "pyobjc-framework-security"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" }
wheels = [
@@ -1921,8 +1921,8 @@ name = "pyobjc-framework-uniformtypeidentifiers"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" }
wheels = [
@@ -1934,8 +1934,8 @@ name = "pyobjc-framework-webkit"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" }
wheels = [
@@ -2044,7 +2044,7 @@ name = "pythonnet"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "clr-loader", marker = "sys_platform == 'win32'" },
{ name = "clr-loader" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" }
wheels = [
@@ -2143,7 +2143,7 @@ name = "qtpy"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
wheels = [
@@ -2579,7 +2579,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "numba", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=0.65.1" },
{ name = "openai", specifier = ">=1.40" },
{ name = "openai", specifier = ">=1.66" },
{ name = "pandas", specifier = ">=2.2" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "platformdirs", specifier = ">=4.0" },
+91 -39
View File
@@ -1,48 +1,78 @@
/**
* 因子页 — 按任务组织的三视图: 发现 (检验+挖掘) / 创建 (公式+组合) / 因子库 (管理)。
*
* 历史 5 平级 tab (检验/因子库/编辑器/组合/挖掘) 是三种任务被摊开: 评估类散在
* 两处、创建类散在两处。收敛后子视图各自有分段切换, 旧 tab 参数经 LEGACY_TABS
* 映射继续工作 (router 的 /mining 与回测页重定向不受影响)。
*/
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { BookmarkCheck, Combine, LibrarySquare, PenLine, Pickaxe, Sigma } from 'lucide-react'
import { BookmarkCheck, FlaskConical, LibrarySquare, Search } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { FactorDiscovery } from './backtest/FactorDiscovery'
import { FactorComposite } from './factors/FactorComposite'
import { FactorEditor } from './factors/FactorEditor'
import { FactorLibrary } from './factors/FactorLibrary'
import { MiningWorkbench } from './backtest/MiningWorkbench'
import { ResearchCandidatesDialog } from './backtest/ResearchCandidatesDialog'
type Tab = 'inspect' | 'library' | 'editor' | 'composite' | 'mining'
type Tab = 'discover' | 'create' | 'library'
type CreateMode = 'editor' | 'composite'
const TABS: Record<Tab, { title: string; icon: typeof Sigma }> = {
inspect: { title: '检验', icon: Sigma },
library: { title: '因子库', icon: LibrarySquare },
editor: { title: '编辑器', icon: PenLine },
composite: { title: '组合', icon: Combine },
mining: { title: '挖掘', icon: Pickaxe },
const TABS: Record<Tab, { title: string; icon: typeof Search; subtitle: string }> = {
discover: { title: '发现', icon: Search, subtitle: '批量检验与挖掘: 哪些特征历史上与涨跌相关' },
create: { title: '创建', icon: FlaskConical, subtitle: '写公式定义因子, 或勾选现有因子组合' },
library: { title: '因子库', icon: LibrarySquare, subtitle: '因子注册表目录与生命周期管理' },
}
// 旧五 tab → 新三 tab; view/mode 让旧深链 (如 tab=mining&run=xx) 落到正确子视图
const LEGACY_TABS: Record<string, { tab: Tab; view?: string; mode?: CreateMode }> = {
inspect: { tab: 'discover' },
mining: { tab: 'discover', view: 'mining' },
editor: { tab: 'create', mode: 'editor' },
composite: { tab: 'create', mode: 'composite' },
library: { tab: 'library' },
}
const CREATE_MODES: { id: CreateMode; label: string }[] = [
{ id: 'editor', label: '公式编辑器' },
{ id: 'composite', label: '因子组合' },
]
export function Factors() {
const [searchParams, setSearchParams] = useSearchParams()
const [candidatesOpen, setCandidatesOpen] = useState(false)
const navigate = useNavigate()
const requestedTab = searchParams.get('tab')
const activeTab: Tab = requestedTab === 'library' || requestedTab === 'editor' || requestedTab === 'composite' || requestedTab === 'mining'
? requestedTab
: 'inspect'
const focusFactor = activeTab === 'inspect' ? searchParams.get('focus') ?? '' : ''
const legacy = requestedTab ? LEGACY_TABS[requestedTab] : undefined
const activeTab: Tab = requestedTab && requestedTab in TABS
? requestedTab as Tab
: legacy?.tab ?? 'discover'
const requestedView = searchParams.get('view') ?? legacy?.view
const discoverView = requestedView === 'single' || requestedView === 'mining' ? requestedView : 'batch'
const requestedMode = (searchParams.get('mode') ?? legacy?.mode) as CreateMode | null
const createMode: CreateMode = requestedMode === 'composite' ? 'composite' : 'editor'
const focusFactor = activeTab === 'discover' ? searchParams.get('focus') ?? '' : ''
const editId = searchParams.get('edit') ?? ''
const patchParams = (patch: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams)
for (const [key, value] of Object.entries(patch)) {
if (value == null) next.delete(key)
else next.set(key, value)
}
setSearchParams(next, { replace: true })
}
const changeTab = (tab: Tab) => {
const next = new URLSearchParams(searchParams)
next.set('tab', tab)
next.delete('focus') // 切 tab 清除单因子聚焦
next.delete('edit') // 切 tab 清除编辑器载入目标
setSearchParams(next, { replace: true })
patchParams({ tab, focus: null, edit: null, view: null, mode: null })
}
return (
<div className="flex min-h-full flex-col bg-base xl:h-full">
<PageHeader
title="因子"
subtitle={<span className="hidden md:inline">{activeTab === 'library' ? '因子注册表目录' : activeTab === 'mining' ? '嵌套样本外因子与策略挖掘' : '因子研究与检验'}</span>}
subtitle={<span className="hidden md:inline">{TABS[activeTab].subtitle}</span>}
className="shrink-0 flex-wrap gap-x-4 gap-y-2 bg-base/95 px-3 lg:flex-nowrap lg:px-5"
right={(
<div className="flex w-full min-w-0 items-center gap-1.5 sm:gap-2 lg:w-auto">
@@ -69,12 +99,12 @@ export function Factors() {
type="button"
onClick={() => changeTab(tab)}
aria-current={active ? 'page' : undefined}
className={`inline-flex h-7 items-center gap-1 rounded-[5px] px-1.5 text-[11px] font-medium transition-colors sm:gap-1.5 sm:px-2.5 sm:text-xs ${active
className={`inline-flex h-7 items-center gap-1 rounded-[5px] px-2.5 text-xs font-medium transition-colors ${active
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:bg-elevated hover:text-foreground'
}`}
>
<Icon className="hidden h-3.5 w-3.5 sm:block" />
<Icon className="h-3.5 w-3.5" />
{mode.title}
</button>
)
@@ -86,28 +116,50 @@ export function Factors() {
/>
<main className="min-h-0 flex-1 px-3 pb-3 pt-3 lg:px-4 lg:pb-4 xl:overflow-hidden">
{activeTab === 'inspect' && <FactorDiscovery focusFactor={focusFactor} />}
{activeTab === 'editor' && <FactorEditor key={searchParams.get('edit') ?? ''} editId={searchParams.get('edit') ?? ''} />}
{activeTab === 'composite' && <FactorComposite />}
{activeTab === 'mining' && (
<div className="h-full min-h-0 xl:overflow-y-auto">
<MiningWorkbench />
{activeTab === 'discover' && (
<FactorDiscovery focusFactor={focusFactor} initialView={discoverView} />
)}
{activeTab === 'create' && (
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex shrink-0 items-center px-1">
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5">
{CREATE_MODES.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => patchParams({ mode: id, edit: null })}
aria-current={createMode === id ? 'true' : undefined}
className={`inline-flex h-7 items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${createMode === id
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:bg-elevated hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{createMode === 'editor' && editId && (
<button
type="button"
onClick={() => patchParams({ edit: null })}
className="ml-2 inline-flex items-center gap-1 rounded-btn px-2 py-1 text-[11px] text-muted transition-colors hover:bg-elevated hover:text-foreground"
title="退出编辑, 回到新建"
>
← 新建因子
</button>
)}
</div>
<div className="min-h-0 flex-1">
{createMode === 'editor'
? <FactorEditor key={editId} editId={editId} />
: <FactorComposite />}
</div>
</div>
)}
{activeTab === 'library' && (
<FactorLibrary
onInspect={factorId => {
const next = new URLSearchParams(searchParams)
next.set('tab', 'inspect')
next.set('focus', factorId)
setSearchParams(next)
}}
onEdit={factorId => {
const next = new URLSearchParams(searchParams)
next.set('tab', 'editor')
next.set('edit', factorId)
setSearchParams(next)
}}
onInspect={factorId => patchParams({ tab: 'discover', view: 'batch', focus: factorId, edit: null, mode: null })}
onEdit={factorId => patchParams({ tab: 'create', mode: 'editor', edit: factorId, focus: null, view: null })}
/>
)}
</main>
@@ -94,9 +94,9 @@ export function AutoMiningDialog({
start: start || null,
end: end || null,
})
toast('已提交自动挖掘:正在后台筛选因子,可离开本页,进度见「挖掘」页', 'success')
toast('已提交自动挖掘:正在后台筛选因子,可离开本页,进度见「发现 · 挖掘工作台」', 'success')
onClose()
navigate('/factors?tab=mining')
navigate('/factors?tab=discover&view=mining')
}
return (
+14 -5
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Play, BarChart3, BookmarkPlus, Clock } from 'lucide-react'
@@ -83,7 +83,7 @@ function LoadingPanel({ symbolsText }: { symbolsText: string }) {
)
}
export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initialFactorName?: string }) {
export function FactorBacktest({ initialFactorName = 'momentum_20d', autoRun = false }: { initialFactorName?: string; autoRun?: boolean }) {
const queryClient = useQueryClient()
const [factorName, setFactorName] = useState(initialFactorName)
const [symbols, setSymbols] = useState('')
@@ -127,7 +127,7 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
api.factorRun({
factor_name: factorName,
asset_type: assetType,
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
symbols: symbols ? symbols.split(/[,,;;、\s]+/).map(s => s.trim()).filter(Boolean) : null,
start: start || null,
end: end || undefined,
n_groups: nGroups,
@@ -145,6 +145,15 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
},
})
// 详情面板等嵌入场景: 挂载即自动运行一次 (默认手动, 页面行为不变)
const autoRunRef = useRef(false)
useEffect(() => {
if (!autoRun || autoRunRef.current) return
autoRunRef.current = true
run.mutate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRun])
const saveCandidate = useMutation({
mutationFn: () => {
if (!result) throw new Error('暂无因子结果')
@@ -354,7 +363,7 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
<EmptyState
icon={BarChart3}
title="选择因子并开始分析"
hint="因子回测分析因子的预测能力 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
hint="因子回测度量因子的历史有效性 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
/>
)}
@@ -378,7 +387,7 @@ export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initial
{/* IC/IR 指标 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground">因子预测能力</h3>
<h3 className="text-sm font-medium text-foreground">因子历史有效性</h3>
<div className="flex items-center gap-2">
<button
type="button"
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,7 @@ export function FactorComposite() {
const lib = useQuery({
queryKey: QK.factorLibrary('all'),
queryFn: () => api.factorLibrary(),
staleTime: 60_000,
})
const save = useMutation({
mutationFn: () => api.factorCompositeCreate({
@@ -182,7 +183,7 @@ export function FactorComposite() {
</div>
<div className="text-[10px] leading-relaxed text-muted">
注册后复合因子进入注册表(因子库可见);在策略编辑的评分配置中直接引用即可完成研究线→交易线接入。
「检验」页可像普通因子一样检验组合。ICIR 自动权重模式将随 metrics 历史数据积累后开放。
「发现」页可像普通因子一样检验组合。ICIR 自动权重模式将随 metrics 历史数据积累后开放。
</div>
</>
)}
+5 -5
View File
@@ -347,8 +347,8 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
onClick={() => insertSnippet(col.id)}
className={CHIP_CLS}
>
<span className="font-mono text-accent">{col.id}</span>
<span className="ml-1 font-sans text-muted">{col.label}</span>
<span className="font-sans text-secondary">{col.label}</span>
<span className="ml-1 font-mono text-muted/70">{col.id}</span>
</button>
))}
</div>
@@ -503,7 +503,7 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
</div>
</div>
<div className="text-[10px] leading-relaxed text-muted">
试算仅为快照预览 (无成本/分层), 完整检验请到「检验」页运行。样本内表现不代表未来。
试算仅为快照预览 (无成本/分层), 完整检验请到「发现」页运行。样本内表现不代表未来。
</div>
{/* 注册/更新区: 试算有非空输出才可保存 (服务端同样 fail-closed 校验) */}
@@ -559,7 +559,7 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
{savedId && !save.isPending && (
<button
type="button"
onClick={() => navigate(`/factors?tab=inspect&focus=${savedId}`)}
onClick={() => navigate(`/factors?tab=discover&focus=${savedId}`)}
className="inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-btn border border-accent/40 px-3 text-xs font-medium text-accent transition-colors hover:bg-accent/10"
>
去检验 {savedId}
@@ -569,7 +569,7 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
<div className="mt-1 text-[10px] text-muted">
{editing
? '公式变化会保存为新版本并回草稿态 (需重新检验后激活); 仅改名称/分组保留当前状态。'
: '保存后状态为 draft;到「检验」页跑完整检验确认有效后,在因子库中激活。'}
: '保存后状态为 draft;到「发现」页跑完整检验确认有效后,在因子库中激活。'}
</div>
{savedId && savedVersion != null && (
<div className="mt-1 text-[10px] text-bull">已保存: {savedId} v{savedVersion}</div>
+194 -111
View File
@@ -16,20 +16,34 @@ const KIND_META: Record<FactorLibraryItem['kind'], { label: string; cls: string
custom: { label: '自定义', cls: 'bg-amber-400/10 text-amber-500' },
}
/** 生命周期状态 (仅自定义/复合因子有): 表格状态列、筛选与详情弹窗共用。 */
const STATUS_META: Record<string, { label: string; cls: string }> = {
active: { label: '激活', cls: 'bg-bull/10 text-bull' },
watch: { label: '观察', cls: 'bg-amber-400/10 text-amber-500' },
draft: { label: '草稿', cls: 'bg-base text-muted' },
retired: { label: '退役', cls: 'bg-danger/10 text-danger/80' },
}
const BUILTIN_STATUS = { label: '内置', cls: 'bg-elevated/60 text-muted' }
const statusOf = (item: FactorLibraryItem) =>
(item.status && STATUS_META[item.status]) || BUILTIN_STATUS
export function FactorLibrary({ onInspect, onEdit }: { onInspect: (factorId: string) => void; onEdit?: (factorId: string) => void }) {
const [query, setQuery] = useState('')
const [kind, setKind] = useState('all')
const [group, setGroup] = useState('all')
const [status, setStatus] = useState('all')
const [detail, setDetail] = useState<FactorLibraryItem | null>(null)
const lib = useQuery({
queryKey: QK.factorLibrary('all'),
queryFn: () => api.factorLibrary(),
// 注册表近乎静态 (只在保存/删除自定义因子时变化, 届时主动 invalidate), 切 tab 不再闪 loading
staleTime: 60_000,
})
const factors = lib.data?.factors ?? []
const groups = useMemo(
() => Array.from(new Set(factors.map(item => item.group))),
() => Array.from(new Set(factors.map(item => item.group))).sort((a, b) => a.localeCompare(b, 'zh')),
[factors],
)
@@ -38,10 +52,28 @@ export function FactorLibrary({ onInspect, onEdit }: { onInspect: (factorId: str
return factors.filter(item => {
if (kind !== 'all' && item.kind !== kind) return false
if (group !== 'all' && item.group !== group) return false
if (status !== 'all') {
if (status === 'builtin') {
if (item.status != null) return false
} else if (item.status !== status) {
return false
}
}
if (keyword && !`${item.id} ${item.label} ${item.formula}`.toLowerCase().includes(keyword)) return false
return true
})
}, [factors, query, kind, group])
}, [factors, query, kind, group, status])
// 按分组分区展示 (粘性组标题 + 响应式卡片网格), 替代旧八列宽表
const grouped = useMemo(() => {
const map = new Map<string, FactorLibraryItem[]>()
for (const item of filtered) {
const list = map.get(item.group) ?? []
list.push(item)
map.set(item.group, list)
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b, 'zh'))
}, [filtered])
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-card border border-border bg-surface/80">
@@ -67,10 +99,18 @@ export function FactorLibrary({ onInspect, onEdit }: { onInspect: (factorId: str
<option value="all">全部分组</option>
{groups.map(name => <option key={name} value={name}>{name}</option>)}
</select>
<select value={status} onChange={event => setStatus(event.target.value)} className={`${INPUT_CLS} w-24`} aria-label="生命周期状态">
<option value="all">全部状态</option>
<option value="builtin">内置</option>
<option value="active">激活</option>
<option value="watch">观察</option>
<option value="draft">草稿</option>
<option value="retired">退役</option>
</select>
<span className="ml-auto text-[10px] text-muted">{lib.isLoading ? '加载中…' : `${filtered.length} / ${factors.length} 个因子`}</span>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-h-0 flex-1 overflow-y-auto">
{lib.isError && (
<div className="m-3 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{String((lib.error as Error).message)}
@@ -82,58 +122,29 @@ export function FactorLibrary({ onInspect, onEdit }: { onInspect: (factorId: str
{!lib.isLoading && !lib.isError && filtered.length === 0 && (
<div className="p-6 text-center text-xs text-muted">无匹配因子</div>
)}
{!lib.isLoading && filtered.length > 0 && (
<table className="w-full min-w-[760px] text-xs">
<thead className="sticky top-0 bg-elevated text-left text-[11px] text-secondary">
<tr>
<th className="px-3 py-2.5 font-medium">因子</th>
<th className="px-3 py-2.5 font-medium">分组</th>
<th className="px-3 py-2.5 font-medium">类型</th>
<th className="px-3 py-2.5 font-medium">公式</th>
<th className="px-3 py-2.5 font-medium" title="按需计算所需的最少历史交易日数">预热</th>
<th className="px-3 py-2.5 font-medium">适用</th>
<th className="w-24 px-3 py-2.5 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{filtered.map(item => (
<tr
key={item.id}
onClick={() => setDetail(item)}
className="cursor-pointer border-t border-border/70 transition-colors hover:bg-elevated/40"
>
<td className="px-3 py-2.5">
<div className="font-medium text-foreground">{item.label}</div>
<div className="mt-0.5 font-mono text-[10px] text-muted">{item.id}</div>
</td>
<td className="px-3 py-2.5 text-secondary">{item.group}</td>
<td className="px-3 py-2.5">
<span className={`inline-flex rounded-btn px-1.5 py-0.5 text-[10px] font-medium ${KIND_META[item.kind].cls}`}>
{KIND_META[item.kind].label}
</span>
{item.pit && <span className="ml-1 text-[10px] text-amber-500" title="点时数据: 仅使用公告日不晚于当日的财务数据">点时</span>}
</td>
<td className="max-w-[22rem] px-3 py-2.5">
<span className="block truncate text-secondary" title={item.formula}>{item.formula}</span>
</td>
<td className="px-3 py-2.5 font-mono text-secondary">{item.warmup_bars > 1 ? `${item.warmup_bars}日` : '—'}</td>
<td className="px-3 py-2.5 text-muted">
{item.asset_types.includes('stock') && item.asset_types.includes('etf') ? '股票/ETF' : item.asset_types.join('/')}
</td>
<td className="px-3 py-2.5 text-right">
<button
type="button"
onClick={event => { event.stopPropagation(); onInspect(item.id) }}
className="inline-flex items-center gap-1 rounded-btn px-2 py-1 text-[10px] text-accent transition-colors hover:bg-accent/10"
title="去检验该因子(切到检验页并只选它)"
>
去检验 <ArrowRight className="h-3 w-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
{!lib.isLoading && grouped.length > 0 && (
<div className="space-y-4 p-3">
{grouped.map(([group, items]) => (
<section key={group}>
<header className="sticky top-0 z-10 -mx-3 mb-2 flex items-center gap-2 bg-surface/95 px-3 py-1.5 backdrop-blur-sm">
<span className="text-xs font-semibold text-foreground">{group}</span>
<span className="font-mono text-[10px] text-muted">{items.length}</span>
<span className="h-px flex-1 bg-border/60" aria-hidden="true" />
</header>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 2xl:grid-cols-3">
{items.map(item => (
<FactorCard
key={item.id}
item={item}
onOpen={setDetail}
onInspect={onInspect}
onEdit={onEdit}
/>
))}
</div>
</section>
))}
</div>
)}
</div>
@@ -142,6 +153,71 @@ export function FactorLibrary({ onInspect, onEdit }: { onInspect: (factorId: str
)
}
/** 因子卡片: 分组网格的单个因子 — 公式完整换行展示 (旧表格列被截断),
* 点击卡片开详情弹窗, 悬停直达 去检验/编辑。 */
function FactorCard({
item,
onOpen,
onInspect,
onEdit,
}: {
item: FactorLibraryItem
onOpen: (item: FactorLibraryItem) => void
onInspect: (factorId: string) => void
onEdit?: (factorId: string) => void
}) {
const assetLabel = item.asset_types.includes('stock') && item.asset_types.includes('etf')
? '股票/ETF'
: item.asset_types.join('/')
return (
<div
onClick={() => onOpen(item)}
title="点击查看完整详情"
className="group flex cursor-pointer flex-col gap-2 rounded-card border border-border bg-base/40 p-3 transition-colors duration-150 ease-smooth hover:border-accent/40 hover:bg-elevated/30"
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-xs font-medium text-foreground">{item.label}</span>
<span className={`inline-flex shrink-0 rounded-btn px-1.5 py-0.5 text-[10px] font-medium ${KIND_META[item.kind].cls}`}>
{KIND_META[item.kind].label}
</span>
<span className={`inline-flex shrink-0 rounded-btn px-1.5 py-0.5 text-[10px] font-medium ${statusOf(item).cls}`}>
{statusOf(item).label}
</span>
{item.pit && (
<span className="shrink-0 text-[10px] text-amber-500" title="点时数据: 仅使用公告日不晚于当日的财务数据">点时</span>
)}
</div>
<div className="font-mono text-[10px] text-muted">{item.id}</div>
<p className="break-all font-mono text-[11px] leading-relaxed text-secondary">{item.formula}</p>
<div className="mt-auto flex items-center justify-between gap-2 border-t border-border/50 pt-2">
<span className="min-w-0 truncate text-[10px] text-muted" title={`预热: ${item.warmup_bars > 1 ? `${item.warmup_bars} 个交易日` : '无需滚动窗口'} · 适用: ${assetLabel}`}>
{item.warmup_bars > 1 ? `预热 ${item.warmup_bars} 日` : '无预热'} · {assetLabel}
</span>
<div className="flex shrink-0 items-center gap-0.5">
{item.kind === 'custom' && onEdit && (
<button
type="button"
onClick={event => { event.stopPropagation(); onEdit(item.id) }}
className="rounded-btn px-2 py-1 text-[10px] text-secondary opacity-0 transition-opacity duration-150 ease-smooth hover:bg-elevated hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
title="在编辑器中打开该公式"
>
编辑
</button>
)}
<button
type="button"
onClick={event => { event.stopPropagation(); onInspect(item.id) }}
className="inline-flex items-center gap-0.5 rounded-btn px-2 py-1 text-[10px] text-accent transition-colors hover:bg-accent/10"
title="去检验该因子(切到发现页并只选它)"
>
去检验 <ArrowRight className="h-3 w-3" />
</button>
</div>
</div>
</div>
)
}
function FactorDetailModal({
item,
onClose,
@@ -214,7 +290,6 @@ function FactorDetailModal({
['适用资产', item.asset_types.join(' / ')],
['跨标的可比', item.scale_free ? '是(可直接截面排序)' : '否(原值受价格尺度影响)'],
['依赖列', item.dependencies.length > 0 ? item.dependencies.join(', ') : '已物化列'],
['状态', item.stability === 'stable' ? '稳定' : item.stability],
]
return (
<Modal
@@ -230,6 +305,9 @@ function FactorDetailModal({
<span className={`inline-flex shrink-0 rounded-btn px-1.5 py-0.5 text-[10px] font-medium ${KIND_META[item.kind].cls}`}>
{KIND_META[item.kind].label}
</span>
<span className={`inline-flex shrink-0 rounded-btn px-1.5 py-0.5 text-[10px] font-medium ${statusOf(item).cls}`}>
{statusOf(item).label}
</span>
</div>
<div className="mt-1 font-mono text-[11px] text-muted">{item.id}</div>
</div>
@@ -247,52 +325,66 @@ function FactorDetailModal({
{rows.map(([key, value]) => (
<div key={key} className="flex items-start gap-3 text-xs">
<dt className="w-20 shrink-0 text-muted">{key}</dt>
<dd className="min-w-0 flex-1 break-words text-secondary">
{key === '分组' && isDynamic ? (
<span className="flex items-center gap-1.5" title="自定义/复合因子可修改分组; 内置因子分组固定">
<input
type="text"
value={groupDraft}
maxLength={24}
onChange={event => setGroupDraft(event.target.value)}
className="h-6 w-32 rounded-input border border-border bg-base/60 px-1.5 text-xs text-foreground focus:border-accent focus:outline-none"
aria-label="修改分组"
/>
<button
type="button"
onClick={() => setGroup.mutate()}
disabled={setGroup.isPending || !groupDraft.trim() || groupDraft.trim() === item.group}
className="rounded-btn border border-border px-1.5 py-0.5 text-[10px] text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-40"
>
保存
</button>
</span>
) : value}
</dd>
<dd className="min-w-0 flex-1 break-words text-secondary">{value}</dd>
</div>
))}
</dl>
{isDynamic && (
<div className="mt-4 rounded-btn border border-border bg-base/40 p-3">
<div className="text-[11px] font-medium text-secondary">管理</div>
<div className="mt-2 flex items-center gap-1.5">
<span className="w-10 shrink-0 text-[11px] text-muted" title="仅自定义/复合因子可改; 内置因子分组固定">分组</span>
<input
type="text"
value={groupDraft}
maxLength={24}
onChange={event => setGroupDraft(event.target.value)}
className="h-6 w-36 rounded-input border border-border bg-base/60 px-1.5 text-xs text-foreground focus:border-accent focus:outline-none"
aria-label="修改分组"
/>
<button
type="button"
onClick={() => setGroup.mutate()}
disabled={setGroup.isPending || !groupDraft.trim() || groupDraft.trim() === item.group}
className="rounded-btn border border-border px-2 py-0.5 text-[10px] text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-40"
>
{setGroup.isPending ? '保存中…' : '保存'}
</button>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
<span className="w-10 shrink-0 text-[11px] text-muted">状态</span>
{(['active', 'watch', 'draft', 'retired'] as const).map(statusKey => (
<button
key={statusKey}
type="button"
onClick={() => setStatus.mutate(statusKey)}
disabled={setStatus.isPending}
className={`rounded-btn border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40 ${(item.status ?? '') === statusKey
? 'border-accent/60 bg-accent/10 font-medium text-accent'
: 'border-border text-secondary hover:border-accent/40 hover:text-accent'
}`}
>
{statusKey === 'active' ? '激活' : statusKey === 'watch' ? '观察' : statusKey === 'retired' ? '退役' : '回草稿'}
</button>
))}
<span className="text-[10px] text-muted" title="生命周期语义">编辑公式后自动回草稿</span>
</div>
</div>
)}
<div className="mt-4 rounded-btn border border-border bg-base/40 px-3 py-2 text-[11px] leading-relaxed text-muted">
方向说明:因子方向不预填,以最近一次检验的 IC 符号为准(IC 为正 = 值大看多)。到「检验」页运行后可查看。
方向说明:因子方向不预填,以最近一次检验的 IC 符号为准(IC 为正 = 值大看多)。到「发现」页运行后可查看。
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-3">
{isDynamic && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-muted" title="生命周期流转">状态:</span>
{(['active', 'watch', 'retired', 'draft'] as const).map(status => (
<button
key={status}
type="button"
onClick={() => setStatus.mutate(status)}
disabled={setStatus.isPending}
className="rounded-btn border border-border px-2 py-0.5 text-[10px] text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-40"
>
{status === 'active' ? '激活' : status === 'watch' ? '观察' : status === 'retired' ? '退役' : '回草稿'}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => setGenerateOpen(true)}
className="inline-flex items-center gap-1 rounded-btn border border-accent/40 px-2.5 py-1.5 text-[11px] font-medium text-accent transition-colors hover:bg-accent/10"
title="按该因子生成单因子排名策略, 保存到自定义策略后可直接回测"
>
<Sparkles className="h-3 w-3" />
生成策略
</button>
{item.kind === 'custom' && onEdit && (
<button
type="button"
@@ -304,22 +396,6 @@ function FactorDetailModal({
编辑公式
</button>
)}
<button
type="button"
onClick={() => setGenerateOpen(true)}
className="inline-flex items-center gap-1 rounded-btn border border-accent/40 px-2.5 py-1.5 text-[11px] font-medium text-accent transition-colors hover:bg-accent/10"
title="按该因子生成单因子排名策略, 保存到自定义策略后可直接回测"
>
<Sparkles className="h-3 w-3" />
生成策略
</button>
<button
type="button"
onClick={() => { onClose(); onInspect(item.id) }}
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-btn bg-accent px-2.5 py-1.5 text-[11px] font-medium text-white transition-colors hover:bg-accent/90"
>
检验此因子 <ArrowRight className="h-3 w-3" />
</button>
{isDynamic && (
confirmDelete ? (
<>
@@ -351,6 +427,13 @@ function FactorDetailModal({
</button>
)
)}
<button
type="button"
onClick={() => { onClose(); onInspect(item.id) }}
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-btn bg-accent px-2.5 py-1.5 text-[11px] font-medium text-white transition-colors hover:bg-accent/90"
>
检验此因子 <ArrowRight className="h-3 w-3" />
</button>
{blockedRefs && (
<div className="w-full rounded-btn border border-danger/40 bg-danger/10 px-3 py-2 text-[11px] leading-relaxed text-danger">
<div className="font-medium">该因子仍被以下对象引用,删除后相关策略/复合因子将无法计算:</div>