mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Generated
+18
-18
@@ -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" },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user