mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
feat(v0.2): 完善量化研究与二次开发能力
This commit is contained in:
@@ -179,7 +179,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
brew install create-dmg
|
brew install create-dmg
|
||||||
create-dmg \
|
create-dmg \
|
||||||
--volname "TickFlow Stock Panel" \
|
--volname "Tick Stock Panel" \
|
||||||
--window-pos 200 120 \
|
--window-pos 200 120 \
|
||||||
--window-size 600 400 \
|
--window-size 600 400 \
|
||||||
--icon-size 100 \
|
--icon-size 100 \
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。
|
修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。
|
||||||
|
|
||||||
|
涉及代码二次开发、前端插槽、后端可替换策略、扩展注册或上游升级兼容时,还必须阅读 [`docs/secondary-development.md`](docs/secondary-development.md)。该文档区分当前已实现能力与目标扩展契约;不得根据设计示例虚构尚不存在的 API。
|
||||||
|
|
||||||
同时遵守以下规则:
|
同时遵守以下规则:
|
||||||
|
|
||||||
- 先理解调用链和现有测试,再进行修改。
|
- 先理解调用链和现有测试,再进行修改。
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
# TickFlow Stock Panel 贡献、AI 开发与复审指南
|
# Tick Stock Panel 贡献、AI 开发与复审指南
|
||||||
|
|
||||||
本文档适用于整个仓库,供贡献者、AI 编码代理和 PR 审查者共同使用。目标是让改动落在正确的模块,保持数据口径、插件化和兼容性一致,并通过可复现的验证减少返工。
|
本文档适用于整个仓库,供贡献者、AI 编码代理和 PR 审查者共同使用。目标是让改动落在正确的模块,保持数据口径、插件化和兼容性一致,并通过可复现的验证减少返工。
|
||||||
|
|
||||||
`CONTRIBUTING.md` 是项目贡献与审查规范,不替代 `README.md`、`docs/` 中的用户文档和领域文档。所有贡献者和 AI 编码代理在修改代码、提交或审查 PR 前都应阅读本文档。
|
`CONTRIBUTING.md` 是项目贡献与审查规范,不替代 `README.md`、`docs/` 中的用户文档和领域文档。所有贡献者和 AI 编码代理在修改代码、提交或审查 PR 前都应阅读本文档。
|
||||||
|
|
||||||
|
涉及代码二次开发时,同时遵循 [`docs/secondary-development.md`](docs/secondary-development.md)。前端优先使用真实存在的受控插槽或注册入口,后端优先使用小粒度策略接口和依赖注入;现有扩展点无法表达核心行为变化时允许直接修改源码,但必须保持改动聚焦并补足兼容性验证。
|
||||||
|
|
||||||
## 1. 基本原则
|
## 1. 基本原则
|
||||||
|
|
||||||
### 1.1 修改前先理解
|
### 1.1 修改前先理解
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ PORT=3018 # 服务端口
|
|||||||
| [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) |
|
| [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) |
|
||||||
| [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、YAML 配置与 mock 联调示例 |
|
| [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、YAML 配置与 mock 联调示例 |
|
||||||
| [docs/strategy.md](./docs/strategy.md) | 策略体系(18 内置策略 + 三种扩展方式 + 文件结构) |
|
| [docs/strategy.md](./docs/strategy.md) | 策略体系(18 内置策略 + 三种扩展方式 + 文件结构) |
|
||||||
|
| [docs/secondary-development.md](./docs/secondary-development.md) | 代码二次开发、前端插槽、后端策略接口与 AI 开发模板 |
|
||||||
| [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) |
|
| [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) |
|
||||||
|
|
||||||
fork同时请点个star哦,欢迎 Issue 和 PR。
|
fork同时请点个star哦,欢迎 Issue 和 PR。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""TickFlow Stock Panel backend."""
|
"""Tick Stock Panel backend."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|||||||
+139
-3
@@ -134,11 +134,11 @@ def factor_columns():
|
|||||||
|
|
||||||
|
|
||||||
class FactorBacktestRequest(BaseModel):
|
class FactorBacktestRequest(BaseModel):
|
||||||
factor_name: str
|
factor_name: str = Field(..., min_length=1, max_length=64)
|
||||||
symbols: list[str] | None = None
|
symbols: list[str] | None = None
|
||||||
start: date | None = None
|
start: date | None = None
|
||||||
end: date | None = None
|
end: date | None = None
|
||||||
n_groups: int = 5
|
n_groups: int = Field(5, ge=2, le=10)
|
||||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||||
weight: Literal["equal", "factor_weight"] = "equal"
|
weight: Literal["equal", "factor_weight"] = "equal"
|
||||||
fees_pct: float = 0.0002
|
fees_pct: float = 0.0002
|
||||||
@@ -149,7 +149,10 @@ class FactorBacktestRequest(BaseModel):
|
|||||||
@router.post("/factor/run")
|
@router.post("/factor/run")
|
||||||
def factor_run(req: FactorBacktestRequest, request: Request):
|
def factor_run(req: FactorBacktestRequest, request: Request):
|
||||||
"""因子回测 — IC/IR 分析 + 分层回测。"""
|
"""因子回测 — IC/IR 分析 + 分层回测。"""
|
||||||
from app.backtest.factor import FactorBacktestService, FactorConfig
|
from app.backtest.factor import FACTOR_COLUMNS, FactorBacktestService, FactorConfig
|
||||||
|
|
||||||
|
if req.factor_name not in {item["id"] for item in FACTOR_COLUMNS}:
|
||||||
|
raise HTTPException(status_code=400, detail=f"不支持的因子: {req.factor_name}")
|
||||||
|
|
||||||
engine = _get_engine(request)
|
engine = _get_engine(request)
|
||||||
svc = FactorBacktestService(engine)
|
svc = FactorBacktestService(engine)
|
||||||
@@ -180,6 +183,139 @@ def factor_run(req: FactorBacktestRequest, request: Request):
|
|||||||
return asdict(result)
|
return asdict(result)
|
||||||
|
|
||||||
|
|
||||||
|
class FactorBatchRequest(BaseModel):
|
||||||
|
factor_names: list[str] = Field(..., min_length=1, max_length=64)
|
||||||
|
symbols: list[str] | None = None
|
||||||
|
start: date | None = None
|
||||||
|
end: date | None = None
|
||||||
|
n_groups: int = Field(5, ge=2, le=10)
|
||||||
|
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||||
|
weight: Literal["equal", "factor_weight"] = "equal"
|
||||||
|
fees_pct: float = 0.0002
|
||||||
|
slippage_bps: float = 5.0
|
||||||
|
asset_type: str = "stock"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/factor/batch")
|
||||||
|
def factor_batch(req: FactorBatchRequest, request: Request):
|
||||||
|
"""批量筛选因子, 同一批次只加载并计算一次数据面板。"""
|
||||||
|
from app.backtest.factor import (
|
||||||
|
FACTOR_COLUMNS,
|
||||||
|
FactorBacktestService,
|
||||||
|
FactorBatchConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
factor_names = list(dict.fromkeys(req.factor_names))
|
||||||
|
allowed = {item["id"] for item in FACTOR_COLUMNS}
|
||||||
|
invalid = [name for name in factor_names if name not in allowed]
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"不支持的因子: {', '.join(invalid)}")
|
||||||
|
|
||||||
|
end = req.end or date.today()
|
||||||
|
start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS)
|
||||||
|
_guard_server_backtest_range(start, end)
|
||||||
|
symbols = req.symbols if req.symbols else None
|
||||||
|
if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只, 请缩小标的范围。",
|
||||||
|
)
|
||||||
|
|
||||||
|
svc = FactorBacktestService(_get_engine(request))
|
||||||
|
result = svc.run_batch(FactorBatchConfig(
|
||||||
|
factor_names=factor_names,
|
||||||
|
symbols=symbols,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
n_groups=req.n_groups,
|
||||||
|
rebalance=req.rebalance,
|
||||||
|
weight=req.weight,
|
||||||
|
fees_pct=req.fees_pct,
|
||||||
|
slippage_bps=req.slippage_bps,
|
||||||
|
asset_type=req.asset_type,
|
||||||
|
))
|
||||||
|
return asdict(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 研究候选方案
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
class CandidateCreateRequest(BaseModel):
|
||||||
|
kind: Literal["factor", "strategy"]
|
||||||
|
name: str = Field(..., min_length=1, max_length=80)
|
||||||
|
source_id: str = Field(..., min_length=1, max_length=120)
|
||||||
|
config: dict = Field(default_factory=dict)
|
||||||
|
metrics: dict = Field(default_factory=dict)
|
||||||
|
data_as_of: date | None = None
|
||||||
|
status: Literal["pending", "validated", "rejected"] = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=80)
|
||||||
|
status: Literal["pending", "validated", "rejected"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_store():
|
||||||
|
from app.backtest.candidates import CandidateStore
|
||||||
|
|
||||||
|
return CandidateStore(settings.data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_candidate_error(exc: Exception) -> None:
|
||||||
|
from app.backtest.candidates import CandidateValidationError
|
||||||
|
|
||||||
|
status_code = 400 if isinstance(exc, CandidateValidationError) else 500
|
||||||
|
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidates")
|
||||||
|
def candidates_list():
|
||||||
|
try:
|
||||||
|
return {"items": _candidate_store().list()}
|
||||||
|
except Exception as exc:
|
||||||
|
_raise_candidate_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidates")
|
||||||
|
def candidate_create(req: CandidateCreateRequest):
|
||||||
|
try:
|
||||||
|
return _candidate_store().create(
|
||||||
|
kind=req.kind,
|
||||||
|
name=req.name,
|
||||||
|
source_id=req.source_id,
|
||||||
|
config=req.config,
|
||||||
|
metrics=req.metrics,
|
||||||
|
data_as_of=req.data_as_of.isoformat() if req.data_as_of else None,
|
||||||
|
status=req.status,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_raise_candidate_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/candidates/{candidate_id}")
|
||||||
|
def candidate_update(candidate_id: str, req: CandidateUpdateRequest):
|
||||||
|
if req.name is None and req.status is None:
|
||||||
|
raise HTTPException(status_code=400, detail="至少提供一个需要更新的字段")
|
||||||
|
try:
|
||||||
|
return _candidate_store().update(candidate_id, name=req.name, status=req.status)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="候选方案不存在") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
_raise_candidate_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/candidates/{candidate_id}")
|
||||||
|
def candidate_delete(candidate_id: str):
|
||||||
|
try:
|
||||||
|
_candidate_store().delete(candidate_id)
|
||||||
|
return {"ok": True}
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="候选方案不存在") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
_raise_candidate_error(exc)
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 策略回测
|
# 策略回测
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class RuleModel(BaseModel):
|
|||||||
strategy_id: str | None = None
|
strategy_id: str | None = None
|
||||||
direction: str = "entry" # entry | exit | both
|
direction: str = "entry" # entry | exit | both
|
||||||
notify_events: list[str] | None = None
|
notify_events: list[str] | None = None
|
||||||
|
score_min: float | None = None
|
||||||
|
score_max: float | None = None
|
||||||
conditions: list[ConditionModel] = []
|
conditions: list[ConditionModel] = []
|
||||||
logic: str = "and" # and | or
|
logic: str = "and" # and | or
|
||||||
cooldown_seconds: int = 3600
|
cooldown_seconds: int = 3600
|
||||||
@@ -317,7 +319,6 @@ def delete_rule(rule_id: str, request: Request):
|
|||||||
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
|
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
|
||||||
|
|
||||||
import time as _time
|
import time as _time
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str],
|
def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str],
|
||||||
@@ -614,7 +615,7 @@ def trigger_ladder(request: Request):
|
|||||||
# 1. 落盘到 alerts.jsonl
|
# 1. 落盘到 alerts.jsonl
|
||||||
try:
|
try:
|
||||||
alert_store.append_many(repo.store.data_dir, rule_events)
|
alert_store.append_many(repo.store.data_dir, rule_events)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass # 落盘失败不阻断推送
|
pass # 落盘失败不阻断推送
|
||||||
|
|
||||||
# 2. SSE 推送 (入 pending_alerts 队列)
|
# 2. SSE 推送 (入 pending_alerts 队列)
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ from app.strategy.ai_generator import AIStrategyGenerator, find_meta_assignment
|
|||||||
from app.strategy.engine import StrategyDef, StrategyEngine
|
from app.strategy.engine import StrategyDef, StrategyEngine
|
||||||
from app.strategy.monitor import StrategyMonitorService
|
from app.strategy.monitor import StrategyMonitorService
|
||||||
from app.strategy.prompt_builder import build_step1, build_step2
|
from app.strategy.prompt_builder import build_step1, build_step2
|
||||||
|
from app.strategy.scoring import (
|
||||||
|
SCORING_DIRECTIONS,
|
||||||
|
effective_scoring,
|
||||||
|
effective_scoring_directions,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -136,14 +141,13 @@ def _strategy_detail(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""策略详情(含用户覆盖)"""
|
"""策略详情(含用户覆盖)"""
|
||||||
bf = {**s.basic_filter}
|
bf = {**s.basic_filter}
|
||||||
scoring = dict(s.meta.get("scoring", {}))
|
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||||
|
scoring_directions = effective_scoring_directions(overrides)
|
||||||
params_defaults = {p["id"]: p["default"] for p in s.meta.get("params", [])}
|
params_defaults = {p["id"]: p["default"] for p in s.meta.get("params", [])}
|
||||||
|
|
||||||
if overrides:
|
if overrides:
|
||||||
if overrides.get("basic_filter"):
|
if overrides.get("basic_filter"):
|
||||||
bf.update(overrides["basic_filter"])
|
bf.update(overrides["basic_filter"])
|
||||||
if overrides.get("scoring"):
|
|
||||||
scoring.update(overrides["scoring"])
|
|
||||||
# 用户保存的参数覆盖默认值: 合并进 params_defaults, 前端据此回显
|
# 用户保存的参数覆盖默认值: 合并进 params_defaults, 前端据此回显
|
||||||
if overrides.get("params"):
|
if overrides.get("params"):
|
||||||
params_defaults.update(overrides["params"])
|
params_defaults.update(overrides["params"])
|
||||||
@@ -166,6 +170,11 @@ def _strategy_detail(
|
|||||||
"params": s.meta.get("params", []),
|
"params": s.meta.get("params", []),
|
||||||
"params_defaults": params_defaults,
|
"params_defaults": params_defaults,
|
||||||
"scoring": scoring,
|
"scoring": scoring,
|
||||||
|
"scoring_directions": {
|
||||||
|
name: direction
|
||||||
|
for name, direction in scoring_directions.items()
|
||||||
|
if name in scoring
|
||||||
|
},
|
||||||
"entry_signals": overrides.get("entry_signals", s.entry_signals) if overrides else s.entry_signals,
|
"entry_signals": overrides.get("entry_signals", s.entry_signals) if overrides else s.entry_signals,
|
||||||
"exit_signals": overrides.get("exit_signals", s.exit_signals) if overrides else s.exit_signals,
|
"exit_signals": overrides.get("exit_signals", s.exit_signals) if overrides else s.exit_signals,
|
||||||
"minute_exit_trigger_supported_signals": sorted(MINUTE_EXIT_TRIGGER_SIGNALS),
|
"minute_exit_trigger_supported_signals": sorted(MINUTE_EXIT_TRIGGER_SIGNALS),
|
||||||
@@ -175,7 +184,6 @@ def _strategy_detail(
|
|||||||
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
|
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
|
||||||
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
|
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
|
||||||
"max_hold_days": overrides.get("max_hold_days", s.max_hold_days) if overrides else s.max_hold_days,
|
"max_hold_days": overrides.get("max_hold_days", s.max_hold_days) if overrides else s.max_hold_days,
|
||||||
"alerts": s.alerts,
|
|
||||||
"order_by": s.meta.get("order_by", "score"),
|
"order_by": s.meta.get("order_by", "score"),
|
||||||
"descending": s.meta.get("descending", True),
|
"descending": s.meta.get("descending", True),
|
||||||
"limit": s.meta.get("limit", 30),
|
"limit": s.meta.get("limit", 30),
|
||||||
@@ -407,6 +415,7 @@ def save_config(req: SaveConfigRequest, request: Request):
|
|||||||
if not engine.has(req.strategy_id):
|
if not engine.has(req.strategy_id):
|
||||||
raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在")
|
raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在")
|
||||||
|
|
||||||
|
_validate_scoring_config(req.overrides)
|
||||||
# 剥离与策略默认值相同的字段,只保存用户真正修改过的值
|
# 剥离与策略默认值相同的字段,只保存用户真正修改过的值
|
||||||
overrides = _strip_defaults(req.strategy_id, req.overrides, engine)
|
overrides = _strip_defaults(req.strategy_id, req.overrides, engine)
|
||||||
|
|
||||||
@@ -414,6 +423,44 @@ def save_config(req: SaveConfigRequest, request: Request):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/config")
|
||||||
|
def patch_config(req: SaveConfigRequest, request: Request):
|
||||||
|
engine = _get_engine(request)
|
||||||
|
if not engine.has(req.strategy_id):
|
||||||
|
raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在")
|
||||||
|
data_dir = _data_dir(request)
|
||||||
|
overrides = strategy_config.load_override(data_dir, req.strategy_id)
|
||||||
|
overrides.update(req.overrides)
|
||||||
|
_validate_scoring_config(overrides)
|
||||||
|
strategy_config.save_override(
|
||||||
|
data_dir,
|
||||||
|
req.strategy_id,
|
||||||
|
_strip_defaults(req.strategy_id, overrides, engine),
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_scoring_config(overrides: dict) -> None:
|
||||||
|
scoring = overrides.get("scoring")
|
||||||
|
if scoring is not None:
|
||||||
|
if not isinstance(scoring, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="评分权重必须是对象")
|
||||||
|
for name, weight in scoring.items():
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
raise HTTPException(status_code=400, detail="评分因子名称无效")
|
||||||
|
if isinstance(weight, bool) or not isinstance(weight, (int, float)) or not math.isfinite(weight) or weight < 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"评分因子 {name} 的权重必须是非负数")
|
||||||
|
directions = overrides.get("scoring_directions")
|
||||||
|
if directions is not None:
|
||||||
|
if not isinstance(directions, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="评分方向必须是对象")
|
||||||
|
invalid = [name for name, direction in directions.items() if direction not in SCORING_DIRECTIONS]
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"评分因子 {invalid[0]} 的方向无效")
|
||||||
|
if "scoring_replace" in overrides and not isinstance(overrides["scoring_replace"], bool):
|
||||||
|
raise HTTPException(status_code=400, detail="scoring_replace 必须是布尔值")
|
||||||
|
|
||||||
|
|
||||||
def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict:
|
def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict:
|
||||||
"""剥离与策略默认值相同的字段,避免默认值被固化到 override 中。
|
"""剥离与策略默认值相同的字段,避免默认值被固化到 override 中。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""量化研究候选方案的轻量本地存储。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
CandidateKind = Literal["factor", "strategy"]
|
||||||
|
CandidateStatus = Literal["pending", "validated", "rejected"]
|
||||||
|
|
||||||
|
MAX_CANDIDATES = 200
|
||||||
|
MAX_NAME_LENGTH = 80
|
||||||
|
MAX_PAYLOAD_BYTES = 32 * 1024
|
||||||
|
MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
_CONFIG_FIELDS: dict[str, frozenset[str]] = {
|
||||||
|
"factor": frozenset({
|
||||||
|
"factor_name", "symbols", "start", "end", "n_groups", "rebalance", "weight",
|
||||||
|
"fees_pct", "slippage_bps", "asset_type",
|
||||||
|
}),
|
||||||
|
"strategy": frozenset({
|
||||||
|
"strategy_id", "symbols", "start", "end", "params", "overrides", "matching",
|
||||||
|
"entry_fill", "exit_fill", "fees_pct", "commission_pct", "stamp_tax_pct",
|
||||||
|
"slippage_bps", "max_positions", "max_exposure_pct", "initial_capital",
|
||||||
|
"position_sizing", "mode", "holding_days", "asset_type", "minute_fill",
|
||||||
|
"regime_filter",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
_METRIC_FIELDS: dict[str, frozenset[str]] = {
|
||||||
|
"factor": frozenset({
|
||||||
|
"ic_mean", "ic_std", "ir", "ic_win_rate", "long_short_return",
|
||||||
|
"long_short_max_drawdown", "n_symbols", "n_dates", "elapsed_ms",
|
||||||
|
}),
|
||||||
|
"strategy": frozenset({
|
||||||
|
"total_return", "annual_return", "max_drawdown", "sharpe", "sortino", "win_rate",
|
||||||
|
"n_trades", "profit_factor", "avg_return", "median_return", "elapsed_ms",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateStoreError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateValidationError(CandidateStoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateStore:
|
||||||
|
def __init__(self, data_dir: Path) -> None:
|
||||||
|
self.path = Path(data_dir) / "user_data" / "research_candidates.json"
|
||||||
|
|
||||||
|
def list(self) -> list[dict[str, Any]]:
|
||||||
|
with _lock:
|
||||||
|
return self._load()
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
kind: CandidateKind,
|
||||||
|
name: str,
|
||||||
|
source_id: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
metrics: dict[str, Any],
|
||||||
|
data_as_of: str | None,
|
||||||
|
status: CandidateStatus = "pending",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clean_name = self._validate_name(name)
|
||||||
|
clean_source_id = source_id.strip()
|
||||||
|
if not clean_source_id or len(clean_source_id) > 120:
|
||||||
|
raise CandidateValidationError("候选来源标识不能为空且不能超过 120 个字符")
|
||||||
|
clean_config = self._validate_config(kind, config)
|
||||||
|
clean_metrics = self._validate_metrics(kind, metrics)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
items = self._load()
|
||||||
|
if len(items) >= MAX_CANDIDATES:
|
||||||
|
raise CandidateValidationError(f"候选方案最多保存 {MAX_CANDIDATES} 个")
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
item = {
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"kind": kind,
|
||||||
|
"name": clean_name,
|
||||||
|
"source_id": clean_source_id,
|
||||||
|
"config": clean_config,
|
||||||
|
"metrics": clean_metrics,
|
||||||
|
"data_as_of": data_as_of,
|
||||||
|
"status": status,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
items.insert(0, item)
|
||||||
|
self._write(items)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
candidate_id: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
status: CandidateStatus | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with _lock:
|
||||||
|
items = self._load()
|
||||||
|
for item in items:
|
||||||
|
if item["id"] != candidate_id:
|
||||||
|
continue
|
||||||
|
if name is not None:
|
||||||
|
item["name"] = self._validate_name(name)
|
||||||
|
if status is not None:
|
||||||
|
item["status"] = status
|
||||||
|
item["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
self._write(items)
|
||||||
|
return item
|
||||||
|
raise KeyError(candidate_id)
|
||||||
|
|
||||||
|
def delete(self, candidate_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
items = self._load()
|
||||||
|
remaining = [item for item in items if item["id"] != candidate_id]
|
||||||
|
if len(remaining) == len(items):
|
||||||
|
raise KeyError(candidate_id)
|
||||||
|
self._write(remaining)
|
||||||
|
|
||||||
|
def _load(self) -> list[dict[str, Any]]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
if self.path.stat().st_size > MAX_FILE_BYTES:
|
||||||
|
raise CandidateStoreError("候选方案文件过大, 已停止读取")
|
||||||
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except CandidateStoreError:
|
||||||
|
raise
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise CandidateStoreError("候选方案文件损坏或无法读取, 未执行覆盖写入") from exc
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise CandidateStoreError("候选方案文件格式无效, 未执行覆盖写入")
|
||||||
|
return [item for value in raw if (item := self._normalize(value)) is not None]
|
||||||
|
|
||||||
|
def _write(self, items: list[dict[str, Any]]) -> None:
|
||||||
|
payload = json.dumps(items, ensure_ascii=False, indent=2, allow_nan=False)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.path.with_suffix(".json.tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
stream.write(payload)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, self.path)
|
||||||
|
except OSError as exc:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise CandidateStoreError("候选方案保存失败") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize(value: Any) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
kind = value.get("kind")
|
||||||
|
if kind not in _CONFIG_FIELDS or not isinstance(value.get("id"), str):
|
||||||
|
return None
|
||||||
|
raw_config = value.get("config") if isinstance(value.get("config"), dict) else {}
|
||||||
|
config = {key: item for key, item in raw_config.items() if key in _CONFIG_FIELDS[kind]}
|
||||||
|
raw_metrics = value.get("metrics") if isinstance(value.get("metrics"), dict) else {}
|
||||||
|
metrics = {
|
||||||
|
key: item
|
||||||
|
for key, item in raw_metrics.items()
|
||||||
|
if key in _METRIC_FIELDS[kind] and not isinstance(item, (dict, list))
|
||||||
|
}
|
||||||
|
source_id = value.get("source_id") or config.get(f"{kind}_name") or config.get(f"{kind}_id")
|
||||||
|
if not isinstance(source_id, str) or not source_id:
|
||||||
|
return None
|
||||||
|
status = value.get("status")
|
||||||
|
if status not in {"pending", "validated", "rejected"}:
|
||||||
|
status = "pending"
|
||||||
|
return {
|
||||||
|
"id": value["id"],
|
||||||
|
"kind": kind,
|
||||||
|
"name": str(value.get("name") or source_id)[:MAX_NAME_LENGTH],
|
||||||
|
"source_id": source_id,
|
||||||
|
"config": config,
|
||||||
|
"metrics": metrics,
|
||||||
|
"data_as_of": value.get("data_as_of"),
|
||||||
|
"status": status,
|
||||||
|
"created_at": str(value.get("created_at") or ""),
|
||||||
|
"updated_at": str(value.get("updated_at") or value.get("created_at") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_name(name: str) -> str:
|
||||||
|
clean = name.strip()
|
||||||
|
if not clean:
|
||||||
|
raise CandidateValidationError("候选名称不能为空")
|
||||||
|
if len(clean) > MAX_NAME_LENGTH:
|
||||||
|
raise CandidateValidationError(f"候选名称不能超过 {MAX_NAME_LENGTH} 个字符")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_config(kind: CandidateKind, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
unknown = set(config) - _CONFIG_FIELDS[kind]
|
||||||
|
if unknown:
|
||||||
|
raise CandidateValidationError(f"候选配置包含不允许的字段: {', '.join(sorted(unknown))}")
|
||||||
|
CandidateStore._check_json_size(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_metrics(kind: CandidateKind, metrics: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
unknown = set(metrics) - _METRIC_FIELDS[kind]
|
||||||
|
if unknown:
|
||||||
|
raise CandidateValidationError(f"候选指标包含不允许的字段: {', '.join(sorted(unknown))}")
|
||||||
|
if any(isinstance(value, (dict, list)) for value in metrics.values()):
|
||||||
|
raise CandidateValidationError("候选指标只允许保存标量摘要")
|
||||||
|
CandidateStore._check_json_size(metrics)
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_json_size(value: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.dumps(value, ensure_ascii=False, allow_nan=False)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CandidateValidationError("候选内容无法序列化") from exc
|
||||||
|
if len(payload.encode("utf-8")) > MAX_PAYLOAD_BYTES:
|
||||||
|
raise CandidateValidationError("候选内容超过 32KB 限制")
|
||||||
+254
-40
@@ -15,27 +15,67 @@ import numpy as np
|
|||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from app.backtest.engine import BacktestEngine
|
from app.backtest.engine import BacktestEngine
|
||||||
|
from app.strategy.scoring import (
|
||||||
|
VIRTUAL_SCORING_DEPENDENCIES as DERIVED_FACTOR_DEPENDENCIES,
|
||||||
|
)
|
||||||
|
from app.strategy.scoring import (
|
||||||
|
materialize_scoring_columns,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标)
|
# 可研究因子目录。保留历史 ID 兼容已有候选方案; 价格尺度相关指标优先提供归一化版本。
|
||||||
FACTOR_COLUMNS: list[dict] = [
|
FACTOR_COLUMNS: list[dict] = [
|
||||||
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"},
|
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5个交易日累计收益率"},
|
||||||
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"},
|
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10个交易日累计收益率"},
|
||||||
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"},
|
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "20个交易日累计收益率"},
|
||||||
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"},
|
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30个交易日累计收益率"},
|
||||||
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"},
|
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "60个交易日累计收益率"},
|
||||||
{"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"},
|
{"id": "change_pct", "label": "日涨跌幅", "group": "动量", "desc": "当日收盘相对前收盘的收益率"},
|
||||||
{"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"},
|
|
||||||
{"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"},
|
{"id": "ma5_bias", "label": "MA5乖离", "group": "均线偏离", "desc": "收盘价 / MA5 - 1"},
|
||||||
{"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"},
|
{"id": "ma10_bias", "label": "MA10乖离", "group": "均线偏离", "desc": "收盘价 / MA10 - 1"},
|
||||||
{"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"},
|
{"id": "ma20_bias", "label": "MA20乖离", "group": "均线偏离", "desc": "收盘价 / MA20 - 1"},
|
||||||
{"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"},
|
{"id": "ma30_bias", "label": "MA30乖离", "group": "均线偏离", "desc": "收盘价 / MA30 - 1"},
|
||||||
{"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"},
|
{"id": "ma60_bias", "label": "MA60乖离", "group": "均线偏离", "desc": "收盘价 / MA60 - 1"},
|
||||||
{"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"},
|
{"id": "ema5_bias", "label": "EMA5乖离", "group": "均线偏离", "desc": "收盘价 / EMA5 - 1"},
|
||||||
{"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"},
|
{"id": "ema10_bias", "label": "EMA10乖离", "group": "均线偏离", "desc": "收盘价 / EMA10 - 1"},
|
||||||
{"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"},
|
{"id": "ema20_bias", "label": "EMA20乖离", "group": "均线偏离", "desc": "收盘价 / EMA20 - 1"},
|
||||||
{"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"},
|
{"id": "ema30_bias", "label": "EMA30乖离", "group": "均线偏离", "desc": "收盘价 / EMA30 - 1"},
|
||||||
|
{"id": "ema60_bias", "label": "EMA60乖离", "group": "均线偏离", "desc": "收盘价 / EMA60 - 1"},
|
||||||
|
|
||||||
|
{"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标"},
|
||||||
|
{"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标"},
|
||||||
|
{"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"},
|
||||||
|
|
||||||
|
{"id": "macd_hist", "label": "MACD柱(原值)", "group": "趋势", "desc": "兼容历史研究; 跨股票比较建议优先使用MACD柱强度"},
|
||||||
|
{"id": "macd_dif_pct", "label": "MACD DIF强度", "group": "趋势", "desc": "MACD DIF / 收盘价"},
|
||||||
|
{"id": "macd_dea_pct", "label": "MACD DEA强度", "group": "趋势", "desc": "MACD DEA / 收盘价"},
|
||||||
|
{"id": "macd_hist_pct", "label": "MACD柱强度", "group": "趋势", "desc": "MACD柱 / 收盘价, 消除股价尺度影响"},
|
||||||
|
{"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"},
|
||||||
|
{"id": "kdj_d", "label": "KDJ-D", "group": "趋势", "desc": "KDJ指标D值"},
|
||||||
|
{"id": "kdj_j", "label": "KDJ-J", "group": "趋势", "desc": "KDJ指标J值"},
|
||||||
|
{"id": "boll_position", "label": "布林位置", "group": "趋势", "desc": "收盘价在布林带下轨到上轨之间的位置"},
|
||||||
|
|
||||||
|
{"id": "annual_vol_20d", "label": "20日波动率", "group": "波动率", "desc": "20日收益率年化标准差"},
|
||||||
|
{"id": "atr_14", "label": "ATR(14)原值", "group": "波动率", "desc": "兼容历史研究; 跨股票比较建议优先使用ATR相对波动"},
|
||||||
|
{"id": "atr_pct", "label": "ATR相对波动", "group": "波动率", "desc": "ATR(14) / 收盘价"},
|
||||||
|
{"id": "amplitude", "label": "日振幅", "group": "波动率", "desc": "当日高低价差 / 前收盘价"},
|
||||||
|
{"id": "boll_width", "label": "布林带宽", "group": "波动率", "desc": "布林带上下轨宽度 / MA20"},
|
||||||
|
|
||||||
|
{"id": "vol_ratio_5d", "label": "5日量比", "group": "量价", "desc": "当日成交量 / 前5日平均成交量"},
|
||||||
|
{"id": "vol_ratio_10d", "label": "10日量比", "group": "量价", "desc": "当日成交量 / 前10日平均成交量"},
|
||||||
|
{"id": "vol_trend_5_10", "label": "成交量趋势", "group": "量价", "desc": "5日平均成交量 / 10日平均成交量 - 1"},
|
||||||
|
{"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "使用历史时点流通股本计算的当日换手率"},
|
||||||
|
{"id": "turnover_ratio_5d", "label": "换手率放大", "group": "量价", "desc": "当日换手率 / 前5日平均换手率 - 1"},
|
||||||
|
{"id": "log_amount", "label": "成交额对数", "group": "量价", "desc": "ln(成交额 + 1), 降低极端规模影响"},
|
||||||
|
{"id": "amount_ratio_5d", "label": "成交额放大", "group": "量价", "desc": "当日成交额 / 前5日平均成交额 - 1"},
|
||||||
|
|
||||||
|
{"id": "gap_return", "label": "开盘跳空", "group": "价格位置", "desc": "开盘价 / 前收盘价 - 1"},
|
||||||
|
{"id": "intraday_return", "label": "日内收益", "group": "价格位置", "desc": "收盘价 / 开盘价 - 1"},
|
||||||
|
{"id": "close_position", "label": "收盘位置", "group": "价格位置", "desc": "收盘价在当日最低价到最高价之间的位置"},
|
||||||
|
{"id": "distance_to_high_60d", "label": "距60日高点", "group": "价格位置", "desc": "收盘价 / 60日最高收盘价 - 1"},
|
||||||
|
{"id": "distance_from_low_60d", "label": "距60日低点", "group": "价格位置", "desc": "收盘价 / 60日最低收盘价 - 1"},
|
||||||
]
|
]
|
||||||
|
|
||||||
FACTOR_WARMUP_DAYS = 120
|
FACTOR_WARMUP_DAYS = 120
|
||||||
@@ -89,6 +129,47 @@ class FactorResult:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FactorBatchConfig:
|
||||||
|
factor_names: list[str]
|
||||||
|
symbols: list[str] | None
|
||||||
|
start: date
|
||||||
|
end: date
|
||||||
|
n_groups: int = 5
|
||||||
|
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||||
|
weight: Literal["equal", "factor_weight"] = "equal"
|
||||||
|
fees_pct: float = 0.0002
|
||||||
|
slippage_bps: float = 5.0
|
||||||
|
asset_type: str = "stock"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FactorBatchItem:
|
||||||
|
factor_name: str
|
||||||
|
label: str
|
||||||
|
group: str
|
||||||
|
ic_mean: float | None = None
|
||||||
|
ir: float | None = None
|
||||||
|
ic_win_rate: float | None = None
|
||||||
|
long_short_return: float | None = None
|
||||||
|
long_short_max_drawdown: float | None = None
|
||||||
|
n_symbols: int = 0
|
||||||
|
n_dates: int = 0
|
||||||
|
elapsed_ms: float = 0.0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FactorBatchResult:
|
||||||
|
run_id: str
|
||||||
|
config: dict
|
||||||
|
results: list[FactorBatchItem] = field(default_factory=list)
|
||||||
|
elapsed_ms: float = 0.0
|
||||||
|
n_symbols: int = 0
|
||||||
|
n_dates: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FactorBacktestService:
|
class FactorBacktestService:
|
||||||
def __init__(self, engine: BacktestEngine) -> None:
|
def __init__(self, engine: BacktestEngine) -> None:
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
@@ -96,21 +177,106 @@ class FactorBacktestService:
|
|||||||
def run(self, config: FactorConfig) -> FactorResult:
|
def run(self, config: FactorConfig) -> FactorResult:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
run_id = uuid.uuid4().hex[:10]
|
run_id = uuid.uuid4().hex[:10]
|
||||||
|
panel = self._load_factor_panel(config, [config.factor_name])
|
||||||
|
if panel.is_empty():
|
||||||
|
return self._error_result(config, run_id, t0, "无数据, 请检查日期范围或先运行盘后管道")
|
||||||
|
|
||||||
def _err(msg: str) -> FactorResult:
|
return self._evaluate_panel(panel, config, run_id, t0)
|
||||||
return FactorResult(
|
|
||||||
|
def run_batch(self, config: FactorBatchConfig) -> FactorBatchResult:
|
||||||
|
"""在同一份 Panel 上依次评估多个因子, 避免重复读取和计算指标。"""
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
run_id = uuid.uuid4().hex[:10]
|
||||||
|
factor_names = list(dict.fromkeys(config.factor_names))
|
||||||
|
result_config = self._batch_config_to_dict(config, factor_names)
|
||||||
|
if not factor_names:
|
||||||
|
return FactorBatchResult(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
config=self._config_to_dict(config),
|
config=result_config,
|
||||||
error=msg,
|
error="至少选择一个因子",
|
||||||
elapsed_ms=(time.perf_counter() - t0) * 1000,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。
|
panel = self._load_factor_panel(config, factor_names)
|
||||||
panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"]
|
if panel.is_empty():
|
||||||
if config.factor_name not in panel_columns:
|
return FactorBatchResult(
|
||||||
panel_columns.append(config.factor_name)
|
run_id=run_id,
|
||||||
|
config=result_config,
|
||||||
|
error="无数据, 请检查日期范围或先运行盘后管道",
|
||||||
|
elapsed_ms=round((time.perf_counter() - t0) * 1000, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = {item["id"]: item for item in FACTOR_COLUMNS}
|
||||||
|
items: list[FactorBatchItem] = []
|
||||||
|
for factor_name in factor_names:
|
||||||
|
item_t0 = time.perf_counter()
|
||||||
|
factor_config = FactorConfig(
|
||||||
|
factor_name=factor_name,
|
||||||
|
symbols=config.symbols,
|
||||||
|
start=config.start,
|
||||||
|
end=config.end,
|
||||||
|
n_groups=config.n_groups,
|
||||||
|
rebalance=config.rebalance,
|
||||||
|
weight=config.weight,
|
||||||
|
fees_pct=config.fees_pct,
|
||||||
|
slippage_bps=config.slippage_bps,
|
||||||
|
asset_type=config.asset_type,
|
||||||
|
)
|
||||||
|
meta = metadata.get(factor_name, {})
|
||||||
|
try:
|
||||||
|
result = self._evaluate_panel(
|
||||||
|
panel,
|
||||||
|
factor_config,
|
||||||
|
f"{run_id}-{len(items) + 1}",
|
||||||
|
item_t0,
|
||||||
|
)
|
||||||
|
long_short = result.long_short_stats
|
||||||
|
items.append(FactorBatchItem(
|
||||||
|
factor_name=factor_name,
|
||||||
|
label=str(meta.get("label", factor_name)),
|
||||||
|
group=str(meta.get("group", "")),
|
||||||
|
ic_mean=result.ic_mean,
|
||||||
|
ir=result.ir,
|
||||||
|
ic_win_rate=result.ic_win_rate,
|
||||||
|
long_short_return=long_short.get("total_return"),
|
||||||
|
long_short_max_drawdown=long_short.get("max_drawdown"),
|
||||||
|
n_symbols=result.n_symbols,
|
||||||
|
n_dates=result.n_dates,
|
||||||
|
elapsed_ms=result.elapsed_ms,
|
||||||
|
error=result.error,
|
||||||
|
))
|
||||||
|
except Exception as exc: # 单因子失败不能中止整个筛选批次
|
||||||
|
logger.exception("factor batch item failed: %s", factor_name)
|
||||||
|
items.append(FactorBatchItem(
|
||||||
|
factor_name=factor_name,
|
||||||
|
label=str(meta.get("label", factor_name)),
|
||||||
|
group=str(meta.get("group", "")),
|
||||||
|
elapsed_ms=round((time.perf_counter() - item_t0) * 1000, 1),
|
||||||
|
error=str(exc),
|
||||||
|
))
|
||||||
|
|
||||||
|
n_symbols = max((item.n_symbols for item in items), default=0)
|
||||||
|
n_dates = max((item.n_dates for item in items), default=0)
|
||||||
|
return FactorBatchResult(
|
||||||
|
run_id=run_id,
|
||||||
|
config=result_config,
|
||||||
|
results=items,
|
||||||
|
elapsed_ms=round((time.perf_counter() - t0) * 1000, 1),
|
||||||
|
n_symbols=n_symbols,
|
||||||
|
n_dates=n_dates,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_factor_panel(
|
||||||
|
self,
|
||||||
|
config: FactorConfig | FactorBatchConfig,
|
||||||
|
factor_names: list[str],
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
panel_columns = [
|
||||||
|
"symbol", "date", "open", "high", "low", "close", "volume", "amount",
|
||||||
|
"turnover_rate",
|
||||||
|
]
|
||||||
|
panel_columns.extend(name for name in factor_names if name not in panel_columns)
|
||||||
load_start = config.start
|
load_start = config.start
|
||||||
if config.factor_name not in {"turnover_rate"}:
|
if any(name != "turnover_rate" for name in factor_names):
|
||||||
load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS)
|
load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS)
|
||||||
|
|
||||||
panel = self.engine.load_panel(
|
panel = self.engine.load_panel(
|
||||||
@@ -121,16 +287,29 @@ class FactorBacktestService:
|
|||||||
asset_type=config.asset_type,
|
asset_type=config.asset_type,
|
||||||
)
|
)
|
||||||
if panel.is_empty():
|
if panel.is_empty():
|
||||||
return _err("无数据,请检查日期范围或先运行盘后管道")
|
return panel
|
||||||
|
|
||||||
|
missing = set(factor_names) - set(panel.columns)
|
||||||
|
if missing:
|
||||||
|
panel = self._compute_missing_factors(panel, missing)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _evaluate_panel(
|
||||||
|
self,
|
||||||
|
source_panel: pl.DataFrame,
|
||||||
|
config: FactorConfig,
|
||||||
|
run_id: str,
|
||||||
|
t0: float,
|
||||||
|
) -> FactorResult:
|
||||||
|
def _err(msg: str) -> FactorResult:
|
||||||
|
return self._error_result(config, run_id, t0, msg)
|
||||||
|
|
||||||
factor_col = config.factor_name
|
factor_col = config.factor_name
|
||||||
if factor_col not in panel.columns:
|
if factor_col not in source_panel.columns:
|
||||||
panel = self._compute_missing_factor(panel, factor_col)
|
|
||||||
if factor_col not in panel.columns:
|
|
||||||
return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
|
return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
|
||||||
if "close" not in panel.columns:
|
if "close" not in source_panel.columns:
|
||||||
return _err("enriched 数据缺少收盘价 close")
|
return _err("enriched 数据缺少收盘价 close")
|
||||||
panel = panel.select(["symbol", "date", "close", factor_col])
|
panel = source_panel.select(["symbol", "date", "close", factor_col])
|
||||||
panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end))
|
panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end))
|
||||||
|
|
||||||
# 过滤有效行
|
# 过滤有效行
|
||||||
@@ -198,20 +377,39 @@ class FactorBacktestService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
|
def _compute_missing_factors(panel: pl.DataFrame, factor_cols: set[str]) -> pl.DataFrame:
|
||||||
required = {"symbol", "date", "open", "high", "low", "close", "volume"}
|
required = {"symbol", "date", "open", "high", "low", "close", "volume"}
|
||||||
if not required.issubset(panel.columns):
|
if not required.issubset(panel.columns):
|
||||||
missing = sorted(required - set(panel.columns))
|
missing = sorted(required - set(panel.columns))
|
||||||
logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing)
|
logger.warning("factors %s cannot be computed, missing columns: %s", factor_cols, missing)
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
from app.indicators.pipeline import compute_indicators
|
from app.indicators.pipeline import compute_indicators
|
||||||
|
|
||||||
# 只需要单个因子列 → 用 needed 裁剪, 跳过无关的 EMA/KDJ/RSI 等计算 pass
|
derived = factor_cols & set(DERIVED_FACTOR_DEPENDENCIES)
|
||||||
computed = compute_indicators(panel, needed={factor_col})
|
indicator_columns = factor_cols - derived
|
||||||
if factor_col not in computed.columns:
|
for factor_name in derived:
|
||||||
return panel
|
indicator_columns.update(DERIVED_FACTOR_DEPENDENCIES[factor_name])
|
||||||
return computed.select(["symbol", "date", "close", factor_col])
|
panel = compute_indicators(panel, needed=indicator_columns)
|
||||||
|
return FactorBacktestService._compute_derived_factors(panel, derived)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_derived_factors(panel: pl.DataFrame, factor_cols: set[str]) -> pl.DataFrame:
|
||||||
|
return materialize_scoring_columns(panel, factor_cols)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error_result(
|
||||||
|
config: FactorConfig,
|
||||||
|
run_id: str,
|
||||||
|
started_at: float,
|
||||||
|
message: str,
|
||||||
|
) -> FactorResult:
|
||||||
|
return FactorResult(
|
||||||
|
run_id=run_id,
|
||||||
|
config=FactorBacktestService._config_to_dict(config),
|
||||||
|
error=message,
|
||||||
|
elapsed_ms=round((time.perf_counter() - started_at) * 1000, 1),
|
||||||
|
)
|
||||||
|
|
||||||
# ── IC 计算 ──
|
# ── IC 计算 ──
|
||||||
|
|
||||||
@@ -527,4 +725,20 @@ class FactorBacktestService:
|
|||||||
"weight": c.weight,
|
"weight": c.weight,
|
||||||
"fees_pct": c.fees_pct,
|
"fees_pct": c.fees_pct,
|
||||||
"slippage_bps": c.slippage_bps,
|
"slippage_bps": c.slippage_bps,
|
||||||
|
"asset_type": c.asset_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _batch_config_to_dict(c: FactorBatchConfig, factor_names: list[str]) -> dict:
|
||||||
|
return {
|
||||||
|
"factor_names": factor_names,
|
||||||
|
"symbols": c.symbols,
|
||||||
|
"start": str(c.start),
|
||||||
|
"end": str(c.end),
|
||||||
|
"n_groups": c.n_groups,
|
||||||
|
"rebalance": c.rebalance,
|
||||||
|
"weight": c.weight,
|
||||||
|
"fees_pct": c.fees_pct,
|
||||||
|
"slippage_bps": c.slippage_bps,
|
||||||
|
"asset_type": c.asset_type,
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-50
@@ -34,6 +34,7 @@ from app.price_limits import (
|
|||||||
numpy_limit_price,
|
numpy_limit_price,
|
||||||
write_numpy_price_limit_matrix,
|
write_numpy_price_limit_matrix,
|
||||||
)
|
)
|
||||||
|
from app.strategy.scoring import SCORING_DIRECTION_LOW
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from numba import njit, prange
|
from numba import njit, prange
|
||||||
@@ -3397,6 +3398,7 @@ class MatrixPipelineConfig:
|
|||||||
scoring: dict[str, float]
|
scoring: dict[str, float]
|
||||||
order_by: str | None
|
order_by: str | None
|
||||||
descending: bool
|
descending: bool
|
||||||
|
scoring_directions: dict[str, str] = field(default_factory=dict)
|
||||||
asset_mask: np.ndarray | None = None
|
asset_mask: np.ndarray | None = None
|
||||||
protect_strategy_cache: bool = False
|
protect_strategy_cache: bool = False
|
||||||
|
|
||||||
@@ -3462,6 +3464,7 @@ class MatrixStrategyPipeline:
|
|||||||
config.order_by,
|
config.order_by,
|
||||||
config.descending,
|
config.descending,
|
||||||
fallback=signals.score,
|
fallback=signals.score,
|
||||||
|
directions=config.scoring_directions,
|
||||||
)
|
)
|
||||||
entry_codes = np.where(entry != 0, signals.entry_signal_code, -1).astype(np.int16)
|
entry_codes = np.where(entry != 0, signals.entry_signal_code, -1).astype(np.int16)
|
||||||
exit_codes = np.where(signals.exit != 0, signals.exit_signal_code, -1).astype(np.int16)
|
exit_codes = np.where(signals.exit != 0, signals.exit_signal_code, -1).astype(np.int16)
|
||||||
@@ -3502,14 +3505,7 @@ def _estimate_pipeline_cache_bytes(
|
|||||||
for name in feature_names:
|
for name in feature_names:
|
||||||
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
|
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
|
||||||
continue
|
continue
|
||||||
if name == "vol_ratio_5d":
|
estimated += 5 * float_bytes
|
||||||
estimated += 2 * float_bytes
|
|
||||||
elif name == "ma20_bias":
|
|
||||||
estimated += 2 * float_bytes
|
|
||||||
elif name == "change_pct" or (
|
|
||||||
name.startswith("momentum_") and name.endswith("d")
|
|
||||||
):
|
|
||||||
estimated += float_bytes
|
|
||||||
return estimated
|
return estimated
|
||||||
|
|
||||||
|
|
||||||
@@ -3588,6 +3584,7 @@ def build_matrix_score(
|
|||||||
descending: bool,
|
descending: bool,
|
||||||
*,
|
*,
|
||||||
fallback: np.ndarray,
|
fallback: np.ndarray,
|
||||||
|
directions: Mapping[str, str] | None = None,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
weights = {name: float(weight) for name, weight in scoring.items() if float(weight) != 0.0}
|
weights = {name: float(weight) for name, weight in scoring.items() if float(weight) != 0.0}
|
||||||
total_weight = sum(weights.values())
|
total_weight = sum(weights.values())
|
||||||
@@ -3639,6 +3636,8 @@ def build_matrix_score(
|
|||||||
np.divide(scratch, row_range[:, None], out=scratch, where=mask)
|
np.divide(scratch, row_range[:, None], out=scratch, where=mask)
|
||||||
np.logical_and(finite, ~varying_rows[:, None], out=mask)
|
np.logical_and(finite, ~varying_rows[:, None], out=mask)
|
||||||
scratch[mask] = np.float32(0.5)
|
scratch[mask] = np.float32(0.5)
|
||||||
|
if (directions or {}).get(name) == SCORING_DIRECTION_LOW:
|
||||||
|
scratch[finite] = np.float32(1.0) - scratch[finite]
|
||||||
scratch *= normalized_weight
|
scratch *= normalized_weight
|
||||||
score[:, start:stop] += scratch
|
score[:, start:stop] += scratch
|
||||||
score *= np.float32(100.0)
|
score *= np.float32(100.0)
|
||||||
@@ -3665,38 +3664,38 @@ def build_matrix_score(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_MATRIX_COMPUTED_FEATURES = frozenset({
|
||||||
|
"prev_close", "change_pct", "change_amount", "amplitude",
|
||||||
|
"boll_upper", "boll_lower", "boll_position", "boll_width",
|
||||||
|
"high_60d", "low_60d", "annual_vol_20d",
|
||||||
|
"macd_dif", "macd_dea", "macd_hist",
|
||||||
|
"macd_dif_pct", "macd_dea_pct", "macd_hist_pct",
|
||||||
|
"kdj_k", "kdj_d", "kdj_j", "atr_14", "atr_pct",
|
||||||
|
"vol_ma5", "vol_ma10", "vol_ratio_5d", "vol_ratio_10d", "vol_trend_5_10",
|
||||||
|
"turnover_ratio_5d", "log_amount", "amount_ratio_5d",
|
||||||
|
"gap_return", "intraday_return", "close_position",
|
||||||
|
"distance_to_high_60d", "distance_from_low_60d",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
def matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
||||||
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
|
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
|
||||||
return market.field(name)
|
return market.field(name)
|
||||||
close_feature = (
|
supported = (
|
||||||
name in {
|
name in _MATRIX_COMPUTED_FEATURES
|
||||||
"prev_close",
|
or (name.startswith("ma") and name.endswith("_bias") and name[2:-5].isdigit())
|
||||||
"change_pct",
|
or (name.startswith("ema") and name.endswith("_bias") and name[3:-5].isdigit())
|
||||||
"change_amount",
|
|
||||||
"amplitude",
|
|
||||||
"boll_upper",
|
|
||||||
"boll_lower",
|
|
||||||
"high_60d",
|
|
||||||
"low_60d",
|
|
||||||
"annual_vol_20d",
|
|
||||||
"ma20_bias",
|
|
||||||
}
|
|
||||||
or (name.startswith("ma") and name[2:].isdigit())
|
or (name.startswith("ma") and name[2:].isdigit())
|
||||||
|
or (name.startswith("ema") and name[3:].isdigit())
|
||||||
or (name.startswith("rsi_") and name[4:].isdigit())
|
or (name.startswith("rsi_") and name[4:].isdigit())
|
||||||
or (
|
or (name.startswith("momentum_") and name.endswith("d"))
|
||||||
name.startswith("momentum_") and name.endswith("d")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if close_feature:
|
if not supported:
|
||||||
source = market.close
|
|
||||||
elif name == "vol_ratio_5d":
|
|
||||||
source = market.volume
|
|
||||||
else:
|
|
||||||
raise ValueError(f"unsupported matrix feature: {name}")
|
raise ValueError(f"unsupported matrix feature: {name}")
|
||||||
with _activate_valid_bar_index(market.valid_bars):
|
with _activate_valid_bar_index(market.valid_bars):
|
||||||
return _cached_matrix_operation(
|
return _cached_matrix_operation(
|
||||||
"matrix_feature",
|
"matrix_feature",
|
||||||
(source,),
|
(market.close,),
|
||||||
{"name": name},
|
{"name": name},
|
||||||
lambda: _compute_matrix_feature(market, name),
|
lambda: _compute_matrix_feature(market, name),
|
||||||
)
|
)
|
||||||
@@ -3729,40 +3728,82 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"unsupported matrix feature: {name}") from exc
|
raise ValueError(f"unsupported matrix feature: {name}") from exc
|
||||||
return _valid_return_over_bars(market.close, close_valid, bars)
|
return _valid_return_over_bars(market.close, close_valid, bars)
|
||||||
if name == "vol_ratio_5d":
|
if name.startswith("ma") and name.endswith("_bias"):
|
||||||
|
period = int(name.removeprefix("ma").removesuffix("_bias"))
|
||||||
|
return _matrix_relative(market.close, valid_rolling_mean(market.close, close_valid, period))
|
||||||
|
if name.startswith("ema") and name.endswith("_bias"):
|
||||||
|
period = int(name.removeprefix("ema").removesuffix("_bias"))
|
||||||
|
return _matrix_relative(market.close, _matrix_ema(market.close, close_valid, period))
|
||||||
|
if name.startswith("ma") and name[2:].isdigit():
|
||||||
|
return valid_rolling_mean(market.close, close_valid, int(name[2:]))
|
||||||
|
if name.startswith("ema") and name[3:].isdigit():
|
||||||
|
return _matrix_ema(market.close, close_valid, int(name[3:]))
|
||||||
|
if name in {"macd_dif", "macd_dea", "macd_hist"}:
|
||||||
|
dif, dea = _matrix_macd(market.close, close_valid)
|
||||||
|
if name == "macd_dif":
|
||||||
|
return dif
|
||||||
|
if name == "macd_dea":
|
||||||
|
return dea
|
||||||
|
return ((dif - dea) * np.float32(2.0)).astype(np.float32, copy=False)
|
||||||
|
if name in {"macd_dif_pct", "macd_dea_pct", "macd_hist_pct"}:
|
||||||
|
source = matrix_feature(market, name.removesuffix("_pct"))
|
||||||
|
return _matrix_ratio(source, market.close)
|
||||||
|
if name in {"vol_ratio_5d", "vol_ratio_10d"}:
|
||||||
|
window = 5 if name == "vol_ratio_5d" else 10
|
||||||
volume_valid = close_valid & np.isfinite(market.volume)
|
volume_valid = close_valid & np.isfinite(market.volume)
|
||||||
previous_volume = valid_shift(market.volume, 1, volume_valid)
|
previous_volume = valid_shift(market.volume, 1, volume_valid)
|
||||||
previous_mean = valid_rolling_mean(
|
previous_mean = valid_rolling_mean(
|
||||||
previous_volume,
|
previous_volume,
|
||||||
np.isfinite(previous_volume),
|
np.isfinite(previous_volume),
|
||||||
5,
|
window,
|
||||||
)
|
)
|
||||||
|
return _matrix_ratio(market.volume, previous_mean)
|
||||||
|
if name in {"vol_ma5", "vol_ma10"}:
|
||||||
|
window = 5 if name == "vol_ma5" else 10
|
||||||
|
volume_valid = close_valid & np.isfinite(market.volume)
|
||||||
|
return valid_rolling_mean(market.volume, volume_valid, window)
|
||||||
|
if name == "vol_trend_5_10":
|
||||||
|
return _matrix_relative(
|
||||||
|
matrix_feature(market, "vol_ma5"),
|
||||||
|
matrix_feature(market, "vol_ma10"),
|
||||||
|
)
|
||||||
|
if name == "turnover_ratio_5d":
|
||||||
|
turnover = market.field("turnover_rate")
|
||||||
|
valid = close_valid & np.isfinite(turnover)
|
||||||
|
previous = valid_shift(turnover, 1, valid)
|
||||||
|
return _matrix_relative(
|
||||||
|
turnover,
|
||||||
|
valid_rolling_mean(previous, np.isfinite(previous), 5),
|
||||||
|
)
|
||||||
|
if name == "log_amount":
|
||||||
|
amount = market.field("amount")
|
||||||
out = np.full(market.shape, np.nan, dtype=np.float32)
|
out = np.full(market.shape, np.nan, dtype=np.float32)
|
||||||
np.divide(
|
valid = close_valid & np.isfinite(amount) & (amount >= 0)
|
||||||
market.volume,
|
np.log(amount + np.float32(1.0), out=out, where=valid)
|
||||||
previous_mean,
|
|
||||||
out=out,
|
|
||||||
where=volume_valid & np.isfinite(previous_mean) & (previous_mean != 0),
|
|
||||||
)
|
|
||||||
return out
|
return out
|
||||||
if name == "ma20_bias":
|
if name == "amount_ratio_5d":
|
||||||
ma20 = valid_rolling_mean(market.close, close_valid, 20)
|
amount = market.field("amount")
|
||||||
out = np.full(market.shape, np.nan, dtype=np.float32)
|
valid = close_valid & np.isfinite(amount)
|
||||||
np.divide(
|
previous = valid_shift(amount, 1, valid)
|
||||||
market.close,
|
return _matrix_relative(
|
||||||
ma20,
|
amount,
|
||||||
out=out,
|
valid_rolling_mean(previous, np.isfinite(previous), 5),
|
||||||
where=close_valid & np.isfinite(ma20) & (ma20 != 0),
|
|
||||||
)
|
)
|
||||||
out -= np.float32(1.0)
|
|
||||||
return out
|
|
||||||
if name.startswith("ma") and name[2:].isdigit():
|
|
||||||
return valid_rolling_mean(market.close, close_valid, int(name[2:]))
|
|
||||||
if name == "boll_upper" or name == "boll_lower":
|
if name == "boll_upper" or name == "boll_lower":
|
||||||
middle = valid_rolling_mean(market.close, close_valid, 20)
|
middle = valid_rolling_mean(market.close, close_valid, 20)
|
||||||
deviation = valid_rolling_std(market.close, close_valid, 20, ddof=1)
|
deviation = valid_rolling_std(market.close, close_valid, 20, ddof=1)
|
||||||
offset = np.float32(2.0) * deviation
|
offset = np.float32(2.0) * deviation
|
||||||
return middle + offset if name == "boll_upper" else middle - offset
|
return middle + offset if name == "boll_upper" else middle - offset
|
||||||
|
if name == "boll_position":
|
||||||
|
return _matrix_ratio(
|
||||||
|
market.close - matrix_feature(market, "boll_lower"),
|
||||||
|
matrix_feature(market, "boll_upper") - matrix_feature(market, "boll_lower"),
|
||||||
|
)
|
||||||
|
if name == "boll_width":
|
||||||
|
return _matrix_ratio(
|
||||||
|
matrix_feature(market, "boll_upper") - matrix_feature(market, "boll_lower"),
|
||||||
|
matrix_feature(market, "ma20"),
|
||||||
|
)
|
||||||
if name == "high_60d":
|
if name == "high_60d":
|
||||||
return valid_rolling_max(market.close, close_valid, 60)
|
return valid_rolling_max(market.close, close_valid, 60)
|
||||||
if name == "low_60d":
|
if name == "low_60d":
|
||||||
@@ -3775,6 +3816,29 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
|||||||
20,
|
20,
|
||||||
ddof=1,
|
ddof=1,
|
||||||
) * np.float32(252 ** 0.5)
|
) * np.float32(252 ** 0.5)
|
||||||
|
if name in {"kdj_k", "kdj_d", "kdj_j"}:
|
||||||
|
low_valid = close_valid & np.isfinite(market.low)
|
||||||
|
high_valid = close_valid & np.isfinite(market.high)
|
||||||
|
low_9 = valid_rolling_min(market.low, low_valid, 9)
|
||||||
|
high_9 = valid_rolling_max(market.high, high_valid, 9)
|
||||||
|
rsv = _matrix_ratio(market.close - low_9, high_9 - low_9) * np.float32(100.0)
|
||||||
|
k = valid_ewm_adjust_false(rsv, np.isfinite(rsv), alpha=1.0 / 3.0)
|
||||||
|
if name == "kdj_k":
|
||||||
|
return k
|
||||||
|
d = valid_ewm_adjust_false(k, np.isfinite(k), alpha=1.0 / 3.0)
|
||||||
|
if name == "kdj_d":
|
||||||
|
return d
|
||||||
|
return (np.float32(3.0) * k - np.float32(2.0) * d).astype(np.float32, copy=False)
|
||||||
|
if name in {"atr_14", "atr_pct"}:
|
||||||
|
previous = valid_shift(market.close, 1, close_valid)
|
||||||
|
true_range = np.fmax.reduce([
|
||||||
|
market.high - market.low,
|
||||||
|
np.abs(market.high - previous),
|
||||||
|
np.abs(market.low - previous),
|
||||||
|
]).astype(np.float32, copy=False)
|
||||||
|
true_range[~close_valid] = np.nan
|
||||||
|
atr = valid_ewm_adjust_false(true_range, np.isfinite(true_range), alpha=1.0 / 14.0)
|
||||||
|
return atr if name == "atr_14" else _matrix_ratio(atr, market.close)
|
||||||
if name.startswith("rsi_") and name[4:].isdigit():
|
if name.startswith("rsi_") and name[4:].isdigit():
|
||||||
window = int(name[4:])
|
window = int(name[4:])
|
||||||
delta = market.close - valid_shift(market.close, 1, close_valid)
|
delta = market.close - valid_shift(market.close, 1, close_valid)
|
||||||
@@ -3798,9 +3862,43 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
|||||||
np.divide(average_gain, denominator, out=out, where=np.isfinite(denominator))
|
np.divide(average_gain, denominator, out=out, where=np.isfinite(denominator))
|
||||||
out = np.float32(100.0) - np.float32(100.0) / (np.float32(1.0) + out)
|
out = np.float32(100.0) - np.float32(100.0) / (np.float32(1.0) + out)
|
||||||
return out
|
return out
|
||||||
|
if name == "gap_return":
|
||||||
|
return _matrix_relative(market.open, valid_shift(market.close, 1, close_valid))
|
||||||
|
if name == "intraday_return":
|
||||||
|
return _matrix_relative(market.close, market.open)
|
||||||
|
if name == "close_position":
|
||||||
|
return _matrix_ratio(market.close - market.low, market.high - market.low)
|
||||||
|
if name == "distance_to_high_60d":
|
||||||
|
return _matrix_relative(market.close, matrix_feature(market, "high_60d"))
|
||||||
|
if name == "distance_from_low_60d":
|
||||||
|
return _matrix_relative(market.close, matrix_feature(market, "low_60d"))
|
||||||
raise ValueError(f"unsupported matrix feature: {name}")
|
raise ValueError(f"unsupported matrix feature: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_ema(values: np.ndarray, valid: np.ndarray, period: int) -> np.ndarray:
|
||||||
|
return valid_ewm_adjust_false(values, valid, alpha=2.0 / (period + 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_macd(values: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
dif = _matrix_ema(values, valid, 12) - _matrix_ema(values, valid, 26)
|
||||||
|
dif = dif.astype(np.float32, copy=False)
|
||||||
|
dea = valid_ewm_adjust_false(dif, np.isfinite(dif), alpha=2.0 / 10.0)
|
||||||
|
return dif, dea
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_ratio(numerator: np.ndarray, denominator: np.ndarray) -> np.ndarray:
|
||||||
|
out = np.full(numerator.shape, np.nan, dtype=np.float32)
|
||||||
|
valid = np.isfinite(numerator) & np.isfinite(denominator) & (denominator != 0)
|
||||||
|
np.divide(numerator, denominator, out=out, where=valid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_relative(numerator: np.ndarray, denominator: np.ndarray) -> np.ndarray:
|
||||||
|
out = _matrix_ratio(numerator, denominator)
|
||||||
|
out[np.isfinite(out)] -= np.float32(1.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def apply_time_masks(
|
def apply_time_masks(
|
||||||
signals: SignalMatrix,
|
signals: SignalMatrix,
|
||||||
entry_time_mask: np.ndarray,
|
entry_time_mask: np.ndarray,
|
||||||
|
|||||||
@@ -42,7 +42,15 @@ from app.indicators.pipeline import (
|
|||||||
get_signal_dependencies,
|
get_signal_dependencies,
|
||||||
)
|
)
|
||||||
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
|
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
|
||||||
from app.strategy.scoring import scoring_dependencies, scoring_value_expr
|
from app.strategy.scoring import (
|
||||||
|
SCORING_DIRECTION_LOW,
|
||||||
|
effective_scoring,
|
||||||
|
effective_scoring_directions,
|
||||||
|
materialize_scoring_columns,
|
||||||
|
scoring_dependencies,
|
||||||
|
scoring_value_expr,
|
||||||
|
scoring_warmup_bars,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -134,8 +142,7 @@ class StrategyDependencyResolver:
|
|||||||
}
|
}
|
||||||
required_signals.update({"signal_limit_up", "signal_limit_down"})
|
required_signals.update({"signal_limit_up", "signal_limit_down"})
|
||||||
|
|
||||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||||
scoring.update(overrides.get("scoring") or {})
|
|
||||||
required_features.update(scoring_dependencies(scoring))
|
required_features.update(scoring_dependencies(scoring))
|
||||||
order_by = strategy.meta.get("order_by")
|
order_by = strategy.meta.get("order_by")
|
||||||
if order_by and order_by != "score":
|
if order_by and order_by != "score":
|
||||||
@@ -187,7 +194,7 @@ class StrategyDependencyResolver:
|
|||||||
plan = FeaturePlan(
|
plan = FeaturePlan(
|
||||||
required_features=frozenset(required_features),
|
required_features=frozenset(required_features),
|
||||||
required_signals=frozenset(required_signals),
|
required_signals=frozenset(required_signals),
|
||||||
warmup_bars=max(60, int(strategy.lookback_days or 1)),
|
warmup_bars=max(60, int(strategy.lookback_days or 1), scoring_warmup_bars(scoring)),
|
||||||
)
|
)
|
||||||
return ResolvedFeaturePlan(
|
return ResolvedFeaturePlan(
|
||||||
base_columns=base_columns,
|
base_columns=base_columns,
|
||||||
@@ -218,8 +225,7 @@ class StrategyDependencyResolver:
|
|||||||
required_features = set(strategy.required_features)
|
required_features = set(strategy.required_features)
|
||||||
required_features.update(strategy.matrix_strategy.required_fields())
|
required_features.update(strategy.matrix_strategy.required_fields())
|
||||||
required_features.update(_basic_filter_dependencies(basic_filter))
|
required_features.update(_basic_filter_dependencies(basic_filter))
|
||||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||||
scoring.update(overrides.get("scoring") or {})
|
|
||||||
required_features.update(scoring_dependencies(scoring))
|
required_features.update(scoring_dependencies(scoring))
|
||||||
order_by = strategy.meta.get("order_by")
|
order_by = strategy.meta.get("order_by")
|
||||||
if order_by and order_by != "score":
|
if order_by and order_by != "score":
|
||||||
@@ -229,7 +235,11 @@ class StrategyDependencyResolver:
|
|||||||
base_columns = frozenset(set(base_columns) | set(_LIMIT_BASE_COLUMNS))
|
base_columns = frozenset(set(base_columns) | set(_LIMIT_BASE_COLUMNS))
|
||||||
instrument_columns = frozenset(required_features & set(_INSTRUMENT_COLUMNS))
|
instrument_columns = frozenset(required_features & set(_INSTRUMENT_COLUMNS))
|
||||||
instrument_columns = frozenset(set(instrument_columns) | {"name"})
|
instrument_columns = frozenset(set(instrument_columns) | {"name"})
|
||||||
warmup_bars = max(60, int(strategy.matrix_strategy.required_warmup_bars(params)))
|
warmup_bars = max(
|
||||||
|
60,
|
||||||
|
int(strategy.matrix_strategy.required_warmup_bars(params)),
|
||||||
|
scoring_warmup_bars(scoring),
|
||||||
|
)
|
||||||
matrix_columns = set(base_columns) | set(instrument_columns) | {
|
matrix_columns = set(base_columns) | set(instrument_columns) | {
|
||||||
"signal_limit_up",
|
"signal_limit_up",
|
||||||
"signal_limit_down",
|
"signal_limit_down",
|
||||||
@@ -662,12 +672,11 @@ class StrategyBacktestService:
|
|||||||
plans.append(child_plan)
|
plans.append(child_plan)
|
||||||
# pipeline 用 composite 统一的 basic_filter; scoring 用子策略自己的
|
# pipeline 用 composite 统一的 basic_filter; scoring 用子策略自己的
|
||||||
# (默认 + 用户 override), 因为子策略内部排序影响合并器的排名融合。
|
# (默认 + 用户 override), 因为子策略内部排序影响合并器的排名融合。
|
||||||
child_scoring = dict(child_def.meta.get("scoring", {}) or {})
|
child_scoring = effective_scoring(child_def.meta.get("scoring"), child_override)
|
||||||
if isinstance(child_override.get("scoring"), dict):
|
|
||||||
child_scoring.update(child_override["scoring"])
|
|
||||||
child_pipeline_cfg = MatrixPipelineConfig(
|
child_pipeline_cfg = MatrixPipelineConfig(
|
||||||
basic_filter=basic_filter,
|
basic_filter=basic_filter,
|
||||||
scoring=child_scoring,
|
scoring=child_scoring,
|
||||||
|
scoring_directions=effective_scoring_directions(child_override),
|
||||||
order_by=child_def.meta.get("order_by"),
|
order_by=child_def.meta.get("order_by"),
|
||||||
descending=bool(child_def.meta.get("descending", True)),
|
descending=bool(child_def.meta.get("descending", True)),
|
||||||
protect_strategy_cache=False,
|
protect_strategy_cache=False,
|
||||||
@@ -1280,12 +1289,12 @@ class StrategyBacktestService:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
scoring = dict(s.meta.get("scoring", {}) or {})
|
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||||
scoring.update(overrides.get("scoring") or {})
|
|
||||||
try:
|
try:
|
||||||
pipeline_config = MatrixPipelineConfig(
|
pipeline_config = MatrixPipelineConfig(
|
||||||
basic_filter=basic_filter,
|
basic_filter=basic_filter,
|
||||||
scoring=scoring,
|
scoring=scoring,
|
||||||
|
scoring_directions=effective_scoring_directions(overrides),
|
||||||
order_by=s.meta.get("order_by"),
|
order_by=s.meta.get("order_by"),
|
||||||
descending=bool(s.meta.get("descending", True)),
|
descending=bool(s.meta.get("descending", True)),
|
||||||
protect_strategy_cache=prepared is not None,
|
protect_strategy_cache=prepared is not None,
|
||||||
@@ -2042,12 +2051,11 @@ class StrategyBacktestService:
|
|||||||
overrides: dict | None,
|
overrides: dict | None,
|
||||||
universe_mask: pl.Series | None = None,
|
universe_mask: pl.Series | None = None,
|
||||||
) -> pl.DataFrame:
|
) -> pl.DataFrame:
|
||||||
scoring = s.meta.get("scoring", {})
|
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||||
scoring_overrides = (overrides or {}).get("scoring")
|
directions = effective_scoring_directions(overrides)
|
||||||
if scoring_overrides:
|
|
||||||
scoring = {**scoring, **scoring_overrides}
|
|
||||||
|
|
||||||
work = panel
|
work = materialize_scoring_columns(panel, scoring.keys())
|
||||||
|
temporary_scoring_columns = [name for name in scoring if name not in panel.columns and name in work.columns]
|
||||||
has_universe = universe_mask is not None and len(universe_mask) == len(panel)
|
has_universe = universe_mask is not None and len(universe_mask) == len(panel)
|
||||||
if has_universe:
|
if has_universe:
|
||||||
work = work.with_columns(universe_mask.rename("_score_universe"))
|
work = work.with_columns(universe_mask.rename("_score_universe"))
|
||||||
@@ -2058,18 +2066,23 @@ class StrategyBacktestService:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def _finish(df: pl.DataFrame) -> pl.DataFrame:
|
def _finish(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
return df.drop("_score_universe") if "_score_universe" in df.columns else df
|
temporary = [
|
||||||
|
name
|
||||||
|
for name in ["_score_universe", *temporary_scoring_columns]
|
||||||
|
if name in df.columns
|
||||||
|
]
|
||||||
|
return df.drop(temporary) if temporary else df
|
||||||
|
|
||||||
if scoring:
|
if scoring:
|
||||||
executable = [
|
executable = [
|
||||||
(value, weight)
|
(str(col), value, weight)
|
||||||
for col, weight in scoring.items()
|
for col, weight in scoring.items()
|
||||||
if weight and (value := scoring_value_expr(work.columns, str(col))) is not None
|
if weight and (value := scoring_value_expr(work.columns, str(col))) is not None
|
||||||
]
|
]
|
||||||
total_weight = sum(weight for _, weight in executable)
|
total_weight = sum(weight for _, _, weight in executable)
|
||||||
if total_weight > 0:
|
if total_weight > 0:
|
||||||
score_parts: list[pl.Expr] = []
|
score_parts: list[pl.Expr] = []
|
||||||
for score_value, weight in executable:
|
for name, score_value, weight in executable:
|
||||||
w = weight / total_weight
|
w = weight / total_weight
|
||||||
value = _value_in_universe(score_value)
|
value = _value_in_universe(score_value)
|
||||||
col_min = value.min().over("date")
|
col_min = value.min().over("date")
|
||||||
@@ -2078,6 +2091,8 @@ class StrategyBacktestService:
|
|||||||
normalized = pl.when(col_range > 0).then(
|
normalized = pl.when(col_range > 0).then(
|
||||||
(score_value - col_min) / col_range
|
(score_value - col_min) / col_range
|
||||||
).otherwise(pl.lit(0.5))
|
).otherwise(pl.lit(0.5))
|
||||||
|
if directions.get(name) == SCORING_DIRECTION_LOW:
|
||||||
|
normalized = 1.0 - normalized
|
||||||
if has_universe:
|
if has_universe:
|
||||||
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
|
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
|
||||||
score_parts.append(normalized * w)
|
score_parts.append(normalized * w)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 后端二次开发目录
|
||||||
|
|
||||||
|
在本目录新增普通 `.py` 模块即可由应用自动发现,无需修改 `app/main.py`。以下划线开头的模块不会加载。
|
||||||
|
|
||||||
|
以 [`_template.py.example`](_template.py.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与运行。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""In-repository backend secondary-development modules.
|
||||||
|
|
||||||
|
Copy ``_template.py.example`` to a non-underscore ``.py`` module to enable it.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.extensions import (
|
||||||
|
BACKEND_EXTENSION_API_VERSION,
|
||||||
|
BackendExtensionRegistrar,
|
||||||
|
ExtensionContext,
|
||||||
|
NotificationFormatContext,
|
||||||
|
NotificationFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyNotificationFormatter(NotificationFormatter):
|
||||||
|
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
|
||||||
|
return f"[公司规则] {event.get('message', '')}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
EXTENSION_ID = "company.example"
|
||||||
|
EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def setup(registrar: BackendExtensionRegistrar) -> None:
|
||||||
|
registrar.register_notification_formatter(
|
||||||
|
"company.notification",
|
||||||
|
CompanyNotificationFormatter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/custom/example", tags=["custom-example"])
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def status() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
registrar.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
def startup(context: ExtensionContext) -> None:
|
||||||
|
# Core repository and data directory are available here. Keep this hook fast.
|
||||||
|
_ = context
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from app.extensions.contracts import (
|
||||||
|
BACKEND_EXTENSION_API_VERSION,
|
||||||
|
ExtensionContext,
|
||||||
|
NotificationFormatContext,
|
||||||
|
NotificationFormatter,
|
||||||
|
)
|
||||||
|
from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BACKEND_EXTENSION_API_VERSION",
|
||||||
|
"BackendExtensionRegistrar",
|
||||||
|
"BackendExtensionRegistry",
|
||||||
|
"ExtensionContext",
|
||||||
|
"NotificationFormatContext",
|
||||||
|
"NotificationFormatter",
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Stable, small-grained contracts for in-repository secondary development."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
BACKEND_EXTENSION_API_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryAccess(Protocol):
|
||||||
|
"""Read-oriented repository surface exposed to backend extensions."""
|
||||||
|
|
||||||
|
def get_name_map(self, symbols: list[str] | None = None) -> dict[str, str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExtensionContext:
|
||||||
|
api_version: int
|
||||||
|
data_dir: Path
|
||||||
|
repository: RepositoryAccess
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NotificationFormatContext:
|
||||||
|
api_version: int
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationFormatter(ABC):
|
||||||
|
"""Customize notification copy without changing the event schema or semantics."""
|
||||||
|
|
||||||
|
api_version = BACKEND_EXTENSION_API_VERSION
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def format_message(
|
||||||
|
self,
|
||||||
|
event: dict[str, Any],
|
||||||
|
context: NotificationFormatContext,
|
||||||
|
) -> str:
|
||||||
|
"""Return notification copy. The input event must not be mutated."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultNotificationFormatter(NotificationFormatter):
|
||||||
|
def format_message(
|
||||||
|
self,
|
||||||
|
event: dict[str, Any],
|
||||||
|
context: NotificationFormatContext,
|
||||||
|
) -> str:
|
||||||
|
del context
|
||||||
|
return str(event.get("message") or "")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Discover in-repository backend customizations without touching user data."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
import pkgutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from app.extensions.contracts import BACKEND_EXTENSION_API_VERSION, ExtensionContext
|
||||||
|
from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackendExtensionLoadError:
|
||||||
|
module: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_module_names() -> list[str]:
|
||||||
|
try:
|
||||||
|
package = importlib.import_module("app.custom")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
item.name
|
||||||
|
for item in pkgutil.iter_modules(package.__path__, f"{package.__name__}.")
|
||||||
|
if not item.name.rsplit(".", 1)[-1].startswith("_")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_backend_extensions(
|
||||||
|
app: FastAPI,
|
||||||
|
) -> tuple[BackendExtensionRegistry, tuple[BackendExtensionLoadError, ...]]:
|
||||||
|
"""Import custom modules and register validated routes and policies."""
|
||||||
|
registry = BackendExtensionRegistry()
|
||||||
|
errors: list[BackendExtensionLoadError] = []
|
||||||
|
|
||||||
|
for module_name in _custom_module_names():
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
extension_id = getattr(module, "EXTENSION_ID", None)
|
||||||
|
api_version = getattr(module, "EXTENSION_API_VERSION", None)
|
||||||
|
if not isinstance(extension_id, str):
|
||||||
|
raise ValueError("backend extension module must define EXTENSION_ID")
|
||||||
|
registrar = BackendExtensionRegistrar(extension_id, api_version=api_version)
|
||||||
|
setup = getattr(module, "setup", None)
|
||||||
|
if not callable(setup):
|
||||||
|
raise ValueError("backend extension module must define setup(registrar)")
|
||||||
|
setup(registrar)
|
||||||
|
_validate_router_conflicts(app, registrar)
|
||||||
|
registry.register(registrar)
|
||||||
|
for router in registrar.routers:
|
||||||
|
app.include_router(router)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("backend extension load failed %s: %s", module_name, exc)
|
||||||
|
errors.append(BackendExtensionLoadError(module_name, str(exc)))
|
||||||
|
|
||||||
|
registry.freeze()
|
||||||
|
return registry, tuple(errors)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_router_conflicts(app: FastAPI, registrar: BackendExtensionRegistrar) -> None:
|
||||||
|
existing = {
|
||||||
|
(route.path, method)
|
||||||
|
for route in app.routes
|
||||||
|
if isinstance(route, APIRoute)
|
||||||
|
for method in route.methods
|
||||||
|
}
|
||||||
|
staged: set[tuple[str, str]] = set()
|
||||||
|
for router in registrar.routers:
|
||||||
|
for route in router.routes:
|
||||||
|
if not isinstance(route, APIRoute):
|
||||||
|
continue
|
||||||
|
for method in route.methods:
|
||||||
|
key = (route.path, method)
|
||||||
|
if key in existing or key in staged:
|
||||||
|
raise ValueError(
|
||||||
|
f"extension {registrar.extension_id!r} route conflicts: "
|
||||||
|
f"{method} {route.path}"
|
||||||
|
)
|
||||||
|
staged.add(key)
|
||||||
|
|
||||||
|
|
||||||
|
def start_backend_extensions(
|
||||||
|
context: ExtensionContext,
|
||||||
|
registry: BackendExtensionRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Run optional post-core startup hooks after the stable context is available."""
|
||||||
|
for module_name in _custom_module_names():
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
if getattr(module, "EXTENSION_ID", None) not in registry.extension_ids():
|
||||||
|
continue
|
||||||
|
startup = getattr(module, "startup", None)
|
||||||
|
if callable(startup):
|
||||||
|
startup(context)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("backend extension startup failed %s: %s", module_name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def current_extension_context(*, data_dir, repository) -> ExtensionContext:
|
||||||
|
return ExtensionContext(
|
||||||
|
api_version=BACKEND_EXTENSION_API_VERSION,
|
||||||
|
data_dir=data_dir,
|
||||||
|
repository=repository,
|
||||||
|
)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Backend extension registry with version checks and deterministic freezing."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.extensions.contracts import (
|
||||||
|
BACKEND_EXTENSION_API_VERSION,
|
||||||
|
DefaultNotificationFormatter,
|
||||||
|
NotificationFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ID_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RegisteredImplementation(Generic[T]):
|
||||||
|
extension_id: str
|
||||||
|
implementation_id: str
|
||||||
|
implementation: T
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
||||||
|
class BackendExtensionRegistrar:
|
||||||
|
"""Staging area: a failed setup is discarded without partial registration."""
|
||||||
|
|
||||||
|
def __init__(self, extension_id: str, *, api_version: int) -> None:
|
||||||
|
self.extension_id = extension_id
|
||||||
|
self.api_version = api_version
|
||||||
|
self.routers: list[APIRouter] = []
|
||||||
|
self.notification_formatters: list[tuple[str, NotificationFormatter, int]] = []
|
||||||
|
|
||||||
|
def include_router(self, router: APIRouter) -> None:
|
||||||
|
if not isinstance(router, APIRouter):
|
||||||
|
raise TypeError("router must be fastapi.APIRouter")
|
||||||
|
self.routers.append(router)
|
||||||
|
|
||||||
|
def register_notification_formatter(
|
||||||
|
self,
|
||||||
|
implementation_id: str,
|
||||||
|
formatter: NotificationFormatter,
|
||||||
|
*,
|
||||||
|
order: int = 100,
|
||||||
|
) -> None:
|
||||||
|
self.notification_formatters.append((implementation_id, formatter, order))
|
||||||
|
|
||||||
|
|
||||||
|
class BackendExtensionRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._extension_ids: set[str] = set()
|
||||||
|
self._notification_formatters: list[RegisteredImplementation[NotificationFormatter]] = []
|
||||||
|
self._frozen = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frozen(self) -> bool:
|
||||||
|
return self._frozen
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_customizations(self) -> bool:
|
||||||
|
return bool(self._extension_ids)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_notification_formatters(self) -> bool:
|
||||||
|
return bool(self._notification_formatters)
|
||||||
|
|
||||||
|
def extension_ids(self) -> frozenset[str]:
|
||||||
|
return frozenset(self._extension_ids)
|
||||||
|
|
||||||
|
def register(self, registrar: BackendExtensionRegistrar) -> None:
|
||||||
|
"""Validate a staged extension fully before mutating the registry."""
|
||||||
|
self._ensure_mutable()
|
||||||
|
extension_id = registrar.extension_id
|
||||||
|
self._validate_id(extension_id, "extension_id")
|
||||||
|
if registrar.api_version != BACKEND_EXTENSION_API_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"extension {extension_id!r} requires backend API v{registrar.api_version}; "
|
||||||
|
f"current is v{BACKEND_EXTENSION_API_VERSION}"
|
||||||
|
)
|
||||||
|
if extension_id in self._extension_ids:
|
||||||
|
raise ValueError(f"duplicate extension id: {extension_id}")
|
||||||
|
|
||||||
|
known_ids = {item.implementation_id for item in self._notification_formatters}
|
||||||
|
staged_ids: set[str] = set()
|
||||||
|
staged: list[RegisteredImplementation[NotificationFormatter]] = []
|
||||||
|
for implementation_id, formatter, order in registrar.notification_formatters:
|
||||||
|
self._validate_id(implementation_id, "implementation_id")
|
||||||
|
if not isinstance(formatter, NotificationFormatter):
|
||||||
|
raise TypeError("formatter must inherit NotificationFormatter")
|
||||||
|
if formatter.api_version != BACKEND_EXTENSION_API_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"formatter {implementation_id!r} requires API v{formatter.api_version}; "
|
||||||
|
f"current is v{BACKEND_EXTENSION_API_VERSION}"
|
||||||
|
)
|
||||||
|
if implementation_id in known_ids or implementation_id in staged_ids:
|
||||||
|
raise ValueError(f"duplicate notification formatter id: {implementation_id}")
|
||||||
|
staged_ids.add(implementation_id)
|
||||||
|
staged.append(
|
||||||
|
RegisteredImplementation(extension_id, implementation_id, formatter, order)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._extension_ids.add(extension_id)
|
||||||
|
self._notification_formatters.extend(staged)
|
||||||
|
|
||||||
|
def freeze(self) -> None:
|
||||||
|
self._notification_formatters.sort(
|
||||||
|
key=lambda item: (item.order, item.implementation_id)
|
||||||
|
)
|
||||||
|
self._frozen = True
|
||||||
|
|
||||||
|
def notification_formatters(
|
||||||
|
self,
|
||||||
|
) -> tuple[RegisteredImplementation[NotificationFormatter], ...]:
|
||||||
|
if not self._frozen:
|
||||||
|
raise RuntimeError("backend extension registry must be frozen before use")
|
||||||
|
if not self._notification_formatters:
|
||||||
|
return (
|
||||||
|
RegisteredImplementation(
|
||||||
|
"core", "core.notification", DefaultNotificationFormatter(), 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return tuple(self._notification_formatters)
|
||||||
|
|
||||||
|
def _ensure_mutable(self) -> None:
|
||||||
|
if self._frozen:
|
||||||
|
raise RuntimeError("backend extension registry is frozen")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_id(value: str, label: str) -> None:
|
||||||
|
if not isinstance(value, str) or not _ID_RE.fullmatch(value):
|
||||||
|
raise ValueError(f"invalid {label}: {value!r}")
|
||||||
+19
-2
@@ -15,6 +15,11 @@ from app import __version__
|
|||||||
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, regime, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, regime, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||||
from app.api.routes import router as core_router
|
from app.api.routes import router as core_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.extensions.loader import (
|
||||||
|
configure_backend_extensions,
|
||||||
|
current_extension_context,
|
||||||
|
start_backend_extensions,
|
||||||
|
)
|
||||||
from app.jobs import daily_pipeline
|
from app.jobs import daily_pipeline
|
||||||
from app.services.quote_service import QuoteService
|
from app.services.quote_service import QuoteService
|
||||||
from app.tickflow import client as tf_client
|
from app.tickflow import client as tf_client
|
||||||
@@ -31,7 +36,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info(
|
logger.info(
|
||||||
"TickFlow Stock Panel v%s starting (mode=%s)",
|
"Tick Stock Panel v%s starting (mode=%s)",
|
||||||
__version__, tf_client.current_mode(),
|
__version__, tf_client.current_mode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,6 +251,13 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.monitor_engine = monitor_engine
|
app.state.monitor_engine = monitor_engine
|
||||||
app.state.sector_monitor_service = sector_monitor_service
|
app.state.sector_monitor_service = sector_monitor_service
|
||||||
|
|
||||||
|
# 源码内二次开发启动钩子: 仅暴露稳定只读上下文, 单个扩展失败不影响核心启动。
|
||||||
|
extension_registry = app.state.extension_registry
|
||||||
|
start_backend_extensions(
|
||||||
|
current_extension_context(data_dir=store.data_dir, repository=repo),
|
||||||
|
extension_registry,
|
||||||
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
if app.state.scheduler:
|
if app.state.scheduler:
|
||||||
@@ -269,7 +281,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="TickFlow Stock Panel",
|
title="Tick Stock Panel",
|
||||||
version=__version__,
|
version=__version__,
|
||||||
description="A 股选股 + 回测面板 — TickFlow 适配",
|
description="A 股选股 + 回测面板 — TickFlow 适配",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -357,6 +369,11 @@ app.include_router(monitor_rules.router)
|
|||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
app.include_router(rps.router)
|
app.include_router(rps.router)
|
||||||
|
|
||||||
|
# 二次开发路由与小粒度策略在所有核心路由后注册, 禁止覆盖核心路径。
|
||||||
|
extension_registry, extension_load_errors = configure_backend_extensions(app)
|
||||||
|
app.state.extension_registry = extension_registry
|
||||||
|
app.state.extension_load_errors = extension_load_errors
|
||||||
|
|
||||||
|
|
||||||
# 能力门控异常 → 403(而非默认 500)
|
# 能力门控异常 → 403(而非默认 500)
|
||||||
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
|
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
|
||||||
|
|||||||
@@ -642,7 +642,7 @@ def _make_writable_and_retry(
|
|||||||
|
|
||||||
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
|
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
|
||||||
parts = [
|
parts = [
|
||||||
"You are TickFlow Stock Panel's local AI provider.",
|
"You are Tick Stock Panel's local AI provider.",
|
||||||
"This is a text-generation task. The working directory is intentionally empty.",
|
"This is a text-generation task. The working directory is intentionally empty.",
|
||||||
"Use only the user-provided prompt content below; do not inspect or modify local files.",
|
"Use only the user-provided prompt content below; do not inspect or modify local files.",
|
||||||
"Return only the final requested content; do not include execution logs.",
|
"Return only the final requested content; do not include execution logs.",
|
||||||
|
|||||||
@@ -1149,6 +1149,7 @@ class QuoteService:
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("指数监控评估失败 (不影响股票/ETF 告警): %s", e)
|
logger.warning("指数监控评估失败 (不影响股票/ETF 告警): %s", e)
|
||||||
if rule_events:
|
if rule_events:
|
||||||
|
rule_events = self._format_extension_notifications(rule_events)
|
||||||
# 落盘到 alerts.jsonl
|
# 落盘到 alerts.jsonl
|
||||||
try:
|
try:
|
||||||
from app.services import alert_store
|
from app.services import alert_store
|
||||||
@@ -1207,6 +1208,42 @@ class QuoteService:
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("监控评估失败: %s", e)
|
logger.warning("监控评估失败: %s", e)
|
||||||
|
|
||||||
|
def _format_extension_notifications(self, events: list[dict]) -> list[dict]:
|
||||||
|
"""Apply optional copy formatters after evaluation and before every output channel."""
|
||||||
|
registry = (
|
||||||
|
getattr(self._app_state, "extension_registry", None)
|
||||||
|
if self._app_state is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if registry is None or not registry.has_notification_formatters:
|
||||||
|
return events
|
||||||
|
|
||||||
|
from app.extensions.contracts import (
|
||||||
|
BACKEND_EXTENSION_API_VERSION,
|
||||||
|
NotificationFormatContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted_events: list[dict] = []
|
||||||
|
for event in events:
|
||||||
|
formatted = dict(event)
|
||||||
|
context = NotificationFormatContext(
|
||||||
|
api_version=BACKEND_EXTENSION_API_VERSION,
|
||||||
|
)
|
||||||
|
for registered in registry.notification_formatters():
|
||||||
|
try:
|
||||||
|
message = registered.implementation.format_message(dict(formatted), context)
|
||||||
|
if not isinstance(message, str):
|
||||||
|
raise TypeError("notification formatter must return str")
|
||||||
|
formatted["message"] = message
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"notification formatter failed %s: %s",
|
||||||
|
registered.implementation_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
formatted_events.append(formatted)
|
||||||
|
return formatted_events
|
||||||
|
|
||||||
def _enrich_alerts_ext(self, alerts: list[dict]) -> None:
|
def _enrich_alerts_ext(self, alerts: list[dict]) -> None:
|
||||||
"""就地给告警事件按 symbol 追加行业/概念 ext 字段。
|
"""就地给告警事件按 symbol 追加行业/概念 ext 字段。
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ ENTRY_SIGNALS = ["signal_boll_breakout_upper"]
|
|||||||
EXIT_SIGNALS = ["signal_boll_breakdown_lower"]
|
EXIT_SIGNALS = ["signal_boll_breakdown_lower"]
|
||||||
STOP_LOSS = -0.06
|
STOP_LOSS = -0.06
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class BollBreakoutMatrixStrategy:
|
class BollBreakoutMatrixStrategy:
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.06
|
STOP_LOSS = -0.06
|
||||||
MAX_HOLD_DAYS = 10
|
MAX_HOLD_DAYS = 10
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class BrokenBoardRecoveryMatrixStrategy:
|
class BrokenBoardRecoveryMatrixStrategy:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20", "signal_ma_golden_20_60"]
|
|||||||
EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.06
|
STOP_LOSS = -0.06
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class BullishAlignmentMatrixStrategy:
|
class BullishAlignmentMatrixStrategy:
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 5
|
MAX_HOLD_DAYS = 5
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class ConsecutiveLimitUpsMatrixStrategy:
|
class ConsecutiveLimitUpsMatrixStrategy:
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ ENTRY_SIGNALS = ["signal_volume_surge"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 10
|
MAX_HOLD_DAYS = 10
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class HighTurnoverSurgeMatrixStrategy:
|
class HighTurnoverSurgeMatrixStrategy:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 5
|
MAX_HOLD_DAYS = 5
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class LimitUpMomentumMatrixStrategy:
|
class LimitUpMomentumMatrixStrategy:
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 30
|
MAX_HOLD_DAYS = 30
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class LowVolatilityLeaderMatrixStrategy:
|
class LowVolatilityLeaderMatrixStrategy:
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
|||||||
EXIT_SIGNALS = ["signal_ma_dead_5_20"]
|
EXIT_SIGNALS = ["signal_ma_dead_5_20"]
|
||||||
STOP_LOSS = -0.06
|
STOP_LOSS = -0.06
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class MAGoldenCrossMatrixStrategy:
|
class MAGoldenCrossMatrixStrategy:
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ EXIT_SIGNALS = ["signal_macd_dead"]
|
|||||||
EXECUTION_BACKEND = "matrix_native"
|
EXECUTION_BACKEND = "matrix_native"
|
||||||
STOP_LOSS = -0.07
|
STOP_LOSS = -0.07
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class MACDGoldenMatrixStrategy:
|
class MACDGoldenMatrixStrategy:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_n_day_low"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.06
|
STOP_LOSS = -0.06
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class NDayLowReversalMatrixStrategy:
|
class NDayLowReversalMatrixStrategy:
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 5
|
MAX_HOLD_DAYS = 5
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class NearLimitUpMatrixStrategy:
|
class NearLimitUpMatrixStrategy:
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = [
|
|
||||||
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class OversoldBounceMatrixStrategy:
|
class OversoldBounceMatrixStrategy:
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = [
|
|
||||||
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class OversoldReversalMatrixStrategy:
|
class OversoldReversalMatrixStrategy:
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class PullbackMA20BounceMatrixStrategy:
|
class PullbackMA20BounceMatrixStrategy:
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class PullbackToSupportMatrixStrategy:
|
class PullbackToSupportMatrixStrategy:
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 10
|
MAX_HOLD_DAYS = 10
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class StrongOpenMatrixStrategy:
|
class StrongOpenMatrixStrategy:
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ ENTRY_SIGNALS = ["signal_n_day_high"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.08
|
STOP_LOSS = -0.08
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = [
|
|
||||||
{"field": "signal_volume_surge", "message": "放量异动"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TrendBreakoutMatrixStrategy:
|
class TrendBreakoutMatrixStrategy:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.06
|
STOP_LOSS = -0.06
|
||||||
MAX_HOLD_DAYS = 15
|
MAX_HOLD_DAYS = 15
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
class VolumePriceSurgeMatrixStrategy:
|
class VolumePriceSurgeMatrixStrategy:
|
||||||
|
|||||||
@@ -11,15 +11,24 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from app.strategy.scoring import scoring_dependencies, scoring_value_expr
|
from app.strategy.scoring import (
|
||||||
|
SCORING_DIRECTION_LOW,
|
||||||
|
effective_scoring,
|
||||||
|
effective_scoring_directions,
|
||||||
|
materialize_scoring_columns,
|
||||||
|
scoring_dependencies,
|
||||||
|
scoring_value_expr,
|
||||||
|
scoring_warmup_bars,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -181,7 +190,6 @@ class StrategyDef:
|
|||||||
trailing_take_profit_activate: float | None
|
trailing_take_profit_activate: float | None
|
||||||
trailing_take_profit_drawdown: float | None
|
trailing_take_profit_drawdown: float | None
|
||||||
max_hold_days: int | None
|
max_hold_days: int | None
|
||||||
alerts: list[dict]
|
|
||||||
filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None
|
filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None
|
||||||
filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None
|
filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None
|
||||||
lookback_days: int
|
lookback_days: int
|
||||||
@@ -520,7 +528,6 @@ class StrategyEngine:
|
|||||||
trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None),
|
trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None),
|
||||||
trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None),
|
trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None),
|
||||||
max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None),
|
max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None),
|
||||||
alerts=getattr(mod, "ALERTS", []),
|
|
||||||
filter_fn=filter_fn,
|
filter_fn=filter_fn,
|
||||||
filter_history_fn=filter_history_fn,
|
filter_history_fn=filter_history_fn,
|
||||||
required_features=frozenset(meta.get("required_features", []) or [])
|
required_features=frozenset(meta.get("required_features", []) or [])
|
||||||
@@ -655,11 +662,14 @@ class StrategyEngine:
|
|||||||
required = 1
|
required = 1
|
||||||
for strategy_id in strategy_ids:
|
for strategy_id in strategy_ids:
|
||||||
strategy = self.get(strategy_id)
|
strategy = self.get(strategy_id)
|
||||||
|
overrides = overrides_map.get(strategy_id) or {}
|
||||||
|
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||||
|
required = max(required, scoring_warmup_bars(scoring))
|
||||||
if strategy.execution_backend == "matrix_native":
|
if strategy.execution_backend == "matrix_native":
|
||||||
params = self.resolve_params(
|
params = self.resolve_params(
|
||||||
strategy,
|
strategy,
|
||||||
params_map.get(strategy_id),
|
params_map.get(strategy_id),
|
||||||
overrides_map.get(strategy_id),
|
overrides,
|
||||||
)
|
)
|
||||||
required = max(
|
required = max(
|
||||||
required,
|
required,
|
||||||
@@ -714,6 +724,12 @@ class StrategyEngine:
|
|||||||
max_warmup = max(
|
max_warmup = max(
|
||||||
max_warmup,
|
max_warmup,
|
||||||
int(strategy.matrix_strategy.required_warmup_bars(params)) + 1,
|
int(strategy.matrix_strategy.required_warmup_bars(params)) + 1,
|
||||||
|
scoring_warmup_bars(
|
||||||
|
effective_scoring(
|
||||||
|
strategy.meta.get("scoring"),
|
||||||
|
overrides_map.get(strategy_id),
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
field_columns.update(
|
field_columns.update(
|
||||||
self._matrix_field_columns(strategy, overrides_map.get(strategy_id))
|
self._matrix_field_columns(strategy, overrides_map.get(strategy_id))
|
||||||
@@ -832,7 +848,15 @@ class StrategyEngine:
|
|||||||
started_at=t0,
|
started_at=t0,
|
||||||
)
|
)
|
||||||
|
|
||||||
signal_df = context.current if context.current is not None else context.history
|
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||||
|
scoring_directions = effective_scoring_directions(overrides)
|
||||||
|
current, history = self._materialize_scoring_frames(
|
||||||
|
context.current,
|
||||||
|
context.history,
|
||||||
|
scoring,
|
||||||
|
)
|
||||||
|
|
||||||
|
signal_df = current if current is not None else history
|
||||||
if signal_df is None:
|
if signal_df is None:
|
||||||
signal_df = pl.DataFrame()
|
signal_df = pl.DataFrame()
|
||||||
if not signal_df.is_empty() and "date" in signal_df.columns:
|
if not signal_df.is_empty() and "date" in signal_df.columns:
|
||||||
@@ -843,9 +867,9 @@ class StrategyEngine:
|
|||||||
|
|
||||||
# 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。
|
# 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。
|
||||||
if s.filter_history_fn:
|
if s.filter_history_fn:
|
||||||
if context.history is None:
|
if history is None:
|
||||||
raise ValueError(f"strategy {strategy_id} requires history data")
|
raise ValueError(f"strategy {strategy_id} requires history data")
|
||||||
df = context.history
|
df = history
|
||||||
if df.is_empty():
|
if df.is_empty():
|
||||||
return StrategyResult(
|
return StrategyResult(
|
||||||
as_of=as_of,
|
as_of=as_of,
|
||||||
@@ -856,9 +880,9 @@ class StrategyEngine:
|
|||||||
if "date" in df.columns:
|
if "date" in df.columns:
|
||||||
df = df.filter(pl.col("date") == as_of)
|
df = df.filter(pl.col("date") == as_of)
|
||||||
else:
|
else:
|
||||||
if context.current is None:
|
if current is None:
|
||||||
raise ValueError(f"strategy {strategy_id} requires current data")
|
raise ValueError(f"strategy {strategy_id} requires current data")
|
||||||
df = context.current
|
df = current
|
||||||
|
|
||||||
if df.is_empty():
|
if df.is_empty():
|
||||||
return StrategyResult(
|
return StrategyResult(
|
||||||
@@ -887,11 +911,7 @@ class StrategyEngine:
|
|||||||
df = df.filter(expr)
|
df = df.filter(expr)
|
||||||
|
|
||||||
# Stage 3: 评分
|
# Stage 3: 评分
|
||||||
scoring = s.meta.get("scoring", {})
|
df = self._apply_scoring(df, scoring, scoring_directions)
|
||||||
scoring_overrides = overrides.get("scoring")
|
|
||||||
if scoring_overrides:
|
|
||||||
scoring = {**scoring, **scoring_overrides}
|
|
||||||
df = self._apply_scoring(df, scoring)
|
|
||||||
entry_signal_hits = self._collect_signal_hits(df, entry_signals)
|
entry_signal_hits = self._collect_signal_hits(df, entry_signals)
|
||||||
if not entry_signals and (s.filter_history_fn or s.filter_fn):
|
if not entry_signals and (s.filter_history_fn or s.filter_fn):
|
||||||
entry_signal_hits = [
|
entry_signal_hits = [
|
||||||
@@ -1048,8 +1068,7 @@ class StrategyEngine:
|
|||||||
or basic_filter.get(f"{prefix}_max") is not None
|
or basic_filter.get(f"{prefix}_max") is not None
|
||||||
):
|
):
|
||||||
fields.add(field_name)
|
fields.add(field_name)
|
||||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||||
scoring.update((overrides or {}).get("scoring") or {})
|
|
||||||
fields.update(scoring_dependencies(scoring))
|
fields.update(scoring_dependencies(scoring))
|
||||||
order_by = strategy.meta.get("order_by")
|
order_by = strategy.meta.get("order_by")
|
||||||
if order_by and order_by != "score":
|
if order_by and order_by != "score":
|
||||||
@@ -1094,8 +1113,7 @@ class StrategyEngine:
|
|||||||
basic_filter = dict(strategy.basic_filter or {})
|
basic_filter = dict(strategy.basic_filter or {})
|
||||||
if overrides.get("basic_filter"):
|
if overrides.get("basic_filter"):
|
||||||
basic_filter.update(overrides["basic_filter"])
|
basic_filter.update(overrides["basic_filter"])
|
||||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||||
scoring.update(overrides.get("scoring") or {})
|
|
||||||
asset_mask = None
|
asset_mask = None
|
||||||
if pool:
|
if pool:
|
||||||
pool_set = set(pool)
|
pool_set = set(pool)
|
||||||
@@ -1112,6 +1130,7 @@ class StrategyEngine:
|
|||||||
MatrixPipelineConfig(
|
MatrixPipelineConfig(
|
||||||
basic_filter=basic_filter,
|
basic_filter=basic_filter,
|
||||||
scoring=scoring,
|
scoring=scoring,
|
||||||
|
scoring_directions=effective_scoring_directions(overrides),
|
||||||
order_by=strategy.meta.get("order_by"),
|
order_by=strategy.meta.get("order_by"),
|
||||||
descending=bool(strategy.meta.get("descending", True)),
|
descending=bool(strategy.meta.get("descending", True)),
|
||||||
asset_mask=asset_mask,
|
asset_mask=asset_mask,
|
||||||
@@ -1398,28 +1417,34 @@ class StrategyEngine:
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_scoring(df: pl.DataFrame, weights: dict) -> pl.DataFrame:
|
def _apply_scoring(
|
||||||
|
df: pl.DataFrame,
|
||||||
|
weights: dict,
|
||||||
|
directions: Mapping[str, str] | None = None,
|
||||||
|
) -> pl.DataFrame:
|
||||||
"""通用评分: min-max 归一化 → 加权求和 → 0~100 分"""
|
"""通用评分: min-max 归一化 → 加权求和 → 0~100 分"""
|
||||||
if not weights:
|
if not weights:
|
||||||
return df
|
return df
|
||||||
|
|
||||||
executable = [
|
executable = [
|
||||||
(value, weight)
|
(str(col), value, weight)
|
||||||
for col, weight in weights.items()
|
for col, weight in weights.items()
|
||||||
if weight and (value := scoring_value_expr(df.columns, str(col))) is not None
|
if weight and (value := scoring_value_expr(df.columns, str(col))) is not None
|
||||||
]
|
]
|
||||||
total_weight = sum(weight for _, weight in executable)
|
total_weight = sum(weight for _, _, weight in executable)
|
||||||
if total_weight <= 0:
|
if total_weight <= 0:
|
||||||
return df
|
return df
|
||||||
|
|
||||||
score_parts: list[pl.Expr] = []
|
score_parts: list[pl.Expr] = []
|
||||||
for value, weight in executable:
|
for name, value, weight in executable:
|
||||||
w = weight / total_weight
|
w = weight / total_weight
|
||||||
col_min = value.min()
|
col_min = value.min()
|
||||||
col_range = value.max() - col_min
|
col_range = value.max() - col_min
|
||||||
normalized = pl.when(col_range > 0).then(
|
normalized = pl.when(col_range > 0).then(
|
||||||
(value - col_min) / col_range
|
(value - col_min) / col_range
|
||||||
).otherwise(pl.lit(0.5))
|
).otherwise(pl.lit(0.5))
|
||||||
|
if (directions or {}).get(name) == SCORING_DIRECTION_LOW:
|
||||||
|
normalized = 1.0 - normalized
|
||||||
score_parts.append(normalized * w)
|
score_parts.append(normalized * w)
|
||||||
|
|
||||||
if not score_parts:
|
if not score_parts:
|
||||||
@@ -1430,6 +1455,31 @@ class StrategyEngine:
|
|||||||
score_expr = score_expr + part
|
score_expr = score_expr + part
|
||||||
return df.with_columns((score_expr * 100).alias("score"))
|
return df.with_columns((score_expr * 100).alias("score"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _materialize_scoring_frames(
|
||||||
|
current: pl.DataFrame | None,
|
||||||
|
history: pl.DataFrame | None,
|
||||||
|
scoring: Mapping[str, Any],
|
||||||
|
) -> tuple[pl.DataFrame | None, pl.DataFrame | None]:
|
||||||
|
names = [str(name) for name, weight in scoring.items() if weight]
|
||||||
|
if not names:
|
||||||
|
return current, history
|
||||||
|
if history is None or history.is_empty():
|
||||||
|
return (
|
||||||
|
materialize_scoring_columns(current, names) if current is not None else None,
|
||||||
|
history,
|
||||||
|
)
|
||||||
|
|
||||||
|
scored_history = materialize_scoring_columns(history, names)
|
||||||
|
if current is None or current.is_empty():
|
||||||
|
return current, scored_history
|
||||||
|
join_keys = [key for key in ("symbol", "date", "datetime") if key in current.columns and key in scored_history.columns]
|
||||||
|
added = [name for name in names if name not in current.columns and name in scored_history.columns]
|
||||||
|
if not join_keys or not added:
|
||||||
|
return materialize_scoring_columns(current, names), scored_history
|
||||||
|
values = scored_history.select([*join_keys, *added]).unique(subset=join_keys, keep="last")
|
||||||
|
return current.join(values, on=join_keys, how="left"), scored_history
|
||||||
|
|
||||||
|
|
||||||
def _sanitize(rows: list[dict]) -> list[dict]:
|
def _sanitize(rows: list[dict]) -> list[dict]:
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
|
"""策略实时监控 — 订阅行情更新,检查策略买卖信号。
|
||||||
|
|
||||||
职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。
|
职责: 接收实时行情 DataFrame → 检查监控中策略的信号 → 推送告警。
|
||||||
不知道: 策略加载逻辑、AI、API、配置持久化、回测。
|
不知道: 策略加载逻辑、AI、API、配置持久化、回测。
|
||||||
依赖: 外部调用 on_quote_update() 传入实时数据。
|
依赖: 外部调用 on_quote_update() 传入实时数据。
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -72,7 +73,7 @@ def _signal_cn_name(name: str) -> str:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class StrategyAlert:
|
class StrategyAlert:
|
||||||
"""策略告警"""
|
"""策略告警"""
|
||||||
type: str # "entry" | "exit" | "alert"
|
type: str # "entry" | "exit"
|
||||||
strategy_id: str
|
strategy_id: str
|
||||||
symbol: str
|
symbol: str
|
||||||
name: str | None
|
name: str | None
|
||||||
@@ -104,7 +105,6 @@ class StrategyMonitorService:
|
|||||||
config: {
|
config: {
|
||||||
"entry_signals": ["signal_n_day_high", ...],
|
"entry_signals": ["signal_n_day_high", ...],
|
||||||
"exit_signals": ["signal_ma20_breakdown", ...],
|
"exit_signals": ["signal_ma20_breakdown", ...],
|
||||||
"alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
with self._watching_lock:
|
with self._watching_lock:
|
||||||
@@ -152,7 +152,7 @@ class StrategyMonitorService:
|
|||||||
strategy_id=strategy_id,
|
strategy_id=strategy_id,
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
name=name,
|
name=name,
|
||||||
message=f"入场信号触发",
|
message="入场信号触发",
|
||||||
price=price,
|
price=price,
|
||||||
change_pct=pct,
|
change_pct=pct,
|
||||||
signals=hit_sigs,
|
signals=hit_sigs,
|
||||||
@@ -169,7 +169,7 @@ class StrategyMonitorService:
|
|||||||
strategy_id=strategy_id,
|
strategy_id=strategy_id,
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
name=name,
|
name=name,
|
||||||
message=f"出场信号触发",
|
message="出场信号触发",
|
||||||
price=price,
|
price=price,
|
||||||
change_pct=pct,
|
change_pct=pct,
|
||||||
signals=hit_sigs,
|
signals=hit_sigs,
|
||||||
@@ -177,21 +177,6 @@ class StrategyMonitorService:
|
|||||||
all_alerts.append(alert)
|
all_alerts.append(alert)
|
||||||
self._emit(alert)
|
self._emit(alert)
|
||||||
|
|
||||||
# 提醒条件
|
|
||||||
for alert_cfg in cfg.get("alerts", []):
|
|
||||||
for sym, name, price, pct in self._check_alert(df, alert_cfg):
|
|
||||||
alert = StrategyAlert(
|
|
||||||
type="alert",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
symbol=sym,
|
|
||||||
name=name,
|
|
||||||
message=alert_cfg.get("message", "提醒"),
|
|
||||||
price=price,
|
|
||||||
change_pct=pct,
|
|
||||||
)
|
|
||||||
all_alerts.append(alert)
|
|
||||||
self._emit(alert)
|
|
||||||
|
|
||||||
return all_alerts
|
return all_alerts
|
||||||
|
|
||||||
def _emit(self, alert: StrategyAlert) -> None:
|
def _emit(self, alert: StrategyAlert) -> None:
|
||||||
@@ -230,46 +215,6 @@ class StrategyMonitorService:
|
|||||||
results.append((sym, name, price, pct, hit_sigs))
|
results.append((sym, name, price, pct, hit_sigs))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_alert(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
alert: dict,
|
|
||||||
) -> list[tuple[str, str | None, float | None, float | None]]:
|
|
||||||
"""检查阈值型提醒条件"""
|
|
||||||
field = alert.get("field", "")
|
|
||||||
if field not in df.columns:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if "op" in alert:
|
|
||||||
# 阈值比较
|
|
||||||
op = alert["op"]
|
|
||||||
value = alert["value"]
|
|
||||||
col = pl.col(field)
|
|
||||||
ops = {
|
|
||||||
">": col > value,
|
|
||||||
">=": col >= value,
|
|
||||||
"<": col < value,
|
|
||||||
"<=": col <= value,
|
|
||||||
}
|
|
||||||
expr = ops.get(op)
|
|
||||||
if expr is None:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
# 信号列 (布尔)
|
|
||||||
expr = pl.col(field).fill_null(False)
|
|
||||||
|
|
||||||
hit_df = df.filter(expr)
|
|
||||||
results = []
|
|
||||||
for row in hit_df.iter_rows(named=True):
|
|
||||||
results.append((
|
|
||||||
row.get("symbol", ""),
|
|
||||||
row.get("name"),
|
|
||||||
row.get("close"),
|
|
||||||
row.get("change_pct"),
|
|
||||||
))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 通用监控规则引擎 MonitorRuleEngine
|
# 通用监控规则引擎 MonitorRuleEngine
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -414,6 +359,8 @@ class MonitorRuleEngine:
|
|||||||
return (
|
return (
|
||||||
rule.get("type"),
|
rule.get("type"),
|
||||||
rule.get("strategy_id"),
|
rule.get("strategy_id"),
|
||||||
|
rule.get("score_min"),
|
||||||
|
rule.get("score_max"),
|
||||||
rule.get("asset_type", "stock"),
|
rule.get("asset_type", "stock"),
|
||||||
rule.get("scope", "symbols"),
|
rule.get("scope", "symbols"),
|
||||||
tuple(sorted(str(symbol) for symbol in rule.get("symbols", []))),
|
tuple(sorted(str(symbol) for symbol in rule.get("symbols", []))),
|
||||||
@@ -987,7 +934,16 @@ class MonitorRuleEngine:
|
|||||||
current=df,
|
current=df,
|
||||||
market=matrix,
|
market=matrix,
|
||||||
)
|
)
|
||||||
elif s.filter_history_fn:
|
required_history_bars = 1
|
||||||
|
history_resolver = getattr(self._strategy_engine, "required_history_bars", None)
|
||||||
|
if callable(history_resolver):
|
||||||
|
required_history_bars = history_resolver(
|
||||||
|
[sid],
|
||||||
|
overrides_map={sid: overrides},
|
||||||
|
)
|
||||||
|
if getattr(s, "execution_backend", "polars_expr") not in {"composite", "matrix_native"} and (
|
||||||
|
s.filter_history_fn or required_history_bars > 1
|
||||||
|
):
|
||||||
history_loader = self._history_loader_for(rule)
|
history_loader = self._history_loader_for(rule)
|
||||||
if history_loader is None:
|
if history_loader is None:
|
||||||
logger.debug("策略 %s 需要历史数据但未注入 history_loader (asset_type=%s), 跳过实时监控",
|
logger.debug("策略 %s 需要历史数据但未注入 history_loader (asset_type=%s), 跳过实时监控",
|
||||||
@@ -995,7 +951,7 @@ class MonitorRuleEngine:
|
|||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
today = cn_today()
|
today = cn_today()
|
||||||
lookback = max(1, getattr(s, "lookback_days", 30))
|
lookback = max(1, getattr(s, "lookback_days", 1), required_history_bars)
|
||||||
hist_df = history_loader(today, lookback)
|
hist_df = history_loader(today, lookback)
|
||||||
if hist_df is None or hist_df.is_empty():
|
if hist_df is None or hist_df.is_empty():
|
||||||
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
|
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
|
||||||
@@ -1039,7 +995,6 @@ class MonitorRuleEngine:
|
|||||||
# 避免并发读到半填充状态。
|
# 避免并发读到半填充状态。
|
||||||
if at == "stock":
|
if at == "stock":
|
||||||
try:
|
try:
|
||||||
import math
|
|
||||||
self._building_strategy_results[sid] = {
|
self._building_strategy_results[sid] = {
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"as_of": str(cn_today()),
|
"as_of": str(cn_today()),
|
||||||
@@ -1053,7 +1008,27 @@ class MonitorRuleEngine:
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
current_pool: set[str] = {r["symbol"] for r in result.rows}
|
score_min = rule.get("score_min")
|
||||||
|
score_max = rule.get("score_max")
|
||||||
|
score_filter_enabled = score_min is not None or score_max is not None
|
||||||
|
eligible_symbols: set[str] = set()
|
||||||
|
if score_filter_enabled:
|
||||||
|
for row in result.rows:
|
||||||
|
symbol = str(row.get("symbol", ""))
|
||||||
|
score = row.get("score", result.scores.get(symbol))
|
||||||
|
if isinstance(score, bool) or not isinstance(score, (int, float)):
|
||||||
|
continue
|
||||||
|
if not math.isfinite(score):
|
||||||
|
continue
|
||||||
|
if score_min is not None and score < score_min:
|
||||||
|
continue
|
||||||
|
if score_max is not None and score > score_max:
|
||||||
|
continue
|
||||||
|
eligible_symbols.add(symbol)
|
||||||
|
else:
|
||||||
|
eligible_symbols = {str(row["symbol"]) for row in result.rows}
|
||||||
|
|
||||||
|
current_pool = eligible_symbols
|
||||||
prev_pool = self._strategy_pools.get(pool_key)
|
prev_pool = self._strategy_pools.get(pool_key)
|
||||||
self._strategy_pools[pool_key] = current_pool
|
self._strategy_pools[pool_key] = current_pool
|
||||||
|
|
||||||
@@ -1066,9 +1041,15 @@ class MonitorRuleEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
entry_signal_hits = result.entry_signal_hits
|
||||||
|
if score_filter_enabled:
|
||||||
|
entry_signal_hits = [
|
||||||
|
hit for hit in entry_signal_hits
|
||||||
|
if str(hit.get("symbol", "")) in eligible_symbols
|
||||||
|
]
|
||||||
changes: dict[str, set[str]] = {
|
changes: dict[str, set[str]] = {
|
||||||
"buy_signal": self._new_strategy_signals(
|
"buy_signal": self._new_strategy_signals(
|
||||||
pool_key, "buy_signal", result.as_of, result.entry_signal_hits,
|
pool_key, "buy_signal", result.as_of, entry_signal_hits,
|
||||||
),
|
),
|
||||||
"sell_signal": self._new_strategy_signals(
|
"sell_signal": self._new_strategy_signals(
|
||||||
pool_key, "sell_signal", result.as_of, result.exit_signal_hits,
|
pool_key, "sell_signal", result.as_of, result.exit_signal_hits,
|
||||||
@@ -1081,7 +1062,7 @@ class MonitorRuleEngine:
|
|||||||
signal_map = {
|
signal_map = {
|
||||||
"buy_signal": {
|
"buy_signal": {
|
||||||
str(hit["symbol"]): list(hit.get("signals") or [])
|
str(hit["symbol"]): list(hit.get("signals") or [])
|
||||||
for hit in result.entry_signal_hits
|
for hit in entry_signal_hits
|
||||||
},
|
},
|
||||||
"sell_signal": {
|
"sell_signal": {
|
||||||
str(hit["symbol"]): list(hit.get("signals") or [])
|
str(hit["symbol"]): list(hit.get("signals") or [])
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -132,6 +133,17 @@ def validate(rule: dict) -> None:
|
|||||||
invalid_events = set(notify_events) - STRATEGY_NOTIFY_EVENTS
|
invalid_events = set(notify_events) - STRATEGY_NOTIFY_EVENTS
|
||||||
if invalid_events:
|
if invalid_events:
|
||||||
raise ValueError(f"notify_events 包含非法事件: {sorted(invalid_events)}")
|
raise ValueError(f"notify_events 包含非法事件: {sorted(invalid_events)}")
|
||||||
|
score_min = rule.get("score_min")
|
||||||
|
score_max = rule.get("score_max")
|
||||||
|
for label, value in (("评分下限", score_min), ("评分上限", score_max)):
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||||
|
raise ValueError(f"{label}必须是 0 到 100 之间的数字")
|
||||||
|
if value < 0 or value > 100:
|
||||||
|
raise ValueError(f"{label}必须是 0 到 100 之间的数字")
|
||||||
|
if score_min is not None and score_max is not None and score_min > score_max:
|
||||||
|
raise ValueError("评分下限不能大于评分上限")
|
||||||
elif rule.get("type") == "ladder":
|
elif rule.get("type") == "ladder":
|
||||||
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
|
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
|
||||||
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
|
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
|
||||||
@@ -231,6 +243,8 @@ def normalize(rule: dict) -> dict:
|
|||||||
# direction 默认值: ladder/sector 用 "up", 其余用 "entry"
|
# direction 默认值: ladder/sector 用 "up", 其余用 "entry"
|
||||||
r.setdefault("direction", "up" if r.get("type") in {"ladder", "sector"} else "entry")
|
r.setdefault("direction", "up" if r.get("type") in {"ladder", "sector"} else "entry")
|
||||||
if r.get("type") == "strategy":
|
if r.get("type") == "strategy":
|
||||||
|
r.setdefault("score_min", None)
|
||||||
|
r.setdefault("score_max", None)
|
||||||
if r.get("notify_events") is None:
|
if r.get("notify_events") is None:
|
||||||
# 兼容统一监控上线后的旧规则: 当时实际行为是同时通知进入和移出。
|
# 兼容统一监控上线后的旧规则: 当时实际行为是同时通知进入和移出。
|
||||||
r["notify_events"] = ["pool_entry", "pool_exit"]
|
r["notify_events"] = ["pool_entry", "pool_exit"]
|
||||||
@@ -238,6 +252,8 @@ def normalize(rule: dict) -> dict:
|
|||||||
r["notify_events"] = list(dict.fromkeys(r["notify_events"]))
|
r["notify_events"] = list(dict.fromkeys(r["notify_events"]))
|
||||||
else:
|
else:
|
||||||
r.pop("notify_events", None)
|
r.pop("notify_events", None)
|
||||||
|
r.pop("score_min", None)
|
||||||
|
r.pop("score_max", None)
|
||||||
r.setdefault("conditions", [])
|
r.setdefault("conditions", [])
|
||||||
# ladder 专属默认字段
|
# ladder 专属默认字段
|
||||||
r.setdefault("metric", "sealed_vol")
|
r.setdefault("metric", "sealed_vol")
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def build_step1(
|
|||||||
strategy_id: str = "",
|
strategy_id: str = "",
|
||||||
execution_backend: str = "polars_expr",
|
execution_backend: str = "polars_expr",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警)
|
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分)
|
||||||
|
|
||||||
注意: 生成规范已在 ai_generator.py 的 system prompt 中加载,
|
注意: 生成规范已在 ai_generator.py 的 system prompt 中加载,
|
||||||
此处只拼用户输入以降低网关超时概率。
|
此处只拼用户输入以降低网关超时概率。
|
||||||
|
|||||||
@@ -35,9 +35,8 @@
|
|||||||
1. **META**:id(name, description, tags, params, scoring, basic_filter, limit 等)
|
1. **META**:id(name, description, tags, params, scoring, basic_filter, limit 等)
|
||||||
2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例
|
2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例
|
||||||
3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天
|
3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天
|
||||||
4. **ALERTS**:列出需要监控提醒的条件
|
4. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整
|
||||||
5. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整
|
5. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑
|
||||||
6. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑
|
|
||||||
|
|
||||||
## 性能原则
|
## 性能原则
|
||||||
|
|
||||||
@@ -84,8 +83,6 @@ EXIT_SIGNALS = []
|
|||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
|
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 规则一
|
1. 规则一
|
||||||
2. 规则二
|
2. 规则二
|
||||||
@@ -136,8 +133,6 @@ EXIT_SIGNALS = []
|
|||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
|
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 规则一(包含时序逻辑)
|
1. 规则一(包含时序逻辑)
|
||||||
2. 规则二
|
2. 规则二
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
- 增/删/改参数 → 更新 META["params"],同步修改当前执行后端对应的 `filter()`、`filter_history()` 或 `MATRIX_STRATEGY`
|
- 增/删/改参数 → 更新 META["params"],同步修改当前执行后端对应的 `filter()`、`filter_history()` 或 `MATRIX_STRATEGY`
|
||||||
- 调整信号 → 更新 ENTRY_SIGNALS / EXIT_SIGNALS
|
- 调整信号 → 更新 ENTRY_SIGNALS / EXIT_SIGNALS
|
||||||
- 修改止损/持有 → 更新 STOP_LOSS / MAX_HOLD_DAYS
|
- 修改止损/持有 → 更新 STOP_LOSS / MAX_HOLD_DAYS
|
||||||
- 增减告警 → 更新 ALERTS
|
|
||||||
- 调整评分 → 更新 META["scoring"];只使用真实数值字段或受控虚拟字段 `ma20_bias`,权重总和保持 1.0
|
- 调整评分 → 更新 META["scoring"];只使用真实数值字段或受控虚拟字段 `ma20_bias`,权重总和保持 1.0
|
||||||
- 修改筛选逻辑 → 更新唯一公式;新增历史回溯时切换为 `python_history_legacy` + `filter_history()`,移除回溯时切回 `polars_expr` + `filter()`,不得同时保留两套公式
|
- 修改筛选逻辑 → 更新唯一公式;新增历史回溯时切换为 `python_history_legacy` + `filter_history()`,移除回溯时切回 `polars_expr` + `filter()`,不得同时保留两套公式
|
||||||
|
|
||||||
@@ -38,3 +37,4 @@
|
|||||||
7. 优先使用 Polars 表达式、窗口函数、聚合和 join,不要默认改成逐行/逐股 Python 循环
|
7. 优先使用 Polars 表达式、窗口函数、聚合和 join,不要默认改成逐行/逐股 Python 循环
|
||||||
8. **输出前自我检查**:完整通读修改后的代码,确认 Python 语法正确、括号匹配、引号闭合、缩进一致。有错误直接修正再输出。
|
8. **输出前自我检查**:完整通读修改后的代码,确认 Python 语法正确、括号匹配、引号闭合、缩进一致。有错误直接修正再输出。
|
||||||
9. 直接输出完整 Python 代码
|
9. 直接输出完整 Python 代码
|
||||||
|
10. 历史代码中的 `ALERTS` 已废弃,输出时删除;实时提醒由监控中心统一管理
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| 方向 | 做多 |
|
| 方向 | 做多 |
|
||||||
| 规则 | 前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。 |
|
| 规则 | 前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。 |
|
||||||
|
|
||||||
点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分、告警):
|
点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""强势反包 — 前日阴线下跌 + 今日放量阳线反包"""
|
"""强势反包 — 前日阴线下跌 + 今日放量阳线反包"""
|
||||||
@@ -82,9 +82,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 10
|
MAX_HOLD_DAYS = 10
|
||||||
ALERTS = [
|
|
||||||
{"field": "signal_broken_board_recovery", "message": "反包信号"},
|
|
||||||
]
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 前一交易日为阴线,且跌幅不小于设定阈值
|
1. 前一交易日为阴线,且跌幅不小于设定阈值
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 规则一
|
1. 规则一
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ STOP_LOSS = -0.05
|
|||||||
# 最长持有天数 (短线 5~20, 中线 20~60)
|
# 最长持有天数 (短线 5~20, 中线 20~60)
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
|
|
||||||
# 提醒条件 (监控用)
|
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
|
|
||||||
# 策略规则(人类可读,逐条编号,至少 3 条)
|
# 策略规则(人类可读,逐条编号,至少 3 条)
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 规则描述一
|
1. 规则描述一
|
||||||
@@ -330,7 +326,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 10
|
MAX_HOLD_DAYS = 10
|
||||||
ALERTS = [{"field": "signal_broken_board_recovery", "message": "反包信号"}]
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 前一交易日为阴线,且跌幅不小于设定阈值
|
1. 前一交易日为阴线,且跌幅不小于设定阈值
|
||||||
|
|||||||
@@ -6,11 +6,75 @@ from typing import Any
|
|||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
|
SCORING_DIRECTION_HIGH = "high"
|
||||||
|
SCORING_DIRECTION_LOW = "low"
|
||||||
|
SCORING_DIRECTIONS = frozenset({SCORING_DIRECTION_HIGH, SCORING_DIRECTION_LOW})
|
||||||
|
|
||||||
VIRTUAL_SCORING_DEPENDENCIES: dict[str, frozenset[str]] = {
|
VIRTUAL_SCORING_DEPENDENCIES: dict[str, frozenset[str]] = {
|
||||||
"ma20_bias": frozenset({"close", "ma20"}),
|
**{
|
||||||
|
f"ma{period}_bias": frozenset({"close", f"ma{period}"})
|
||||||
|
for period in (5, 10, 20, 30, 60)
|
||||||
|
},
|
||||||
|
**{
|
||||||
|
f"ema{period}_bias": frozenset({"close", f"ema{period}"})
|
||||||
|
for period in (5, 10, 20, 30, 60)
|
||||||
|
},
|
||||||
|
"macd_dif_pct": frozenset({"close", "macd_dif"}),
|
||||||
|
"macd_dea_pct": frozenset({"close", "macd_dea"}),
|
||||||
|
"macd_hist_pct": frozenset({"close", "macd_hist"}),
|
||||||
|
"boll_position": frozenset({"close", "boll_upper", "boll_lower"}),
|
||||||
|
"atr_pct": frozenset({"close", "atr_14"}),
|
||||||
|
"boll_width": frozenset({"ma20", "boll_upper", "boll_lower"}),
|
||||||
|
"vol_ratio_10d": frozenset({"volume"}),
|
||||||
|
"vol_trend_5_10": frozenset({"vol_ma5", "vol_ma10"}),
|
||||||
|
"turnover_ratio_5d": frozenset({"turnover_rate"}),
|
||||||
|
"log_amount": frozenset({"amount"}),
|
||||||
|
"amount_ratio_5d": frozenset({"amount"}),
|
||||||
|
"gap_return": frozenset({"open", "prev_close"}),
|
||||||
|
"intraday_return": frozenset({"open", "close"}),
|
||||||
|
"close_position": frozenset({"high", "low", "close"}),
|
||||||
|
"distance_to_high_60d": frozenset({"close", "high_60d"}),
|
||||||
|
"distance_from_low_60d": frozenset({"close", "low_60d"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ROLLING_SCORING_WARMUP: dict[str, int] = {
|
||||||
|
"vol_ratio_10d": 11,
|
||||||
|
"turnover_ratio_5d": 6,
|
||||||
|
"amount_ratio_5d": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def effective_scoring(
|
||||||
|
defaults: Mapping[str, Any] | None,
|
||||||
|
overrides: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""解析有效评分;新配置可完整替换,历史配置保持局部覆盖。"""
|
||||||
|
override_values = (overrides or {}).get("scoring")
|
||||||
|
if (overrides or {}).get("scoring_replace") is True:
|
||||||
|
return dict(override_values) if isinstance(override_values, Mapping) else {}
|
||||||
|
scoring = dict(defaults or {})
|
||||||
|
if isinstance(override_values, Mapping):
|
||||||
|
scoring.update(override_values)
|
||||||
|
return scoring
|
||||||
|
|
||||||
|
|
||||||
|
def effective_scoring_directions(overrides: Mapping[str, Any] | None) -> dict[str, str]:
|
||||||
|
values = (overrides or {}).get("scoring_directions")
|
||||||
|
if not isinstance(values, Mapping):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(name): str(direction)
|
||||||
|
for name, direction in values.items()
|
||||||
|
if direction in SCORING_DIRECTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scoring_warmup_bars(scoring: Mapping[str, Any]) -> int:
|
||||||
|
return max(
|
||||||
|
(_ROLLING_SCORING_WARMUP.get(str(name), 1) for name, weight in scoring.items() if weight),
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def scoring_dependencies(scoring: Mapping[str, Any]) -> set[str]:
|
def scoring_dependencies(scoring: Mapping[str, Any]) -> set[str]:
|
||||||
"""把受控虚拟评分字段展开为实际数据依赖。"""
|
"""把受控虚拟评分字段展开为实际数据依赖。"""
|
||||||
@@ -30,8 +94,76 @@ def scoring_value_expr(columns: Collection[str], name: str) -> pl.Expr | None:
|
|||||||
dependencies = VIRTUAL_SCORING_DEPENDENCIES.get(name)
|
dependencies = VIRTUAL_SCORING_DEPENDENCIES.get(name)
|
||||||
if dependencies is None or not dependencies.issubset(available):
|
if dependencies is None or not dependencies.issubset(available):
|
||||||
return None
|
return None
|
||||||
if name == "ma20_bias":
|
if name.startswith("ma") and name.endswith("_bias"):
|
||||||
return pl.when(pl.col("ma20") != 0).then(
|
period = name.removeprefix("ma").removesuffix("_bias")
|
||||||
pl.col("close") / pl.col("ma20") - 1.0
|
if period.isdigit():
|
||||||
).otherwise(None)
|
return _relative(pl.col("close"), pl.col(f"ma{period}"))
|
||||||
|
if name.startswith("ema") and name.endswith("_bias"):
|
||||||
|
period = name.removeprefix("ema").removesuffix("_bias")
|
||||||
|
if period.isdigit():
|
||||||
|
return _relative(pl.col("close"), pl.col(f"ema{period}"))
|
||||||
|
if name in {"macd_dif_pct", "macd_dea_pct", "macd_hist_pct"}:
|
||||||
|
source = name.removesuffix("_pct")
|
||||||
|
return _ratio(pl.col(source), pl.col("close"))
|
||||||
|
if name == "atr_pct":
|
||||||
|
return _ratio(pl.col("atr_14"), pl.col("close"))
|
||||||
|
if name == "boll_position":
|
||||||
|
return _ratio(
|
||||||
|
pl.col("close") - pl.col("boll_lower"),
|
||||||
|
pl.col("boll_upper") - pl.col("boll_lower"),
|
||||||
|
)
|
||||||
|
if name == "boll_width":
|
||||||
|
return _ratio(pl.col("boll_upper") - pl.col("boll_lower"), pl.col("ma20"))
|
||||||
|
if name == "vol_ratio_10d":
|
||||||
|
return _ratio(
|
||||||
|
pl.col("volume"),
|
||||||
|
pl.col("volume").shift(1).rolling_mean(10).over("symbol"),
|
||||||
|
)
|
||||||
|
if name == "vol_trend_5_10":
|
||||||
|
return _relative(pl.col("vol_ma5"), pl.col("vol_ma10"))
|
||||||
|
if name == "turnover_ratio_5d":
|
||||||
|
return _relative(
|
||||||
|
pl.col("turnover_rate"),
|
||||||
|
pl.col("turnover_rate").shift(1).rolling_mean(5).over("symbol"),
|
||||||
|
)
|
||||||
|
if name == "log_amount":
|
||||||
|
return pl.when(pl.col("amount") >= 0).then((pl.col("amount") + 1).log()).otherwise(None)
|
||||||
|
if name == "amount_ratio_5d":
|
||||||
|
return _relative(
|
||||||
|
pl.col("amount"),
|
||||||
|
pl.col("amount").shift(1).rolling_mean(5).over("symbol"),
|
||||||
|
)
|
||||||
|
if name == "gap_return":
|
||||||
|
return _relative(pl.col("open"), pl.col("prev_close"))
|
||||||
|
if name == "intraday_return":
|
||||||
|
return _relative(pl.col("close"), pl.col("open"))
|
||||||
|
if name == "close_position":
|
||||||
|
return _ratio(pl.col("close") - pl.col("low"), pl.col("high") - pl.col("low"))
|
||||||
|
if name == "distance_to_high_60d":
|
||||||
|
return _relative(pl.col("close"), pl.col("high_60d"))
|
||||||
|
if name == "distance_from_low_60d":
|
||||||
|
return _relative(pl.col("close"), pl.col("low_60d"))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_scoring_columns(
|
||||||
|
frame: pl.DataFrame,
|
||||||
|
names: Collection[str],
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
expressions = [
|
||||||
|
expression.alias(name)
|
||||||
|
for name in names
|
||||||
|
if name not in frame.columns
|
||||||
|
and (expression := scoring_value_expr(frame.columns, str(name))) is not None
|
||||||
|
]
|
||||||
|
return frame.with_columns(expressions) if expressions else frame
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio(numerator: pl.Expr, denominator: pl.Expr) -> pl.Expr:
|
||||||
|
return pl.when(denominator.is_not_null() & (denominator != 0)).then(
|
||||||
|
numerator / denominator
|
||||||
|
).otherwise(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(numerator: pl.Expr, denominator: pl.Expr) -> pl.Expr:
|
||||||
|
return _ratio(numerator, denominator) - 1.0
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ def _strategy(**overrides) -> StrategyDef:
|
|||||||
trailing_take_profit_activate=None,
|
trailing_take_profit_activate=None,
|
||||||
trailing_take_profit_drawdown=None,
|
trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=None,
|
max_hold_days=None,
|
||||||
alerts=[],
|
|
||||||
filter_fn=lambda df, params: pl.col("rsi_14") < params["rsi_max"],
|
filter_fn=lambda df, params: pl.col("rsi_14") < params["rsi_max"],
|
||||||
filter_history_fn=None,
|
filter_history_fn=None,
|
||||||
lookback_days=20,
|
lookback_days=20,
|
||||||
@@ -65,6 +64,23 @@ def test_resolver_expands_virtual_scoring_dependencies():
|
|||||||
assert "ma20_bias" not in plan.indicator_columns
|
assert "ma20_bias" not in plan.indicator_columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_honors_full_scoring_replacement():
|
||||||
|
plan = StrategyDependencyResolver().resolve(
|
||||||
|
_strategy(),
|
||||||
|
params={"rsi_max": 30},
|
||||||
|
basic_filter={"enabled": False},
|
||||||
|
entry_signals=[],
|
||||||
|
exit_signals=[],
|
||||||
|
overrides={
|
||||||
|
"scoring": {"amount_ratio_5d": 1.0},
|
||||||
|
"scoring_replace": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "amount" in plan.base_columns
|
||||||
|
assert "momentum_20d" not in plan.indicator_columns
|
||||||
|
|
||||||
|
|
||||||
def test_history_strategy_without_required_features_falls_back_to_full(caplog):
|
def test_history_strategy_without_required_features_falls_back_to_full(caplog):
|
||||||
strategy = _strategy(
|
strategy = _strategy(
|
||||||
filter_fn=None,
|
filter_fn=None,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.backtest.factor import (
|
||||||
|
DERIVED_FACTOR_DEPENDENCIES,
|
||||||
|
FACTOR_COLUMNS,
|
||||||
|
FactorBacktestService,
|
||||||
|
FactorBatchConfig,
|
||||||
|
FactorConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _panel() -> pl.DataFrame:
|
||||||
|
rows = []
|
||||||
|
start = date(2026, 1, 1)
|
||||||
|
for day in range(8):
|
||||||
|
for index, symbol in enumerate(("000001.SZ", "000002.SZ", "600000.SH")):
|
||||||
|
rows.append({
|
||||||
|
"symbol": symbol,
|
||||||
|
"date": start + timedelta(days=day),
|
||||||
|
"open": 10.0 + index + day * 0.1,
|
||||||
|
"high": 10.5 + index + day * 0.1,
|
||||||
|
"low": 9.5 + index + day * 0.1,
|
||||||
|
"close": 10.0 + index + day * (index + 1) * 0.1,
|
||||||
|
"volume": 1000.0 + index * 100 + day,
|
||||||
|
"change_pct": 0.01 * (index + 1) + day * 0.001,
|
||||||
|
"turnover_rate": 0.02 * (3 - index) + day * 0.001,
|
||||||
|
})
|
||||||
|
return pl.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
class _Engine:
|
||||||
|
def __init__(self, panel: pl.DataFrame) -> None:
|
||||||
|
self.panel = panel
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
def load_panel(self, symbols, start, end, columns, asset_type):
|
||||||
|
self.calls.append({
|
||||||
|
"symbols": symbols,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"columns": columns,
|
||||||
|
"asset_type": asset_type,
|
||||||
|
})
|
||||||
|
selected = [column for column in columns if column in self.panel.columns]
|
||||||
|
return self.panel.select(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_config(factor_names: list[str]) -> FactorBatchConfig:
|
||||||
|
return FactorBatchConfig(
|
||||||
|
factor_names=factor_names,
|
||||||
|
symbols=None,
|
||||||
|
start=date(2026, 1, 1),
|
||||||
|
end=date(2026, 1, 8),
|
||||||
|
n_groups=3,
|
||||||
|
rebalance="daily",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_loads_panel_once_and_deduplicates_factors():
|
||||||
|
engine = _Engine(_panel())
|
||||||
|
result = FactorBacktestService(engine).run_batch(
|
||||||
|
_batch_config(["change_pct", "turnover_rate", "change_pct"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(engine.calls) == 1
|
||||||
|
assert result.config["factor_names"] == ["change_pct", "turnover_rate"]
|
||||||
|
assert [item.factor_name for item in result.results] == ["change_pct", "turnover_rate"]
|
||||||
|
assert all(item.error is None for item in result.results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_isolates_a_single_factor_failure(monkeypatch):
|
||||||
|
engine = _Engine(_panel())
|
||||||
|
service = FactorBacktestService(engine)
|
||||||
|
original = service._evaluate_panel
|
||||||
|
|
||||||
|
def evaluate(panel, config, run_id, started_at):
|
||||||
|
if config.factor_name == "turnover_rate":
|
||||||
|
raise ValueError("broken factor")
|
||||||
|
return original(panel, config, run_id, started_at)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_evaluate_panel", evaluate)
|
||||||
|
result = service.run_batch(_batch_config(["change_pct", "turnover_rate"]))
|
||||||
|
|
||||||
|
assert result.results[0].error is None
|
||||||
|
assert result.results[1].error == "broken factor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_empty_panel_returns_batch_error():
|
||||||
|
engine = _Engine(pl.DataFrame())
|
||||||
|
result = FactorBacktestService(engine).run_batch(_batch_config(["change_pct"]))
|
||||||
|
|
||||||
|
assert len(engine.calls) == 1
|
||||||
|
assert result.results == []
|
||||||
|
assert result.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_factor_contract_remains_compatible():
|
||||||
|
engine = _Engine(_panel())
|
||||||
|
result = FactorBacktestService(engine).run(FactorConfig(
|
||||||
|
factor_name="change_pct",
|
||||||
|
symbols=None,
|
||||||
|
start=date(2026, 1, 1),
|
||||||
|
end=date(2026, 1, 8),
|
||||||
|
n_groups=3,
|
||||||
|
rebalance="daily",
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.error is None
|
||||||
|
assert result.config["factor_name"] == "change_pct"
|
||||||
|
assert result.config["asset_type"] == "stock"
|
||||||
|
assert result.n_symbols == 3
|
||||||
|
assert result.ic_series
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_catalog_covers_normalized_indicator_families():
|
||||||
|
factor_ids = [item["id"] for item in FACTOR_COLUMNS]
|
||||||
|
|
||||||
|
assert len(factor_ids) == len(set(factor_ids))
|
||||||
|
assert len(factor_ids) > 16
|
||||||
|
assert {
|
||||||
|
"ma5_bias",
|
||||||
|
"ema60_bias",
|
||||||
|
"macd_hist_pct",
|
||||||
|
"boll_position",
|
||||||
|
"atr_pct",
|
||||||
|
"kdj_d",
|
||||||
|
"vol_ratio_10d",
|
||||||
|
"turnover_ratio_5d",
|
||||||
|
"log_amount",
|
||||||
|
"gap_return",
|
||||||
|
"distance_to_high_60d",
|
||||||
|
} <= set(factor_ids)
|
||||||
|
assert set(DERIVED_FACTOR_DEPENDENCIES) <= set(factor_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_factors_are_computed_from_shared_base_panel():
|
||||||
|
start = date(2026, 1, 1)
|
||||||
|
rows = []
|
||||||
|
for day in range(70):
|
||||||
|
close = 10.0 + day
|
||||||
|
rows.append({
|
||||||
|
"symbol": "000001.SZ",
|
||||||
|
"date": start + timedelta(days=day),
|
||||||
|
"open": close * 0.99,
|
||||||
|
"high": close * 1.01,
|
||||||
|
"low": close * 0.98,
|
||||||
|
"close": close,
|
||||||
|
"volume": 1000.0 + day,
|
||||||
|
"amount": (1000.0 + day) * close,
|
||||||
|
"turnover_rate": 2.0 + day * 0.01,
|
||||||
|
})
|
||||||
|
engine = _Engine(pl.DataFrame(rows))
|
||||||
|
service = FactorBacktestService(engine)
|
||||||
|
factor_names = [
|
||||||
|
"ma20_bias",
|
||||||
|
"atr_pct",
|
||||||
|
"boll_position",
|
||||||
|
"vol_ratio_10d",
|
||||||
|
"turnover_ratio_5d",
|
||||||
|
"log_amount",
|
||||||
|
"gap_return",
|
||||||
|
"intraday_return",
|
||||||
|
"close_position",
|
||||||
|
"distance_to_high_60d",
|
||||||
|
]
|
||||||
|
|
||||||
|
panel = service._load_factor_panel(_batch_config(factor_names), factor_names)
|
||||||
|
last = panel.tail(1).to_dicts()[0]
|
||||||
|
|
||||||
|
assert set(factor_names) <= set(panel.columns)
|
||||||
|
assert last["ma20_bias"] == pytest.approx(79.0 / 69.5 - 1)
|
||||||
|
assert last["atr_pct"] == pytest.approx(last["atr_14"] / 79.0)
|
||||||
|
assert last["boll_position"] == pytest.approx(
|
||||||
|
(79.0 - last["boll_lower"]) / (last["boll_upper"] - last["boll_lower"]),
|
||||||
|
)
|
||||||
|
assert last["vol_ratio_10d"] == pytest.approx(1069.0 / 1063.5)
|
||||||
|
assert last["turnover_ratio_5d"] == pytest.approx(2.69 / 2.66 - 1)
|
||||||
|
assert last["log_amount"] == pytest.approx(float(np.log1p(1069.0 * 79.0)))
|
||||||
|
assert last["gap_return"] == pytest.approx((79.0 * 0.99) / 78.0 - 1)
|
||||||
|
assert last["intraday_return"] == pytest.approx(1 / 0.99 - 1)
|
||||||
|
assert last["close_position"] == pytest.approx(2 / 3)
|
||||||
|
assert last["distance_to_high_60d"] == pytest.approx(0.0)
|
||||||
@@ -11,6 +11,7 @@ import polars as pl
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.backtest import matrix as matrix_module
|
from app.backtest import matrix as matrix_module
|
||||||
|
from app.backtest.factor import FACTOR_COLUMNS, FactorBacktestService
|
||||||
from app.backtest.matrix import (
|
from app.backtest.matrix import (
|
||||||
MatrixPipelineConfig,
|
MatrixPipelineConfig,
|
||||||
MatrixStrategyPipeline,
|
MatrixStrategyPipeline,
|
||||||
@@ -80,6 +81,40 @@ def test_common_matrix_features_match_polars_indicator_pipeline():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_factor_catalog_matches_matrix_features():
|
||||||
|
rows = []
|
||||||
|
start = date(2024, 1, 1)
|
||||||
|
for offset in range(120):
|
||||||
|
for asset_id, symbol in enumerate(("000001.SZ", "600000.SH")):
|
||||||
|
close = 10.0 + asset_id * 5.0 + offset * (0.02 + asset_id * 0.01) + np.sin(offset / 5.0)
|
||||||
|
rows.append({
|
||||||
|
"symbol": symbol,
|
||||||
|
"date": start + timedelta(days=offset),
|
||||||
|
"open": close - 0.15 + (offset % 3) * 0.02,
|
||||||
|
"high": close + 0.35 + asset_id * 0.03,
|
||||||
|
"low": close - 0.3,
|
||||||
|
"close": close,
|
||||||
|
"volume": 1000.0 + asset_id * 250.0 + (offset % 9) * 80.0,
|
||||||
|
"amount": (1000.0 + asset_id * 250.0 + (offset % 9) * 80.0) * close,
|
||||||
|
"turnover_rate": 1.0 + asset_id * 0.2 + (offset % 7) * 0.05,
|
||||||
|
})
|
||||||
|
panel = pl.DataFrame(rows)
|
||||||
|
factor_names = {item["id"] for item in FACTOR_COLUMNS}
|
||||||
|
expected = FactorBacktestService._compute_missing_factors(panel, factor_names)
|
||||||
|
market = build_market_data_matrix(panel, field_columns={"amount", "turnover_rate"})
|
||||||
|
|
||||||
|
for name in sorted(factor_names):
|
||||||
|
expected_values = expected.sort(["date", "symbol"])[name].to_numpy().reshape(market.shape)
|
||||||
|
np.testing.assert_allclose(
|
||||||
|
matrix_feature(market, name),
|
||||||
|
expected_values,
|
||||||
|
rtol=2e-4,
|
||||||
|
atol=2e-4,
|
||||||
|
equal_nan=True,
|
||||||
|
err_msg=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _panel_with_missing_asset_bar() -> pl.DataFrame:
|
def _panel_with_missing_asset_bar() -> pl.DataFrame:
|
||||||
rows = []
|
rows = []
|
||||||
start = date(2024, 1, 1)
|
start = date(2024, 1, 1)
|
||||||
@@ -740,6 +775,22 @@ def test_chunked_matrix_score_matches_previous_full_matrix_formula():
|
|||||||
)
|
)
|
||||||
np.testing.assert_array_equal(actual, expected)
|
np.testing.assert_array_equal(actual, expected)
|
||||||
|
|
||||||
|
inverted = build_matrix_score(
|
||||||
|
market,
|
||||||
|
universe,
|
||||||
|
weights,
|
||||||
|
"score",
|
||||||
|
True,
|
||||||
|
fallback=np.zeros(market.shape, dtype=np.float32),
|
||||||
|
directions={"feature_a": "low", "feature_b": "low"},
|
||||||
|
)
|
||||||
|
np.testing.assert_allclose(
|
||||||
|
inverted[universe],
|
||||||
|
np.float32(100.0) - expected[universe],
|
||||||
|
rtol=1e-6,
|
||||||
|
atol=1e-6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_signal_slice_is_zero_copy_and_masking_only_allocates_final_flags():
|
def test_signal_slice_is_zero_copy_and_masking_only_allocates_final_flags():
|
||||||
entry = np.ones((5, 2), dtype=np.uint8)
|
entry = np.ones((5, 2), dtype=np.uint8)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api import backtest as api
|
||||||
|
from app.backtest.factor import FACTOR_COLUMNS
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_batch_api_rejects_unknown_factor():
|
||||||
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
|
||||||
|
req = api.FactorBatchRequest(factor_names=["unknown"])
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
api.factor_batch(req, request)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "unknown" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_batch_request_accepts_full_research_catalog():
|
||||||
|
factor_names = [item["id"] for item in FACTOR_COLUMNS]
|
||||||
|
|
||||||
|
request = api.FactorBatchRequest(factor_names=factor_names)
|
||||||
|
|
||||||
|
assert len(request.factor_names) > 16
|
||||||
|
assert request.factor_names == factor_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_api_create_list_and_update(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(api.settings, "data_dir", tmp_path)
|
||||||
|
created = api.candidate_create(api.CandidateCreateRequest(
|
||||||
|
kind="factor",
|
||||||
|
name="RSI 候选",
|
||||||
|
source_id="rsi_14",
|
||||||
|
config={"factor_name": "rsi_14"},
|
||||||
|
metrics={"ic_mean": 0.03},
|
||||||
|
data_as_of=date(2026, 8, 11),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert api.candidates_list()["items"][0]["id"] == created["id"]
|
||||||
|
updated = api.candidate_update(
|
||||||
|
created["id"],
|
||||||
|
api.CandidateUpdateRequest(status="validated"),
|
||||||
|
)
|
||||||
|
assert updated["status"] == "validated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_api_returns_clear_error_for_corrupt_file(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(api.settings, "data_dir", tmp_path)
|
||||||
|
path = tmp_path / "user_data" / "research_candidates.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text("not-json", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
api.candidates_list()
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
assert "损坏" in str(exc_info.value.detail)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.backtest.candidates import (
|
||||||
|
CandidateStore,
|
||||||
|
CandidateStoreError,
|
||||||
|
CandidateValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create(store: CandidateStore):
|
||||||
|
return store.create(
|
||||||
|
kind="factor",
|
||||||
|
name="20日动量候选",
|
||||||
|
source_id="momentum_20d",
|
||||||
|
config={"factor_name": "momentum_20d", "start": "2026-01-01"},
|
||||||
|
metrics={"ic_mean": 0.04, "ir": 0.8},
|
||||||
|
data_as_of="2026-08-11",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_crud_and_atomic_file(tmp_path):
|
||||||
|
store = CandidateStore(tmp_path)
|
||||||
|
created = _create(store)
|
||||||
|
|
||||||
|
assert store.path.exists()
|
||||||
|
assert not store.path.with_suffix(".json.tmp").exists()
|
||||||
|
assert store.list()[0]["id"] == created["id"]
|
||||||
|
|
||||||
|
updated = store.update(created["id"], status="validated", name="动量候选 A")
|
||||||
|
assert updated["status"] == "validated"
|
||||||
|
assert store.list()[0]["name"] == "动量候选 A"
|
||||||
|
|
||||||
|
store.delete(created["id"])
|
||||||
|
assert store.list() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_rejects_full_result_fields(tmp_path):
|
||||||
|
store = CandidateStore(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(CandidateValidationError, match="不允许的字段"):
|
||||||
|
store.create(
|
||||||
|
kind="strategy",
|
||||||
|
name="策略候选",
|
||||||
|
source_id="demo",
|
||||||
|
config={"strategy_id": "demo", "equity_curve": [1, 2]},
|
||||||
|
metrics={},
|
||||||
|
data_as_of=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_rejects_non_json_config(tmp_path):
|
||||||
|
store = CandidateStore(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(CandidateValidationError, match="无法序列化"):
|
||||||
|
store.create(
|
||||||
|
kind="strategy",
|
||||||
|
name="策略候选",
|
||||||
|
source_id="demo",
|
||||||
|
config={"strategy_id": object()},
|
||||||
|
metrics={},
|
||||||
|
data_as_of=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_loads_legacy_missing_optional_fields(tmp_path):
|
||||||
|
path = tmp_path / "user_data" / "research_candidates.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(json.dumps([{
|
||||||
|
"id": "legacy",
|
||||||
|
"kind": "factor",
|
||||||
|
"name": "旧候选",
|
||||||
|
"config": {"factor_name": "rsi_14", "equity_curve": [1, 2]},
|
||||||
|
"metrics": {"ic_mean": 0.03, "trades": [{"symbol": "000001.SZ"}]},
|
||||||
|
}]), encoding="utf-8")
|
||||||
|
|
||||||
|
item = CandidateStore(tmp_path).list()[0]
|
||||||
|
assert item["source_id"] == "rsi_14"
|
||||||
|
assert item["config"] == {"factor_name": "rsi_14"}
|
||||||
|
assert item["metrics"] == {"ic_mean": 0.03}
|
||||||
|
assert item["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_corrupt_file_fails_closed(tmp_path):
|
||||||
|
path = tmp_path / "user_data" / "research_candidates.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text("{broken", encoding="utf-8")
|
||||||
|
store = CandidateStore(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(CandidateStoreError, match="损坏"):
|
||||||
|
store.list()
|
||||||
|
with pytest.raises(CandidateStoreError, match="损坏"):
|
||||||
|
_create(store)
|
||||||
|
assert path.read_text(encoding="utf-8") == "{broken"
|
||||||
@@ -24,7 +24,6 @@ def _strategy(**kwargs) -> StrategyDef:
|
|||||||
trailing_take_profit_activate=None,
|
trailing_take_profit_activate=None,
|
||||||
trailing_take_profit_drawdown=None,
|
trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=None,
|
max_hold_days=None,
|
||||||
alerts=[],
|
|
||||||
filter_fn=lambda df, params: pl.lit(True),
|
filter_fn=lambda df, params: pl.lit(True),
|
||||||
filter_history_fn=None,
|
filter_history_fn=None,
|
||||||
lookback_days=1,
|
lookback_days=1,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = None
|
STOP_LOSS = None
|
||||||
MAX_HOLD_DAYS = 1
|
MAX_HOLD_DAYS = 1
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
class AlwaysEntry:
|
class AlwaysEntry:
|
||||||
def required_fields(self):
|
def required_fields(self):
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
||||||
return pl.lit(True)
|
return pl.lit(True)
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
|
||||||
|
from app.extensions.contracts import (
|
||||||
|
BACKEND_EXTENSION_API_VERSION,
|
||||||
|
NotificationFormatContext,
|
||||||
|
NotificationFormatter,
|
||||||
|
)
|
||||||
|
from app.extensions.loader import configure_backend_extensions
|
||||||
|
from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry
|
||||||
|
from app.services.quote_service import QuoteService
|
||||||
|
|
||||||
|
|
||||||
|
class PrefixFormatter(NotificationFormatter):
|
||||||
|
def __init__(self, prefix: str) -> None:
|
||||||
|
self.prefix = prefix
|
||||||
|
|
||||||
|
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
|
||||||
|
assert context.api_version == BACKEND_EXTENSION_API_VERSION
|
||||||
|
return f"{self.prefix}{event['message']}"
|
||||||
|
|
||||||
|
|
||||||
|
class BrokenFormatter(NotificationFormatter):
|
||||||
|
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
|
||||||
|
del event, context
|
||||||
|
raise RuntimeError("broken formatter")
|
||||||
|
|
||||||
|
|
||||||
|
def _registrar(extension_id: str = "company.test") -> BackendExtensionRegistrar:
|
||||||
|
return BackendExtensionRegistrar(
|
||||||
|
extension_id,
|
||||||
|
api_version=BACKEND_EXTENSION_API_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_registry_preserves_existing_notification_objects() -> None:
|
||||||
|
registry = BackendExtensionRegistry()
|
||||||
|
registry.freeze()
|
||||||
|
service = QuoteService()
|
||||||
|
service._app_state = types.SimpleNamespace(extension_registry=registry)
|
||||||
|
events = [{"message": "原始消息", "source": "strategy"}]
|
||||||
|
|
||||||
|
result = service._format_extension_notifications(events)
|
||||||
|
|
||||||
|
assert result is events
|
||||||
|
assert result[0] is events[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_formatters_are_ordered_and_do_not_mutate_input() -> None:
|
||||||
|
registry = BackendExtensionRegistry()
|
||||||
|
registrar = _registrar()
|
||||||
|
registrar.register_notification_formatter("company.second", PrefixFormatter("B"), order=20)
|
||||||
|
registrar.register_notification_formatter("company.first", PrefixFormatter("A"), order=10)
|
||||||
|
registry.register(registrar)
|
||||||
|
registry.freeze()
|
||||||
|
service = QuoteService()
|
||||||
|
service._app_state = types.SimpleNamespace(extension_registry=registry)
|
||||||
|
events = [{"message": "原始消息", "source": "strategy"}]
|
||||||
|
|
||||||
|
result = service._format_extension_notifications(events)
|
||||||
|
|
||||||
|
assert result == [{"message": "BA原始消息", "source": "strategy"}]
|
||||||
|
assert events == [{"message": "原始消息", "source": "strategy"}]
|
||||||
|
assert result is not events
|
||||||
|
assert result[0] is not events[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_formatter_keeps_previous_message_and_later_formatters_run() -> None:
|
||||||
|
registry = BackendExtensionRegistry()
|
||||||
|
registrar = _registrar()
|
||||||
|
registrar.register_notification_formatter("company.first", PrefixFormatter("A"), order=10)
|
||||||
|
registrar.register_notification_formatter("company.broken", BrokenFormatter(), order=20)
|
||||||
|
registrar.register_notification_formatter("company.last", PrefixFormatter("B"), order=30)
|
||||||
|
registry.register(registrar)
|
||||||
|
registry.freeze()
|
||||||
|
service = QuoteService()
|
||||||
|
service._app_state = types.SimpleNamespace(extension_registry=registry)
|
||||||
|
|
||||||
|
result = service._format_extension_notifications([{"message": "原始消息"}])
|
||||||
|
|
||||||
|
assert result[0]["message"] == "BA原始消息"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_version_mismatch_without_partial_registration() -> None:
|
||||||
|
registry = BackendExtensionRegistry()
|
||||||
|
registrar = BackendExtensionRegistrar("company.future", api_version=999)
|
||||||
|
registrar.register_notification_formatter("company.future", PrefixFormatter("x"))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="requires backend API"):
|
||||||
|
registry.register(registrar)
|
||||||
|
|
||||||
|
registry.freeze()
|
||||||
|
assert not registry.has_customizations
|
||||||
|
assert not registry.has_notification_formatters
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_is_frozen_after_startup() -> None:
|
||||||
|
registry = BackendExtensionRegistry()
|
||||||
|
registry.freeze()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="frozen"):
|
||||||
|
registry.register(_registrar())
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_isolates_failed_setup_and_registers_valid_route(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
broken = types.ModuleType("app.custom.broken")
|
||||||
|
broken.EXTENSION_ID = "company.broken"
|
||||||
|
broken.EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
|
||||||
|
|
||||||
|
def broken_setup(registrar: BackendExtensionRegistrar) -> None:
|
||||||
|
registrar.register_notification_formatter("company.partial", PrefixFormatter("x"))
|
||||||
|
raise RuntimeError("setup failed")
|
||||||
|
|
||||||
|
broken.setup = broken_setup
|
||||||
|
|
||||||
|
valid = types.ModuleType("app.custom.valid")
|
||||||
|
valid.EXTENSION_ID = "company.valid"
|
||||||
|
valid.EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
|
||||||
|
|
||||||
|
def valid_setup(registrar: BackendExtensionRegistrar) -> None:
|
||||||
|
router = APIRouter(prefix="/api/custom/valid")
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def status() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
registrar.include_router(router)
|
||||||
|
|
||||||
|
valid.setup = valid_setup
|
||||||
|
modules = {broken.__name__: broken, valid.__name__: valid}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.extensions.loader._custom_module_names",
|
||||||
|
lambda: [broken.__name__, valid.__name__],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.extensions.loader.importlib.import_module",
|
||||||
|
lambda name: modules[name],
|
||||||
|
)
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
registry, errors = configure_backend_extensions(app)
|
||||||
|
|
||||||
|
assert registry.frozen
|
||||||
|
assert registry.extension_ids() == frozenset({"company.valid"})
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert errors[0].module == broken.__name__
|
||||||
|
assert any(getattr(route, "path", None) == "/api/custom/valid/status" for route in app.routes)
|
||||||
@@ -23,7 +23,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 测试规则一
|
1. 测试规则一
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 测试规则一
|
1. 测试规则一
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ def _make_strategy(
|
|||||||
trailing_take_profit_activate=None,
|
trailing_take_profit_activate=None,
|
||||||
trailing_take_profit_drawdown=None,
|
trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=10,
|
max_hold_days=10,
|
||||||
alerts=[],
|
|
||||||
filter_fn=None,
|
filter_fn=None,
|
||||||
filter_history_fn=None,
|
filter_history_fn=None,
|
||||||
lookback_days=60,
|
lookback_days=60,
|
||||||
@@ -43,6 +42,7 @@ def test_no_overrides_returns_default_signals():
|
|||||||
exit_signals=["signal_ma20_breakdown"],
|
exit_signals=["signal_ma20_breakdown"],
|
||||||
)
|
)
|
||||||
detail = _strategy_detail(s, overrides=None)
|
detail = _strategy_detail(s, overrides=None)
|
||||||
|
assert "alerts" not in detail
|
||||||
assert detail["entry_signals"] == ["signal_ma20_breakout", "signal_n_day_high"]
|
assert detail["entry_signals"] == ["signal_ma20_breakout", "signal_n_day_high"]
|
||||||
assert detail["exit_signals"] == ["signal_ma20_breakdown"]
|
assert detail["exit_signals"] == ["signal_ma20_breakdown"]
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,23 @@ def _result(
|
|||||||
pool: tuple[str, ...] = (),
|
pool: tuple[str, ...] = (),
|
||||||
buys: tuple[str, ...] = (),
|
buys: tuple[str, ...] = (),
|
||||||
sells: tuple[str, ...] = (),
|
sells: tuple[str, ...] = (),
|
||||||
|
scores: dict[str, float] | None = None,
|
||||||
) -> StrategyResult:
|
) -> StrategyResult:
|
||||||
|
scores = scores or {}
|
||||||
return StrategyResult(
|
return StrategyResult(
|
||||||
as_of=as_of,
|
as_of=as_of,
|
||||||
strategy_id="demo",
|
strategy_id="demo",
|
||||||
rows=[{"symbol": symbol, "close": 10.0, "change_pct": 0.01} for symbol in pool],
|
rows=[
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
"close": 10.0,
|
||||||
|
"change_pct": 0.01,
|
||||||
|
**({"score": scores[symbol]} if symbol in scores else {}),
|
||||||
|
}
|
||||||
|
for symbol in pool
|
||||||
|
],
|
||||||
total=len(pool),
|
total=len(pool),
|
||||||
|
scores=scores,
|
||||||
entry_signal_hits=[{"symbol": symbol, "signals": ["signal_buy"]} for symbol in buys],
|
entry_signal_hits=[{"symbol": symbol, "signals": ["signal_buy"]} for symbol in buys],
|
||||||
exit_signal_hits=[{"symbol": symbol, "signals": ["signal_sell"]} for symbol in sells],
|
exit_signal_hits=[{"symbol": symbol, "signals": ["signal_sell"]} for symbol in sells],
|
||||||
)
|
)
|
||||||
@@ -84,15 +95,75 @@ def test_strategy_rule_compatibility_and_validation(tmp_path):
|
|||||||
loaded = monitor_rules.load_one(tmp_path, "legacy")
|
loaded = monitor_rules.load_one(tmp_path, "legacy")
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded["notify_events"] == ["pool_entry", "pool_exit"]
|
assert loaded["notify_events"] == ["pool_entry", "pool_exit"]
|
||||||
|
assert loaded["score_min"] is None
|
||||||
|
assert loaded["score_max"] is None
|
||||||
assert monitor_rules.load_all(tmp_path)[0]["notify_events"] == ["pool_entry", "pool_exit"]
|
assert monitor_rules.load_all(tmp_path)[0]["notify_events"] == ["pool_entry", "pool_exit"]
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="至少选择一个通知事件"):
|
with pytest.raises(ValueError, match="至少选择一个通知事件"):
|
||||||
monitor_rules.validate(_rule())
|
monitor_rules.validate(_rule())
|
||||||
with pytest.raises(ValueError, match="非法事件"):
|
with pytest.raises(ValueError, match="非法事件"):
|
||||||
monitor_rules.validate(_rule("unknown"))
|
monitor_rules.validate(_rule("unknown"))
|
||||||
|
with pytest.raises(ValueError, match="0 到 100"):
|
||||||
|
monitor_rules.validate(_rule("pool_entry", score_min=-1))
|
||||||
|
with pytest.raises(ValueError, match="不能大于"):
|
||||||
|
monitor_rules.validate(_rule("pool_entry", score_min=90, score_max=70))
|
||||||
monitor_rules.validate(_rule("buy_signal", "pool_exit"))
|
monitor_rules.validate(_rule("buy_signal", "pool_exit"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_score_range_filters_pool_and_buy_signals_but_not_sell_signals():
|
||||||
|
day = date(2026, 7, 24)
|
||||||
|
engine = MonitorRuleEngine()
|
||||||
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
||||||
|
_result(
|
||||||
|
day,
|
||||||
|
pool=("A", "B", "D"),
|
||||||
|
buys=("A", "B", "D"),
|
||||||
|
scores={"A": 69, "B": 80},
|
||||||
|
),
|
||||||
|
_result(
|
||||||
|
day,
|
||||||
|
pool=("A", "B", "C", "D"),
|
||||||
|
buys=("A", "B", "C", "D"),
|
||||||
|
sells=("B",),
|
||||||
|
scores={"A": 70, "B": 91, "C": 90},
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
engine.set_rules([_rule(
|
||||||
|
"buy_signal", "sell_signal", "pool_entry", "pool_exit",
|
||||||
|
score_min=70,
|
||||||
|
score_max=90,
|
||||||
|
)])
|
||||||
|
|
||||||
|
with patch("app.strategy.monitor.time.time", side_effect=[100, 101]):
|
||||||
|
assert engine.evaluate(_quotes()) == []
|
||||||
|
events = engine.evaluate(_quotes())
|
||||||
|
|
||||||
|
assert {(event["type"], event["symbol"]) for event in events} == {
|
||||||
|
("buy_signal", "A"),
|
||||||
|
("buy_signal", "C"),
|
||||||
|
("sell_signal", "B"),
|
||||||
|
("pool_entry", "A"),
|
||||||
|
("pool_entry", "C"),
|
||||||
|
("pool_exit", "B"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_score_range_edit_resets_pool_baseline():
|
||||||
|
day = date(2026, 7, 24)
|
||||||
|
engine = MonitorRuleEngine()
|
||||||
|
engine.set_strategy_engine(_SequenceStrategyEngine([
|
||||||
|
_result(day, pool=("A",), scores={"A": 80}),
|
||||||
|
_result(day, pool=("A",), scores={"A": 80}),
|
||||||
|
]))
|
||||||
|
rule = _rule("pool_exit", score_min=70)
|
||||||
|
engine.set_rules([rule])
|
||||||
|
assert engine.evaluate(_quotes()) == []
|
||||||
|
|
||||||
|
engine.set_rules([{**rule, "score_min": 90}])
|
||||||
|
|
||||||
|
assert engine.evaluate(_quotes()) == []
|
||||||
|
|
||||||
|
|
||||||
def test_strategy_events_baseline_dedupe_and_next_day_replay():
|
def test_strategy_events_baseline_dedupe_and_next_day_replay():
|
||||||
day1 = date(2026, 7, 24)
|
day1 = date(2026, 7, 24)
|
||||||
day2 = date(2026, 7, 25)
|
day2 = date(2026, 7, 25)
|
||||||
@@ -223,7 +294,6 @@ def test_matrix_strategy_pool_masks_rows_and_both_signal_directions():
|
|||||||
trailing_take_profit_activate=None,
|
trailing_take_profit_activate=None,
|
||||||
trailing_take_profit_drawdown=None,
|
trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=None,
|
max_hold_days=None,
|
||||||
alerts=[],
|
|
||||||
filter_fn=None,
|
filter_fn=None,
|
||||||
filter_history_fn=None,
|
filter_history_fn=None,
|
||||||
lookback_days=1,
|
lookback_days=1,
|
||||||
@@ -270,7 +340,6 @@ def test_ordinary_strategy_uses_signal_overrides_and_ignores_malformed_values():
|
|||||||
trailing_take_profit_activate=None,
|
trailing_take_profit_activate=None,
|
||||||
trailing_take_profit_drawdown=None,
|
trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=None,
|
max_hold_days=None,
|
||||||
alerts=[],
|
|
||||||
filter_fn=None,
|
filter_fn=None,
|
||||||
filter_history_fn=None,
|
filter_history_fn=None,
|
||||||
lookback_days=1,
|
lookback_days=1,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def _make_strategy_with_params(params) -> StrategyDef:
|
|||||||
entry_signals=[], exit_signals=[],
|
entry_signals=[], exit_signals=[],
|
||||||
stop_loss=None, trailing_stop=None,
|
stop_loss=None, trailing_stop=None,
|
||||||
trailing_take_profit_activate=None, trailing_take_profit_drawdown=None,
|
trailing_take_profit_activate=None, trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=None, alerts=[],
|
max_hold_days=None,
|
||||||
filter_fn=None, filter_history_fn=None,
|
filter_fn=None, filter_history_fn=None,
|
||||||
lookback_days=60, source="custom",
|
lookback_days=60, source="custom",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,3 +118,17 @@ def test_builtin_custom_and_ai_files_share_one_registry_and_run_path(tmp_path):
|
|||||||
results = engine.run_all(context, overrides_map=overrides)
|
results = engine.run_all(context, overrides_map=overrides)
|
||||||
assert set(results) == set(strategy_ids.values())
|
assert set(results) == set(strategy_ids.values())
|
||||||
assert all(result.total == 1 for result in results.values())
|
assert all(result.total == 1 for result in results.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_alerts_global_is_ignored(tmp_path):
|
||||||
|
path = tmp_path / "legacy_alerts.py"
|
||||||
|
path.write_text(
|
||||||
|
_strategy_code("legacy_alerts")
|
||||||
|
+ '\nALERTS = [{"field": "rsi_14", "op": "<", "value": 25}]\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = StrategyEngine(strategy_dirs=[tmp_path])
|
||||||
|
|
||||||
|
assert engine.has("legacy_alerts")
|
||||||
|
assert not hasattr(engine.get("legacy_alerts"), "alerts")
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api import strategy as strategy_api
|
||||||
|
from app.strategy import config as strategy_config
|
||||||
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
|
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +23,6 @@ def _make_engine() -> tuple[StrategyEngine, StrategyDataContext]:
|
|||||||
trailing_take_profit_activate=None,
|
trailing_take_profit_activate=None,
|
||||||
trailing_take_profit_drawdown=None,
|
trailing_take_profit_drawdown=None,
|
||||||
max_hold_days=None,
|
max_hold_days=None,
|
||||||
alerts=[],
|
|
||||||
filter_fn=lambda _df, params: pl.col("value") >= params.get("min_value", 1),
|
filter_fn=lambda _df, params: pl.col("value") >= params.get("min_value", 1),
|
||||||
filter_history_fn=None,
|
filter_history_fn=None,
|
||||||
lookback_days=1,
|
lookback_days=1,
|
||||||
@@ -53,3 +57,44 @@ def test_explicit_params_override_saved_strategy_params():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert [row["symbol"] for row in result.rows] == ["C"]
|
assert [row["symbol"] for row in result.rows] == ["C"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_config_preserves_other_user_overrides(tmp_path):
|
||||||
|
engine, _ = _make_engine()
|
||||||
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
|
||||||
|
strategy_engine=engine,
|
||||||
|
repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)),
|
||||||
|
)))
|
||||||
|
strategy_config.save_override(tmp_path, "saved_params", {
|
||||||
|
"params": {"min_value": 2},
|
||||||
|
"stop_loss": -0.05,
|
||||||
|
})
|
||||||
|
|
||||||
|
strategy_api.patch_config(strategy_api.SaveConfigRequest(
|
||||||
|
strategy_id="saved_params",
|
||||||
|
overrides={
|
||||||
|
"scoring": {"rsi_14": 1.0},
|
||||||
|
"scoring_directions": {"rsi_14": "low"},
|
||||||
|
"scoring_replace": True,
|
||||||
|
},
|
||||||
|
), request)
|
||||||
|
|
||||||
|
saved = strategy_config.load_override(tmp_path, "saved_params")
|
||||||
|
assert saved["params"] == {"min_value": 2}
|
||||||
|
assert saved["stop_loss"] == -0.05
|
||||||
|
assert saved["scoring"] == {"rsi_14": 1.0}
|
||||||
|
assert saved["scoring_directions"] == {"rsi_14": "low"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_config_rejects_invalid_scoring_direction(tmp_path):
|
||||||
|
engine, _ = _make_engine()
|
||||||
|
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
|
||||||
|
strategy_engine=engine,
|
||||||
|
repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)),
|
||||||
|
)))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException, match="方向无效"):
|
||||||
|
strategy_api.save_config(strategy_api.SaveConfigRequest(
|
||||||
|
strategy_id="saved_params",
|
||||||
|
overrides={"scoring_directions": {"rsi_14": "sideways"}},
|
||||||
|
), request)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from app.backtest.strategy import StrategyBacktestService
|
from app.backtest.strategy import StrategyBacktestService
|
||||||
from app.strategy.engine import StrategyEngine
|
from app.strategy.engine import StrategyEngine
|
||||||
|
from app.strategy.scoring import effective_scoring
|
||||||
|
|
||||||
|
|
||||||
def _candidates() -> pl.DataFrame:
|
def _candidates() -> pl.DataFrame:
|
||||||
@@ -37,3 +38,75 @@ def test_scoring_reweights_only_available_fields():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert scored["score"].to_list() == pytest.approx([100.0, 0.0])
|
assert scored["score"].to_list() == pytest.approx([100.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoring_can_prefer_lower_factor_values():
|
||||||
|
scored = StrategyEngine._apply_scoring(
|
||||||
|
_candidates(),
|
||||||
|
{"ma20_bias": 0.6, "vol_ratio_5d": 0.4},
|
||||||
|
{"ma20_bias": "low"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scored["score"].to_list() == pytest.approx([100.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_scoring_uses_saved_direction_and_replacement():
|
||||||
|
strategy = SimpleNamespace(meta={
|
||||||
|
"scoring": {"ma20_bias": 1.0},
|
||||||
|
"order_by": "score",
|
||||||
|
"descending": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
scored = StrategyBacktestService._apply_score(
|
||||||
|
_candidates(),
|
||||||
|
strategy,
|
||||||
|
{
|
||||||
|
"scoring": {"vol_ratio_5d": 1.0},
|
||||||
|
"scoring_directions": {"vol_ratio_5d": "low"},
|
||||||
|
"scoring_replace": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scored["score"].to_list() == pytest.approx([0.0, 100.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_realtime_scoring_materializes_rolling_factor_from_history():
|
||||||
|
start = date(2024, 1, 1)
|
||||||
|
history = pl.DataFrame({
|
||||||
|
"symbol": [symbol for offset in range(11) for symbol in ("A", "B")],
|
||||||
|
"date": [start + timedelta(days=offset) for offset in range(11) for _ in range(2)],
|
||||||
|
"volume": [
|
||||||
|
20.0 if symbol == "A" and offset == 10 else 10.0
|
||||||
|
for offset in range(11)
|
||||||
|
for symbol in ("A", "B")
|
||||||
|
],
|
||||||
|
})
|
||||||
|
current = history.filter(pl.col("date") == start + timedelta(days=10))
|
||||||
|
|
||||||
|
scored_current, scored_history = StrategyEngine._materialize_scoring_frames(
|
||||||
|
current,
|
||||||
|
history,
|
||||||
|
{"vol_ratio_10d": 1.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scored_current is not None
|
||||||
|
assert scored_history is not None
|
||||||
|
assert scored_current.sort("symbol")["vol_ratio_10d"].to_list() == pytest.approx([2.0, 1.0])
|
||||||
|
assert scored_history["vol_ratio_10d"].drop_nulls().len() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_scoring_keeps_legacy_merge_and_supports_full_replace():
|
||||||
|
defaults = {"momentum_20d": 0.6, "vol_ratio_5d": 0.4}
|
||||||
|
|
||||||
|
assert effective_scoring(defaults, {"scoring": {"vol_ratio_5d": 0.8}}) == {
|
||||||
|
"momentum_20d": 0.6,
|
||||||
|
"vol_ratio_5d": 0.8,
|
||||||
|
}
|
||||||
|
assert effective_scoring(defaults, {
|
||||||
|
"scoring": {"rsi_14": 1.0},
|
||||||
|
"scoring_replace": True,
|
||||||
|
}) == {"rsi_14": 1.0}
|
||||||
|
assert effective_scoring(defaults, {
|
||||||
|
"scoring": {},
|
||||||
|
"scoring_replace": True,
|
||||||
|
}) == {}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ if (-not (Test-Path (Join-Path $BackendDir '.venv')) -or $BackendExtraArgs.Count
|
|||||||
Log-Info 'first run - installing Python deps (1-2 min)...'
|
Log-Info 'first run - installing Python deps (1-2 min)...'
|
||||||
}
|
}
|
||||||
Push-Location $BackendDir
|
Push-Location $BackendDir
|
||||||
try { & uv sync @BackendExtraArgs } finally { Pop-Location }
|
try { & uv sync --frozen @BackendExtraArgs } finally { Pop-Location }
|
||||||
if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 }
|
||||||
Log-Ok 'backend deps installed'
|
Log-Ok 'backend deps installed'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ if [ ! -d "$BACKEND_DIR/.venv" ] || [ "${#BACKEND_EXTRA_ARGS[@]}" -gt 0 ]; then
|
|||||||
else
|
else
|
||||||
info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..."
|
info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..."
|
||||||
fi
|
fi
|
||||||
( cd "$BACKEND_DIR" && uv sync "${BACKEND_EXTRA_ARGS[@]}" )
|
( cd "$BACKEND_DIR" && uv sync --frozen "${BACKEND_EXTRA_ARGS[@]}" )
|
||||||
ok "后端依赖装好了"
|
ok "后端依赖装好了"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ echo
|
|||||||
|
|
||||||
(
|
(
|
||||||
cd "$BACKEND_DIR"
|
cd "$BACKEND_DIR"
|
||||||
uv run uvicorn app.main:app "${UVICORN_ENV_ARGS[@]}" --reload \
|
uv run --frozen uvicorn app.main:app "${UVICORN_ENV_ARGS[@]}" --reload \
|
||||||
--host "$BACKEND_HOST" --port "$BACKEND_PORT" 2>&1 \
|
--host "$BACKEND_HOST" --port "$BACKEND_PORT" 2>&1 \
|
||||||
| prefix_awk "$(printf "${BLUE}[backend ]${NC} ")"
|
| prefix_awk "$(printf "${BLUE}[backend ]${NC} ")"
|
||||||
) &
|
) &
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# 代码二次开发与 AI 扩展指南
|
||||||
|
|
||||||
|
本文面向需要在当前仓库中二次开发的维护者、团队和 AI 编码代理。目标不是禁止修改源码,而是让新增页面、业务规则和定制逻辑尽量通过稳定边界接入,使后续合并上游版本时冲突更少、风险可验证。
|
||||||
|
|
||||||
|
修改任何代码前,仍须先完整阅读根目录的 [`CONTRIBUTING.md`](../CONTRIBUTING.md)。金融数据口径、缓存、并发、数据源和测试要求以该文档为准。
|
||||||
|
|
||||||
|
## 1. 文档状态
|
||||||
|
|
||||||
|
本文同时描述现有能力和后续按需建设的代码扩展契约。两者不能混用:
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已可用 | 当前仓库中已经存在,可以在确认调用链后直接复用 |
|
||||||
|
| 按需扩展 | 尚未实现;出现真实用例后才能增加,不能提前假设 API 存在 |
|
||||||
|
|
||||||
|
当前已可用的主要扩展能力:
|
||||||
|
|
||||||
|
- 自定义、AI 和叠加策略目录:`data/strategies/`。
|
||||||
|
- 数据源 Provider 与 `plugin.yaml` 机制:详见 [`plugin-development.md`](plugin-development.md)。
|
||||||
|
- 扩展数据与声明式分析页面:适合不需要自定义 React 交互的页面。
|
||||||
|
- 前端源码扩展注册:`frontend/src/custom/<namespace>/extension.tsx`,支持静态页面、导航和已开放插槽。
|
||||||
|
- 后端源码扩展注册:`backend/app/custom/<module>.py`,支持 FastAPI 路由、启动钩子和通知格式化器。
|
||||||
|
- 当前前端插槽:`layout.navigation.extra`。
|
||||||
|
- 当前后端继承点:`NotificationFormatter`。
|
||||||
|
|
||||||
|
尚未实现、只能在真实需求出现后增加的能力:
|
||||||
|
|
||||||
|
- 更多页面局部插槽。
|
||||||
|
- 候选过滤、评分、仓位、风控和回测成本等后端业务策略接口。
|
||||||
|
- 配置 schema 迁移注册表。
|
||||||
|
|
||||||
|
AI 在开始任务前必须通过代码搜索确认能力是否已经实现。找不到定义和测试时,应把示例视为设计规范,不得虚构导入路径或调用结果。
|
||||||
|
|
||||||
|
## 2. 二次开发分级
|
||||||
|
|
||||||
|
按升级风险从低到高选择实现方式:
|
||||||
|
|
||||||
|
| 级别 | 实现方式 | 适用场景 | 升级风险 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| L1 | 配置、策略文件、扩展数据 | 已有契约能够完成需求 | 最低 |
|
||||||
|
| L2 | 前端插槽、路由注册;后端策略接口、注册替换 | 新页面、局部 UI、可替换业务规则 | 较低 |
|
||||||
|
| L3 | 直接修改核心源码 | 核心流程本身必须变化,现有扩展点无法表达 | 最高 |
|
||||||
|
|
||||||
|
选择原则:
|
||||||
|
|
||||||
|
1. 先确认现有功能能否复用,禁止平行实现第二套数据、策略、缓存或请求逻辑。
|
||||||
|
2. 只在存在真实二开需求的位置增加扩展点,不为未来可能出现的需求预埋通用框架。
|
||||||
|
3. 插槽或接口无法表达核心行为变化时,可以修改源码;必须缩小改动范围并补回归测试。
|
||||||
|
4. 不为了避开一次冲突复制完整页面、服务或引擎。复制会把一次显式冲突变成长期的隐式分叉。
|
||||||
|
|
||||||
|
## 3. 前端扩展规范
|
||||||
|
|
||||||
|
### 3.1 何时使用插槽
|
||||||
|
|
||||||
|
插槽适合在既有页面中增加局部内容:
|
||||||
|
|
||||||
|
- 操作按钮或工具栏命令。
|
||||||
|
- 筛选条件或表单字段。
|
||||||
|
- 表格列和详情面板。
|
||||||
|
- 个股详情、策略详情中的附加标签页。
|
||||||
|
- 设置页面中的独立配置区。
|
||||||
|
|
||||||
|
新增完整页面时应使用路由和导航注册,不要把整页塞入某个插槽。改变核心页面的数据流、状态模型或主要布局时,应直接修改核心代码并按 L3 管理。
|
||||||
|
|
||||||
|
### 3.2 当前插槽契约
|
||||||
|
|
||||||
|
核心页面通过已经实现的 `ExtensionSlot` 提供受控上下文:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ExtensionSlot
|
||||||
|
name="strategy.monitor.filters"
|
||||||
|
context={{
|
||||||
|
apiVersion: 1,
|
||||||
|
rule: draft,
|
||||||
|
updateRule,
|
||||||
|
readOnly,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
二开模块通过默认导出的注册清单接入,不修改核心文件:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { FrontendExtension } from '@/extensions/types'
|
||||||
|
|
||||||
|
function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string }) {
|
||||||
|
return collapsed ? null : <div>二开内容</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension: FrontendExtension = {
|
||||||
|
id: 'company.navigation',
|
||||||
|
apiVersion: 1,
|
||||||
|
slots: [{
|
||||||
|
name: 'layout.navigation.extra',
|
||||||
|
id: 'company-summary',
|
||||||
|
order: 100,
|
||||||
|
component: NavigationExtra,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default extension
|
||||||
|
```
|
||||||
|
|
||||||
|
插槽设计必须满足:
|
||||||
|
|
||||||
|
- `name` 和注册项 `id` 全局稳定、唯一。
|
||||||
|
- `context` 使用明确的 TypeScript 类型,并包含契约版本。
|
||||||
|
- 只暴露完成该插槽职责所需的数据和操作,不传递整个页面状态。
|
||||||
|
- 插槽通过公开回调修改状态,不直接访问父组件内部 store 或缓存。
|
||||||
|
- 单个扩展渲染失败应由错误边界隔离,并显示可定位的扩展 ID。
|
||||||
|
- 注册顺序确定,使用 `order` 后再按 `id` 排序,避免加载顺序导致界面漂移。
|
||||||
|
- 插槽内容必须遵守项目现有设计系统、响应式和可访问性要求。
|
||||||
|
|
||||||
|
当前只开放:
|
||||||
|
|
||||||
|
```text
|
||||||
|
layout.navigation.extra
|
||||||
|
```
|
||||||
|
|
||||||
|
新增插槽前必须有真实用例,并同时定义 context 类型、异常隔离和测试;不能只在类型表中预留名字。
|
||||||
|
|
||||||
|
### 3.3 当前路由与导航契约
|
||||||
|
|
||||||
|
完整页面通过注册表接入:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const extension: FrontendExtension = {
|
||||||
|
id: 'company.risk',
|
||||||
|
apiVersion: 1,
|
||||||
|
routes: [
|
||||||
|
{ id: 'company-risk', path: '/company/risk', component: CompanyRiskPage },
|
||||||
|
],
|
||||||
|
navigation: [
|
||||||
|
{
|
||||||
|
id: 'company-risk',
|
||||||
|
routeId: 'company-risk',
|
||||||
|
label: '风险分析',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
order: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
路由注册与菜单注册已经解耦:页面可以存在但不显示在菜单中;菜单只能引用同一扩展内的已注册路由。首版只支持静态绝对路径。扩展路由不得覆盖核心或其他扩展路径,冲突时只禁用该扩展并输出明确错误。
|
||||||
|
|
||||||
|
完整前端模板位于 [`frontend/src/custom/_template/extension.tsx.example`](../frontend/src/custom/_template/extension.tsx.example)。
|
||||||
|
|
||||||
|
### 3.4 前端禁止事项
|
||||||
|
|
||||||
|
- 不直接在多个组件中拼接后端 URL,统一使用 `frontend/src/lib/api.ts` 或未来公开客户端。
|
||||||
|
- 不自行创建与现有 TanStack Query 重复的缓存;查询键仍由 `queryKeys.ts` 集中管理。
|
||||||
|
- 不用插槽绕过权限、数据口径或表单校验。
|
||||||
|
- 不通过 DOM 查询、全局事件或 monkey patch 修改核心组件。
|
||||||
|
- 不把整个核心页面复制到二开目录后长期独立维护。
|
||||||
|
|
||||||
|
## 4. 后端扩展规范
|
||||||
|
|
||||||
|
### 4.1 使用小粒度继承
|
||||||
|
|
||||||
|
后端允许二开类继承稳定、职责单一的抽象基类,再通过注册表或依赖注入接入。不要继承并覆盖大型编排服务。
|
||||||
|
|
||||||
|
当前已经实现的继承点:
|
||||||
|
|
||||||
|
- `NotificationFormatter`:在监控规则完成评估后统一调整通知文案,不改变事件结构和触发语义。
|
||||||
|
|
||||||
|
下列是可能适合的小粒度接口,但目前没有实现,不能直接导入:
|
||||||
|
|
||||||
|
- `CandidateFilter`:候选池过滤。
|
||||||
|
- `ScoringPolicy`:评分计算。
|
||||||
|
- `PositionSizingPolicy`:仓位计算。
|
||||||
|
- `RiskPolicy`:风险约束。
|
||||||
|
- `NotificationFormatter`:通知文案。
|
||||||
|
- `StrategyProvider`:策略发现。
|
||||||
|
- `MonitorConditionEvaluator`:自定义监控条件。
|
||||||
|
- `BacktestCostModel`:手续费和滑点模型。
|
||||||
|
|
||||||
|
不适合作为公共继承点的核心类:
|
||||||
|
|
||||||
|
- `StrategyEngine`。
|
||||||
|
- `BacktestEngine`。
|
||||||
|
- `ScreenerService`。
|
||||||
|
- `StrategyMonitorService`。
|
||||||
|
- `DataStore` 和仓库实现。
|
||||||
|
- FastAPI 主应用及生命周期函数。
|
||||||
|
|
||||||
|
这些类管理流程、缓存、并发或生命周期。覆盖其中的内部方法会让上游调整执行顺序后产生难以发现的语义错误。
|
||||||
|
|
||||||
|
### 4.2 当前基类与注册契约
|
||||||
|
|
||||||
|
后端模块从 `app.extensions` 导入稳定契约:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.extensions import (
|
||||||
|
BACKEND_EXTENSION_API_VERSION,
|
||||||
|
BackendExtensionRegistrar,
|
||||||
|
NotificationFormatContext,
|
||||||
|
NotificationFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXTENSION_ID = 'company.notice'
|
||||||
|
EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyNotificationFormatter(NotificationFormatter):
|
||||||
|
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
|
||||||
|
return f"[公司规则] {event.get('message', '')}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def setup(registrar: BackendExtensionRegistrar) -> None:
|
||||||
|
registrar.register_notification_formatter(
|
||||||
|
'company.notification',
|
||||||
|
CompanyNotificationFormatter(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
同一个 `setup` 可以通过 `registrar.include_router(router)` 注册独立 FastAPI 路由。核心路由冲突、重复 ID 或契约版本不匹配时,该扩展整体不注册,不留下半注册状态。
|
||||||
|
|
||||||
|
核心数据层初始化完成后,可选的 `startup(context: ExtensionContext)` 会收到数据目录和只读仓库协议。启动钩子失败只记录错误,不阻止主程序启动。
|
||||||
|
|
||||||
|
完整后端模板位于 [`backend/app/custom/_template.py.example`](../backend/app/custom/_template.py.example)。
|
||||||
|
|
||||||
|
### 4.3 后端契约要求
|
||||||
|
|
||||||
|
- 上下文优先使用不可变 `dataclass` 或 `Protocol`,不向扩展暴露整个 `app.state`。
|
||||||
|
- 抽象方法参数、返回值、单位、空值和异常行为必须有文档及契约测试。
|
||||||
|
- 注册 ID 全局唯一;重复注册默认拒绝,不允许静默覆盖官方实现。
|
||||||
|
- 默认实现必须存在。没有启用二开实现时,核心行为与当前版本一致。
|
||||||
|
- 单个可选实现加载失败时应禁用自身;金融结果无法可靠计算时必须 fail-closed。
|
||||||
|
- 注册表在启动完成后冻结,实时线程中不得动态替换实现。
|
||||||
|
- 破坏性契约变化必须提升 `api_version`,旧版本至少保留一个大版本的兼容期。
|
||||||
|
|
||||||
|
### 4.4 继承与组合的边界
|
||||||
|
|
||||||
|
继承只用于表达稳定的“是一种策略实现”关系。需要同时组合过滤、评分、通知等能力时,分别注册多个小实现,不创建拥有大量可选方法的万能基类。
|
||||||
|
|
||||||
|
优先组合的场景:
|
||||||
|
|
||||||
|
- 一个服务需要多个独立规则。
|
||||||
|
- 行为需要按资产类型或运行上下文选择。
|
||||||
|
- 扩展只需要装饰默认结果,而不是完全替换算法。
|
||||||
|
- 依赖缓存、仓库或通知服务,需要通过明确构造参数注入。
|
||||||
|
|
||||||
|
## 5. 直接修改源码
|
||||||
|
|
||||||
|
扩展点不是限制。核心流程必须变化时允许直接修改源码,但要把升级成本显式管理。
|
||||||
|
|
||||||
|
### 5.1 修改要求
|
||||||
|
|
||||||
|
- 一个提交只包含一个二开目的,不混入格式化、依赖升级和无关重构。
|
||||||
|
- 优先新增独立模块,再对核心入口做最小接线。
|
||||||
|
- 修改公共契约时同时更新后端模型、前端类型、调用方和测试。
|
||||||
|
- 修改数据写入时列出持久化、内存缓存、版本、SSE 和前端查询失效链路。
|
||||||
|
- 保留旧配置和旧数据读取能力;必须迁移时提供幂等迁移和回滚说明。
|
||||||
|
- 在 PR 描述中标记被修改的核心热点及未来合并上游时的复核点。
|
||||||
|
|
||||||
|
### 5.2 高冲突热点
|
||||||
|
|
||||||
|
以下文件集中管理启动、路由或公共契约,直接修改时需要重点复核:
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/app/main.py
|
||||||
|
backend/app/strategy/engine.py
|
||||||
|
backend/app/backtest/engine.py
|
||||||
|
frontend/src/router.tsx
|
||||||
|
frontend/src/components/Layout.tsx
|
||||||
|
frontend/src/lib/api.ts
|
||||||
|
frontend/src/lib/queryKeys.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
高冲突不代表禁止修改,而是要求改动更小、测试更完整。若多个二开需求反复修改同一热点,应把共同接线能力提升为正式插槽或策略接口。
|
||||||
|
|
||||||
|
## 6. AI 开发工作流
|
||||||
|
|
||||||
|
AI 必须按以下顺序工作:
|
||||||
|
|
||||||
|
1. 完整阅读 `AGENTS.md`、`CONTRIBUTING.md` 和本文。
|
||||||
|
2. 检查 `git status`,保留工作区已有修改。
|
||||||
|
3. 搜索目标调用链、相邻实现、现有扩展点和测试。
|
||||||
|
4. 明确需求属于 L1、L2 还是 L3,并说明选择依据。
|
||||||
|
5. 判断目标插槽或后端基类是否真实存在,不依据本文示例虚构代码。
|
||||||
|
6. 写出最小改动计划和完成标准。
|
||||||
|
7. 先补能证明行为的测试,再实施必要改动。
|
||||||
|
8. 执行对应验证矩阵,检查最终 diff 和兼容性。
|
||||||
|
|
||||||
|
### 6.1 可直接使用的任务模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
请在 Tick Stock Panel 当前仓库中实现:[具体需求]。
|
||||||
|
|
||||||
|
开始前完整阅读 AGENTS.md、CONTRIBUTING.md 和
|
||||||
|
docs/secondary-development.md,并先检查 git status、真实调用链和现有测试。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1. 先判断现有功能能否复用,并将方案归类为 L1/L2/L3。
|
||||||
|
2. 前端优先使用已经存在的受控插槽、路由或导航注册;后端优先使用已经存在的
|
||||||
|
小粒度抽象基类和注册机制。必须用搜索和测试证明接口真实存在,不能根据设计文档
|
||||||
|
虚构 API。
|
||||||
|
3. 若扩展点尚未实现,先说明最小可行方案;只有该需求确实需要时才新增扩展点。
|
||||||
|
4. 允许直接修改源码,但保持改动最小,不复制完整页面或核心服务。
|
||||||
|
5. 复用现有 API、数据仓库、缓存、查询键、组件和领域口径,不创建第二套逻辑。
|
||||||
|
6. 保持历史配置和数据兼容,扩展失败不能破坏未启用扩展的主流程。
|
||||||
|
7. 不覆盖已有修改,不提交、不推送,除非我单独确认。
|
||||||
|
|
||||||
|
完成后请列出:
|
||||||
|
- 方案分级及原因;
|
||||||
|
- 修改文件和关键契约;
|
||||||
|
- 对缓存、数据、API 和升级兼容性的影响;
|
||||||
|
- 实际执行的测试、构建和结果;
|
||||||
|
- 仍需人工确认的风险。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 让 AI 设计扩展点的模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
请只设计并评审以下二次开发需求的扩展边界,暂不修改代码:[具体需求]。
|
||||||
|
|
||||||
|
请基于当前仓库真实调用链回答:
|
||||||
|
1. 现有能力是否已经可以实现;
|
||||||
|
2. 前端应使用局部插槽、路由注册还是直接改源码;
|
||||||
|
3. 后端应使用哪个小粒度策略接口,为什么不继承大型核心服务;
|
||||||
|
4. 最小 context/Protocol 应包含哪些字段;
|
||||||
|
5. 默认实现、失败隔离、契约版本和测试如何设计;
|
||||||
|
6. 哪些抽象属于当前不需要的过度设计。
|
||||||
|
|
||||||
|
不要假设本文中的目标 API 已经实现,请给出代码证据和文件位置。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 验证矩阵
|
||||||
|
|
||||||
|
除 `CONTRIBUTING.md` 的通用要求外,二开还应按接入方式验证:
|
||||||
|
|
||||||
|
| 改动 | 最低验证 |
|
||||||
|
| --- | --- |
|
||||||
|
| 前端插槽 | 无注册、单注册、多注册排序、异常隔离、窄屏、前端构建 |
|
||||||
|
| 路由/导航注册 | 路径冲突、隐藏页面、无权限、直接刷新、未知路由 |
|
||||||
|
| 后端策略实现 | 默认实现、二开实现、重复 ID、版本不兼容、加载失败隔离 |
|
||||||
|
| 配置或契约变化 | 旧字段缺失、未知字段、更高版本拒写、迁移幂等 |
|
||||||
|
| 直接修改核心 | 受影响模块完整回归、缓存失效、历史配置、前后端联调 |
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv run --frozen pytest tests/path/to/test_x.py -q
|
||||||
|
uv run --frozen ruff check app/path.py tests/path.py
|
||||||
|
|
||||||
|
cd ../frontend
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
git diff --check
|
||||||
|
git status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
不得把“扩展已加载”当作业务验证。测试必须断言真实过滤结果、评分、路由输出、界面状态或失败隔离行为。
|
||||||
|
|
||||||
|
## 8. 版本与升级约定
|
||||||
|
|
||||||
|
- 二开分支应记录开始开发时的上游 Git Tag 或 commit,不能只写“基于 v0.x”。
|
||||||
|
- 正式发布使用不可变 Tag;二开升级优先合并 Tag,而不是持续变化的开发分支头。
|
||||||
|
- 公共插槽和后端扩展接口使用独立的 `api_version`,不要直接等同应用版本。
|
||||||
|
- 同一 `api_version` 内只做向后兼容的字段新增;删除、改名或改变语义必须提升主版本。
|
||||||
|
- 废弃字段先标记并保留兼容读取,至少跨一个大版本后再移除。
|
||||||
|
- 升级后必须重新运行二开契约测试,不能只依赖 Git 显示“无冲突”。
|
||||||
|
|
||||||
|
直接修改核心源码的二开分支可在升级前运行只读预检:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/upgrade_check.py <目标Tag或分支>
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本不会执行 merge、修改索引或工作区。它会报告共同基线、双方修改的同一文件以及 Git 三方预演可识别的文本冲突。未提交内容不会进入预演,因此正式评估前应先提交到临时二开分支。
|
||||||
|
|
||||||
|
## 9. 完成检查表
|
||||||
|
|
||||||
|
- [ ] 已确认需求属于 L1、L2 或 L3。
|
||||||
|
- [ ] 已证明使用的插槽、基类和注册 API 在当前代码中真实存在。
|
||||||
|
- [ ] 没有复制已有数据读取、缓存、API 客户端或完整核心页面。
|
||||||
|
- [ ] 前端扩展只获得必要 context,后端没有继承大型编排服务。
|
||||||
|
- [ ] 默认实现和未启用二开时的行为保持不变。
|
||||||
|
- [ ] 重复注册、加载失败、版本不兼容和空数据均有明确行为。
|
||||||
|
- [ ] 历史配置、策略和用户数据仍可读取。
|
||||||
|
- [ ] 已执行适用的测试、构建、Ruff 和 `git diff --check`。
|
||||||
|
- [ ] 最终说明包含升级风险和未来合并上游时的复核点。
|
||||||
|
|
||||||
|
## 10. 后续扩展原则
|
||||||
|
|
||||||
|
统一注册基础设施已经完成。后续只按真实业务需求增加能力:
|
||||||
|
|
||||||
|
1. 页面需要局部定制时,在真实位置增加一个类型化插槽及测试。
|
||||||
|
2. 后端业务规则需要替换时,从该调用链提取一个小粒度接口、默认实现和契约测试。
|
||||||
|
3. 不继承大型编排服务,不暴露整个 `app.state`,不复制核心流程。
|
||||||
|
4. 只有出现需要持久化的二开配置后,再增加 schema 迁移注册表。
|
||||||
|
5. 需要升级直接修改源码的分支时,使用 `scripts/upgrade_check.py` 预检,再执行真实合并和回归。
|
||||||
|
|
||||||
|
这种顺序遵循 KISS 和 YAGNI:先解决已经存在的升级冲突,不提前建设完整插件平台,也不阻止开发者在必要时直接修改源码。
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="theme-color" content="#8B5CF6" />
|
<meta name="theme-color" content="#8B5CF6" />
|
||||||
<title>TickFlow Stock Panel · Quant Terminal</title>
|
<title>Tick Stock Panel · Quant Terminal</title>
|
||||||
<link rel="preconnect" href="https://rsms.me/" />
|
<link rel="preconnect" href="https://rsms.me/" />
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import { cn } from '@/lib/cn'
|
|||||||
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
||||||
import { toggleTheme, useTheme } from '@/lib/theme'
|
import { toggleTheme, useTheme } from '@/lib/theme'
|
||||||
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
||||||
|
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
|
||||||
|
import { getFrontendExtensionNavigation } from '@/extensions/registry'
|
||||||
|
|
||||||
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
||||||
const BRAND = '#8B5CF6'
|
const BRAND = '#8B5CF6'
|
||||||
@@ -76,7 +78,7 @@ const nav = [
|
|||||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||||
{ to: '/watchlist', label: '自选', icon: Star },
|
{ to: '/watchlist', label: '自选', icon: Star },
|
||||||
{ to: '/screener', label: '策略', icon: ScanSearch },
|
{ to: '/screener', label: '策略', icon: ScanSearch },
|
||||||
{ to: '/backtest', label: '回测', icon: History },
|
{ to: '/backtest', label: '回测', icon: History },
|
||||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||||
@@ -140,7 +142,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
|
|||||||
if (items.length === 0) return null
|
if (items.length === 0) return null
|
||||||
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
|
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
<div className="mt-2 grid grid-cols-2 gap-1.5 border-t border-border/60 pt-2">
|
||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const q = quoteBySymbol.get(item.symbol)
|
const q = quoteBySymbol.get(item.symbol)
|
||||||
const value = q?.last_price ?? q?.close
|
const value = q?.last_price ?? q?.close
|
||||||
@@ -332,8 +334,22 @@ export function Layout() {
|
|||||||
const [dismissFreeHint, setDismissFreeHint] = useState(false)
|
const [dismissFreeHint, setDismissFreeHint] = useState(false)
|
||||||
// 侧边栏收起状态 — 持久化到 localStorage
|
// 侧边栏收起状态 — 持久化到 localStorage
|
||||||
const [navCollapsed, setNavCollapsed] = useState(() => {
|
const [navCollapsed, setNavCollapsed] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches) return true
|
||||||
try { return localStorage.getItem('tf-nav-collapsed') === '1' } catch { return false }
|
try { return localStorage.getItem('tf-nav-collapsed') === '1' } catch { return false }
|
||||||
})
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
const compact = window.matchMedia('(max-width: 767px)')
|
||||||
|
const syncSidebarWithViewport = (event: MediaQueryListEvent | MediaQueryList) => {
|
||||||
|
if (event.matches) {
|
||||||
|
setNavCollapsed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { setNavCollapsed(localStorage.getItem('tf-nav-collapsed') === '1') } catch {}
|
||||||
|
}
|
||||||
|
syncSidebarWithViewport(compact)
|
||||||
|
compact.addEventListener('change', syncSidebarWithViewport)
|
||||||
|
return () => compact.removeEventListener('change', syncSidebarWithViewport)
|
||||||
|
}, [])
|
||||||
const toggleNavCollapsed = () => {
|
const toggleNavCollapsed = () => {
|
||||||
setNavCollapsed(prev => {
|
setNavCollapsed(prev => {
|
||||||
const next = !prev
|
const next = !prev
|
||||||
@@ -372,6 +388,34 @@ export function Layout() {
|
|||||||
const realtimeProviderName = realtimeProvider && realtimeProvider !== 'tickflow'
|
const realtimeProviderName = realtimeProvider && realtimeProvider !== 'tickflow'
|
||||||
? (dataSources?.custom?.find(s => s.name === realtimeProvider)?.display_name || realtimeProvider)
|
? (dataSources?.custom?.find(s => s.name === realtimeProvider)?.display_name || realtimeProvider)
|
||||||
: null
|
: null
|
||||||
|
const realtimeToggleDisabled = toggleQuote.isPending || isPaused
|
||||||
|
const realtimeActive = realtimeEnabled && isRunning && isTrading
|
||||||
|
const realtimeStatusLabel = toggleQuote.isPending
|
||||||
|
? '正在更新'
|
||||||
|
: isPaused
|
||||||
|
? '同步期间暂停'
|
||||||
|
: realtimeActive
|
||||||
|
? '运行中'
|
||||||
|
: realtimeEnabled
|
||||||
|
? (isTrading ? '正在连接' : '等待交易时段')
|
||||||
|
: '已关闭'
|
||||||
|
const realtimeStatusClass = realtimeActive
|
||||||
|
? 'text-accent'
|
||||||
|
: realtimeEnabled || isPaused
|
||||||
|
? 'text-warning/80'
|
||||||
|
: 'text-muted'
|
||||||
|
const realtimeIndicatorClass = realtimeActive
|
||||||
|
? 'bg-accent animate-pulse'
|
||||||
|
: realtimeEnabled || isPaused
|
||||||
|
? 'bg-warning/70'
|
||||||
|
: 'bg-muted'
|
||||||
|
const realtimeToggleTitle = isPaused
|
||||||
|
? '数据同步运行中,实时行情已临时暂停'
|
||||||
|
: toggleQuote.isPending
|
||||||
|
? '正在更新实时行情设置'
|
||||||
|
: realtimeEnabled
|
||||||
|
? '关闭实时行情'
|
||||||
|
: '开启实时行情'
|
||||||
|
|
||||||
// 当前主数据源 (用于侧边栏数据源状态卡)
|
// 当前主数据源 (用于侧边栏数据源状态卡)
|
||||||
const activeProvider = prefs?.daily_data_provider || 'tickflow'
|
const activeProvider = prefs?.daily_data_provider || 'tickflow'
|
||||||
@@ -399,8 +443,14 @@ export function Layout() {
|
|||||||
const analysisNav: NavItem[] = (analysisMenus?.items ?? [])
|
const analysisNav: NavItem[] = (analysisMenus?.items ?? [])
|
||||||
.filter(m => m.visible)
|
.filter(m => m.visible)
|
||||||
.map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
|
.map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 }))
|
||||||
|
const extensionNav: NavItem[] = getFrontendExtensionNavigation().map(item => ({
|
||||||
|
to: item.route.path,
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
badge: item.badge,
|
||||||
|
}))
|
||||||
|
|
||||||
const allNav: NavItem[] = [...nav, ...analysisNav]
|
const allNav: NavItem[] = [...nav, ...analysisNav, ...extensionNav]
|
||||||
const savedOrder = prefs?.nav_order ?? []
|
const savedOrder = prefs?.nav_order ?? []
|
||||||
|
|
||||||
const navItems = savedOrder.length > 0
|
const navItems = savedOrder.length > 0
|
||||||
@@ -478,13 +528,14 @@ export function Layout() {
|
|||||||
|
|
||||||
{/* 状态卡 — 收起时隐藏 */}
|
{/* 状态卡 — 收起时隐藏 */}
|
||||||
{!navCollapsed && (
|
{!navCollapsed && (
|
||||||
<div className="mt-2.5 space-y-0.5">
|
<div className="mt-2.5 border-t border-border/60 pt-1">
|
||||||
<TierBadge
|
<TierBadge
|
||||||
label={caps?.label ?? ''}
|
label={caps?.label ?? ''}
|
||||||
hasKey={settingsState?.mode !== 'none'}
|
hasKey={settingsState?.mode !== 'none'}
|
||||||
providerName={activeProviderName}
|
providerName={activeProviderName}
|
||||||
isTickflow={!isCustomActive}
|
isTickflow={!isCustomActive}
|
||||||
/>
|
/>
|
||||||
|
<div className="mx-2 border-t border-border/45" aria-hidden="true" />
|
||||||
<AIConfigBadge
|
<AIConfigBadge
|
||||||
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
|
||||||
model={settingsState?.ai_model}
|
model={settingsState?.ai_model}
|
||||||
@@ -608,6 +659,11 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<ExtensionSlot
|
||||||
|
name="layout.navigation.extra"
|
||||||
|
context={{ collapsed: navCollapsed, pathname: location.pathname }}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 全局行情开关 — 收起时只显示状态指示点 */}
|
{/* 全局行情开关 — 收起时只显示状态指示点 */}
|
||||||
@@ -615,15 +671,13 @@ export function Layout() {
|
|||||||
<div className="border-t border-border px-2 py-2.5 shrink-0 flex justify-center">
|
<div className="border-t border-border px-2 py-2.5 shrink-0 flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(!realtimeEnabled)}
|
onClick={() => handleToggle(!realtimeEnabled)}
|
||||||
disabled={toggleQuote.isPending || isPaused}
|
disabled={realtimeToggleDisabled}
|
||||||
title={realtimeEnabled ? (isRunning && isTrading ? '行情运行中 · 点击关闭' : '实时行情已开启') : '实时行情已关闭 · 点击开启'}
|
aria-label={realtimeToggleTitle}
|
||||||
className="flex items-center justify-center rounded-btn p-1.5 transition-colors hover:bg-elevated/70"
|
aria-busy={toggleQuote.isPending}
|
||||||
|
title={realtimeToggleTitle}
|
||||||
|
className="flex items-center justify-center rounded-btn p-1.5 transition-colors hover:bg-elevated/70 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-2 w-2 rounded-full ${
|
<span className={`inline-block h-2 w-2 rounded-full ${realtimeIndicatorClass}`} />
|
||||||
realtimeEnabled && isRunning && isTrading
|
|
||||||
? 'bg-accent animate-pulse'
|
|
||||||
: realtimeEnabled ? 'bg-warning/60' : 'bg-muted'
|
|
||||||
}`} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -652,67 +706,76 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Starter+ — 开关 + 跳转设置 */
|
/* Starter+ — 开关 + 跳转设置 */
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
|
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${realtimeIndicatorClass}`} />
|
||||||
realtimeEnabled && isRunning && isTrading
|
<div className="min-w-0">
|
||||||
? 'bg-accent animate-pulse'
|
<div className="text-xs font-medium leading-none text-foreground">实时行情</div>
|
||||||
: realtimeEnabled
|
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] leading-none">
|
||||||
? 'bg-warning/60'
|
<span className="truncate text-muted">{realtimeProviderName || realtimeModeLabel}</span>
|
||||||
: 'bg-muted'
|
<span className="shrink-0 text-border" aria-hidden="true">·</span>
|
||||||
}`} />
|
<span className={`shrink-0 ${realtimeStatusClass}`}>{realtimeStatusLabel}</span>
|
||||||
<span className="text-xs text-secondary truncate">
|
</div>
|
||||||
实时行情 · {realtimeProviderName || realtimeModeLabel}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings?tab=monitoring')}
|
onClick={() => navigate('/settings?tab=monitoring')}
|
||||||
className="text-secondary hover:text-foreground transition-colors shrink-0"
|
aria-label="打开实时监控设置"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
||||||
title="实时监控设置"
|
title="实时监控设置"
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={realtimeEnabled}
|
||||||
|
aria-label={realtimeToggleTitle}
|
||||||
|
aria-busy={toggleQuote.isPending}
|
||||||
|
onClick={() => handleToggle(!realtimeEnabled)}
|
||||||
|
disabled={realtimeToggleDisabled}
|
||||||
|
title={realtimeToggleTitle}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full border transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface',
|
||||||
|
realtimeEnabled
|
||||||
|
? 'border-accent/50 bg-accent shadow-[0_0_6px_rgba(59,130,246,0.25)]'
|
||||||
|
: 'border-border bg-elevated hover:border-muted',
|
||||||
|
realtimeToggleDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full border border-black/5 bg-white shadow-sm transition-transform duration-200',
|
||||||
|
realtimeEnabled ? 'translate-x-[18px]' : 'translate-x-0.5',
|
||||||
|
)} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(!realtimeEnabled)}
|
|
||||||
disabled={toggleQuote.isPending || isPaused}
|
|
||||||
title={isPaused ? '数据同步运行中,实时行情已临时暂停' : undefined}
|
|
||||||
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
|
|
||||||
realtimeEnabled
|
|
||||||
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
|
|
||||||
: 'bg-elevated'
|
|
||||||
} ${toggleQuote.isPending || isPaused ? 'opacity-50' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
||||||
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 状态提示 */}
|
{/* 状态提示 */}
|
||||||
{realtimeEnabled && (!isNoneTier || realtimeProviderName) && (
|
{realtimeEnabled
|
||||||
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
|
&& (!isNoneTier || realtimeProviderName)
|
||||||
{isWatchlistMode && !dismissFreeHint && !realtimeProviderName && (
|
&& (isPaused || (isWatchlistMode && !dismissFreeHint && !realtimeProviderName))
|
||||||
<div className="flex items-start gap-1 text-amber-400/80">
|
&& (
|
||||||
<span className="flex-1">监控自选股前 5 只,全市场监控需 Starter+</span>
|
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
|
||||||
<button
|
{isWatchlistMode && !dismissFreeHint && !realtimeProviderName && (
|
||||||
onClick={() => setDismissFreeHint(true)}
|
<div className="flex items-start gap-1 text-amber-400/80">
|
||||||
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
|
<span className="flex-1">监控自选股前 5 只,全市场监控需 Starter+</span>
|
||||||
title="关闭提示"
|
<button
|
||||||
>
|
onClick={() => setDismissFreeHint(true)}
|
||||||
<X className="h-2.5 w-2.5" />
|
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
|
||||||
</button>
|
title="关闭提示"
|
||||||
</div>
|
>
|
||||||
)}
|
<X className="h-2.5 w-2.5" />
|
||||||
{isPaused ? (
|
</button>
|
||||||
<div className="text-warning/80">数据同步运行中,实时行情已临时暂停</div>
|
</div>
|
||||||
) : isRunning && isTrading ? (
|
)}
|
||||||
<div className="text-accent">行情运行中</div>
|
{isPaused && (
|
||||||
) : realtimeEnabled && !isTrading ? (
|
<div className="text-warning/80">数据同步运行中,实时行情已临时暂停</div>
|
||||||
<div className="text-warning/70">非交易时段,将在交易时间自动开启</div>
|
)}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
{showSidebarQuotes && !isWatchlistMode && (!isNoneTier || !!realtimeProviderName) && (
|
{showSidebarQuotes && !isWatchlistMode && (!isNoneTier || !!realtimeProviderName) && (
|
||||||
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
|
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
|
|||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="TickFlow Stock Panel"
|
aria-label="Tick Stock Panel"
|
||||||
>
|
>
|
||||||
{/* 左方括号 */}
|
{/* 左方括号 */}
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cn } from '@/lib/cn'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: React.ReactNode
|
||||||
/** 标题右侧、subtitle 之前的额外节点(如状态徽标) */
|
/** 标题右侧、subtitle 之前的额外节点(如状态徽标) */
|
||||||
titleExtra?: React.ReactNode
|
titleExtra?: React.ReactNode
|
||||||
right?: React.ReactNode
|
right?: React.ReactNode
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ArrowDown, ArrowUp, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
|
||||||
|
import { api, type FactorColumn, type ScoringDirection } from '@/lib/api'
|
||||||
|
import { QK } from '@/lib/queryKeys'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: Record<string, number>
|
||||||
|
directions: Record<string, ScoringDirection>
|
||||||
|
onChange: (value: Record<string, number>, directions: Record<string, ScoringDirection>) => void
|
||||||
|
fallbackLabels?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightsToPercentages(values: Record<string, number>) {
|
||||||
|
const entries = Object.entries(values).map(([name, value]) => [
|
||||||
|
name,
|
||||||
|
Math.max(0, Number(value) || 0),
|
||||||
|
] as const)
|
||||||
|
const total = entries.reduce((sum, [, value]) => sum + value, 0)
|
||||||
|
if (total <= 0) {
|
||||||
|
return Object.fromEntries(entries.map(([name]) => [name, 0])) as Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = entries.map(([name, value], index) => {
|
||||||
|
const exact = value / total * 100
|
||||||
|
return { name, index, value: Math.floor(exact), remainder: exact - Math.floor(exact) }
|
||||||
|
})
|
||||||
|
let remaining = 100 - shares.reduce((sum, item) => sum + item.value, 0)
|
||||||
|
for (const item of [...shares].sort((a, b) => b.remainder - a.remainder || a.index - b.index)) {
|
||||||
|
if (remaining <= 0) break
|
||||||
|
item.value += 1
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
return Object.fromEntries(shares.map(item => [item.name, item.value])) as Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePercentages(values: Record<string, number>) {
|
||||||
|
const active = Object.entries(values).filter(([, value]) => Number(value) > 0)
|
||||||
|
const total = active.reduce((sum, [, value]) => sum + Number(value), 0)
|
||||||
|
if (total <= 0) return {}
|
||||||
|
return Object.fromEntries(
|
||||||
|
active.map(([name, value]) => [name, +(Number(value) / total).toFixed(6)]),
|
||||||
|
) as Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoringRow({ name, label, weight, direction, editing, onWeightChange, onDirectionChange, onRemove }: {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
weight: number
|
||||||
|
direction: ScoringDirection
|
||||||
|
editing: boolean
|
||||||
|
onWeightChange: (value: number) => void
|
||||||
|
onDirectionChange: (value: ScoringDirection) => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-8 grid-cols-[minmax(4rem,6.5rem)_3.75rem_minmax(3.5rem,1fr)_2.25rem_1.75rem] items-center gap-1.5">
|
||||||
|
<span className="truncate text-right text-[11px] text-secondary" title={`${label} · ${name}`}>{label}</span>
|
||||||
|
{editing ? (
|
||||||
|
<div className="grid h-6 grid-cols-2 overflow-hidden rounded border border-border bg-base">
|
||||||
|
{([['high', ArrowUp, '偏好高值'], ['low', ArrowDown, '偏好低值']] as const).map(([value, Icon, title]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDirectionChange(value)}
|
||||||
|
className={`flex items-center justify-center transition-colors ${direction === value
|
||||||
|
? value === 'high' ? 'bg-emerald-400/15 text-emerald-400' : 'bg-cyan-400/15 text-cyan-400'
|
||||||
|
: 'text-muted hover:bg-elevated hover:text-secondary'
|
||||||
|
}`}
|
||||||
|
title={title}
|
||||||
|
aria-label={`${label}${title}`}
|
||||||
|
aria-pressed={direction === value}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={`flex items-center justify-center gap-1 text-[10px] ${direction === 'low' ? 'text-cyan-400' : 'text-emerald-400'}`}>
|
||||||
|
{direction === 'low' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
|
||||||
|
{direction === 'low' ? '低值' : '高值'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={weight}
|
||||||
|
onChange={event => onWeightChange(Number(event.target.value))}
|
||||||
|
className="h-1 min-w-0 cursor-pointer accent-amber-400"
|
||||||
|
aria-label={`${label}权重`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-1.5 min-w-0 overflow-hidden rounded-full bg-elevated">
|
||||||
|
<div className="h-full rounded-full bg-amber-400/70" style={{ width: `${Math.min(weight, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-right font-mono text-[10px] text-muted">{weight}%</span>
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-danger/10 hover:text-danger"
|
||||||
|
title={`移除${label}`}
|
||||||
|
aria-label={`移除评分因子${label}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : <span aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoringEditor({ value, directions, onChange, fallbackLabels = {} }: Props) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<Record<string, number>>(() => weightsToPercentages(value))
|
||||||
|
const [directionDraft, setDirectionDraft] = useState<Record<string, ScoringDirection>>(directions)
|
||||||
|
const [factorToAdd, setFactorToAdd] = useState('')
|
||||||
|
const factors = useQuery({
|
||||||
|
queryKey: QK.factorColumns,
|
||||||
|
queryFn: api.factorColumns,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
const factorLabels = useMemo(() => Object.fromEntries(
|
||||||
|
(factors.data?.columns ?? []).map(item => [item.id, item.label]),
|
||||||
|
), [factors.data])
|
||||||
|
const factorGroups = useMemo(() => {
|
||||||
|
const groups: Record<string, FactorColumn[]> = {}
|
||||||
|
for (const item of factors.data?.columns ?? []) {
|
||||||
|
;(groups[item.group] ??= []).push(item)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}, [factors.data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) return
|
||||||
|
setDraft(weightsToPercentages(value))
|
||||||
|
setDirectionDraft(directions)
|
||||||
|
}, [directions, editing, value])
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
setDraft(weightsToPercentages(value))
|
||||||
|
setDirectionDraft(directions)
|
||||||
|
setFactorToAdd('')
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setDraft(weightsToPercentages(value))
|
||||||
|
setDirectionDraft(directions)
|
||||||
|
setFactorToAdd('')
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
const saveDraft = () => {
|
||||||
|
const normalized = normalizePercentages(draft)
|
||||||
|
const nextDirections = Object.fromEntries(
|
||||||
|
Object.keys(normalized).map(name => [name, directionDraft[name] ?? 'high']),
|
||||||
|
) as Record<string, ScoringDirection>
|
||||||
|
onChange(normalized, nextDirections)
|
||||||
|
setFactorToAdd('')
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
const addFactor = () => {
|
||||||
|
if (!factorToAdd || factorToAdd in draft) return
|
||||||
|
setDraft(current => ({ ...current, [factorToAdd]: Object.keys(current).length > 0 ? 10 : 100 }))
|
||||||
|
setDirectionDraft(current => ({ ...current, [factorToAdd]: 'high' }))
|
||||||
|
setFactorToAdd('')
|
||||||
|
}
|
||||||
|
const removeFactor = (name: string) => {
|
||||||
|
setDraft(current => Object.fromEntries(
|
||||||
|
Object.entries(current).filter(([key]) => key !== name),
|
||||||
|
))
|
||||||
|
setDirectionDraft(current => Object.fromEntries(
|
||||||
|
Object.entries(current).filter(([key]) => key !== name),
|
||||||
|
) as Record<string, ScoringDirection>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleWeights = editing ? draft : weightsToPercentages(value)
|
||||||
|
const visibleDirections = editing ? directionDraft : directions
|
||||||
|
const visibleKeys = Object.keys(visibleWeights)
|
||||||
|
const draftTotal = Object.values(visibleWeights).reduce((sum, weight) => sum + weight, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{editing && (
|
||||||
|
<div className="flex gap-2 border-b border-border/40 pb-3">
|
||||||
|
<select
|
||||||
|
value={factorToAdd}
|
||||||
|
onChange={event => setFactorToAdd(event.target.value)}
|
||||||
|
disabled={factors.isLoading || factors.isError}
|
||||||
|
className="h-8 min-w-0 flex-1 rounded-input border border-border bg-base px-2 text-xs text-secondary focus:border-accent focus:outline-none disabled:opacity-50"
|
||||||
|
aria-label="选择要添加的评分因子"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{factors.isLoading ? '加载因子目录…' : factors.isError ? '因子目录加载失败' : '选择评分因子'}
|
||||||
|
</option>
|
||||||
|
{Object.entries(factorGroups).map(([group, items]) => {
|
||||||
|
const available = items.filter(item => !(item.id in draft))
|
||||||
|
return available.length > 0 ? (
|
||||||
|
<optgroup key={group} label={group}>
|
||||||
|
{available.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addFactor}
|
||||||
|
disabled={!factorToAdd}
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-btn border border-accent/30 bg-accent/10 text-accent transition-colors hover:bg-accent/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="添加评分因子"
|
||||||
|
aria-label="添加评分因子"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleKeys.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visibleKeys.map(name => (
|
||||||
|
<ScoringRow
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
label={factorLabels[name] ?? fallbackLabels[name] ?? name}
|
||||||
|
weight={visibleWeights[name] ?? 0}
|
||||||
|
direction={visibleDirections[name] ?? 'high'}
|
||||||
|
editing={editing}
|
||||||
|
onWeightChange={weight => setDraft(current => ({ ...current, [name]: Math.max(0, weight) }))}
|
||||||
|
onDirectionChange={direction => setDirectionDraft(current => ({ ...current, [name]: direction }))}
|
||||||
|
onRemove={() => removeFactor(name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-y border-border/40 py-5 text-center text-xs text-muted">
|
||||||
|
{editing ? '请选择评分因子' : '当前策略不使用因子评分'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/40 pt-2">
|
||||||
|
<div className="text-[10px] text-muted">
|
||||||
|
权重 <span className={`font-mono text-xs font-medium ${editing && draftTotal !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||||
|
{editing ? draftTotal : visibleKeys.length > 0 ? 100 : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{editing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
||||||
|
title="取消编辑"
|
||||||
|
aria-label="取消编辑评分方案"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={editing ? saveDraft : startEditing}
|
||||||
|
className="inline-flex h-7 items-center gap-1.5 rounded-btn border border-amber-400/40 bg-amber-400/10 px-2.5 text-[11px] text-amber-400 transition-colors hover:bg-amber-400/15"
|
||||||
|
>
|
||||||
|
{editing ? <Save className="h-3.5 w-3.5" /> : <Pencil className="h-3.5 w-3.5" />}
|
||||||
|
{editing ? '保存方案' : '编辑方案'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
|
|||||||
threshold_pct: 1,
|
threshold_pct: 1,
|
||||||
window_minutes: 5,
|
window_minutes: 5,
|
||||||
strategy_id: null,
|
strategy_id: null,
|
||||||
|
score_min: null,
|
||||||
|
score_max: null,
|
||||||
direction: 'entry',
|
direction: 'entry',
|
||||||
conditions: [],
|
conditions: [],
|
||||||
logic: 'or',
|
logic: 'or',
|
||||||
@@ -138,7 +140,17 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
|||||||
if (d.type === 'strategy') {
|
if (d.type === 'strategy') {
|
||||||
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
|
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
|
||||||
if (!d.notify_events?.length) throw new Error('至少选择一个通知事件')
|
if (!d.notify_events?.length) throw new Error('至少选择一个通知事件')
|
||||||
|
for (const [label, value] of [['最低分', d.score_min], ['最高分', d.score_max]] as const) {
|
||||||
|
if (value != null && (!Number.isFinite(value) || value < 0 || value > 100)) {
|
||||||
|
throw new Error(`${label}必须在 0 到 100 之间`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d.score_min != null && d.score_max != null && d.score_min > d.score_max) {
|
||||||
|
throw new Error('最低分不能高于最高分')
|
||||||
|
}
|
||||||
} else if (d.type === 'sector') {
|
} else if (d.type === 'sector') {
|
||||||
|
delete d.score_min
|
||||||
|
delete d.score_max
|
||||||
d.scope = 'all'
|
d.scope = 'all'
|
||||||
d.symbols = []
|
d.symbols = []
|
||||||
d.conditions = []
|
d.conditions = []
|
||||||
@@ -146,6 +158,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
|||||||
if (!d.sector_targets?.length) throw new Error('请选择至少一个监控对象')
|
if (!d.sector_targets?.length) throw new Error('请选择至少一个监控对象')
|
||||||
if ((d.threshold_pct ?? 0) <= 0 || (d.threshold_pct ?? 0) > 20) throw new Error('阈值必须大于 0 且不超过 20%')
|
if ((d.threshold_pct ?? 0) <= 0 || (d.threshold_pct ?? 0) > 20) throw new Error('阈值必须大于 0 且不超过 20%')
|
||||||
} else {
|
} else {
|
||||||
|
delete d.score_min
|
||||||
|
delete d.score_max
|
||||||
delete d.notify_events
|
delete d.notify_events
|
||||||
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
|
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
|
||||||
for (const c of d.conditions) {
|
for (const c of d.conditions) {
|
||||||
@@ -843,6 +857,50 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[11px] text-muted"
|
||||||
|
title="评分范围仅过滤选股结果与买入信号,卖出信号不受限制"
|
||||||
|
>
|
||||||
|
评分范围
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-[10px] text-muted">最低分(含)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step="any"
|
||||||
|
value={draft.score_min ?? ''}
|
||||||
|
onChange={event => setDraft(d => ({
|
||||||
|
...d,
|
||||||
|
score_min: event.target.value === '' ? null : Number(event.target.value),
|
||||||
|
}))}
|
||||||
|
placeholder="不限"
|
||||||
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="mt-5 text-xs text-muted">至</span>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-[10px] text-muted">最高分(含)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step="any"
|
||||||
|
value={draft.score_max ?? ''}
|
||||||
|
onChange={event => setDraft(d => ({
|
||||||
|
...d,
|
||||||
|
score_max: event.target.value === '' ? null : Number(event.target.value),
|
||||||
|
}))}
|
||||||
|
placeholder="不限"
|
||||||
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<span className="text-[11px] text-muted">通知事件</span>
|
<span className="text-[11px] text-muted">通知事件</span>
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ ENTRY_SIGNALS = ["signal_n_day_high"]
|
|||||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
RULES = """
|
RULES = """
|
||||||
1. 规则一
|
1. 规则一
|
||||||
@@ -150,7 +149,6 @@ ENTRY_SIGNALS = []
|
|||||||
EXIT_SIGNALS = []
|
EXIT_SIGNALS = []
|
||||||
STOP_LOSS = -0.05
|
STOP_LOSS = -0.05
|
||||||
MAX_HOLD_DAYS = 20
|
MAX_HOLD_DAYS = 20
|
||||||
ALERTS = []
|
|
||||||
|
|
||||||
class CustomMatrixStrategy:
|
class CustomMatrixStrategy:
|
||||||
def required_fields(self) -> frozenset[str]:
|
def required_fields(self) -> frozenset[str]:
|
||||||
@@ -598,7 +596,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
|
|||||||
AI 修改
|
AI 修改
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted/40">修改指令可调整参数、信号、告警、评分等任意内容。确认无误后点击「保存策略」。</p>
|
<p className="text-[10px] text-muted/40">修改指令可调整参数、信号、评分等任意内容。确认无误后点击「保存策略」。</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download, Layers, Plus, Trash2 } from 'lucide-react'
|
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download, Layers, Plus, Trash2 } from 'lucide-react'
|
||||||
import { api, type StrategyDetail, type StrategyParamDef, type CompositeChildInfo } from '@/lib/api'
|
import { api, type StrategyDetail, type StrategyParamDef, type CompositeChildInfo, type ScoringDirection } from '@/lib/api'
|
||||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
||||||
import { color } from '@/lib/colors'
|
import { color } from '@/lib/colors'
|
||||||
import { SignalPicker } from './SignalPicker'
|
import { SignalPicker } from './SignalPicker'
|
||||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
|
import { ScoringEditor } from '@/components/ScoringEditor'
|
||||||
|
|
||||||
// 内置列名 → 中文标签
|
// 内置列名 → 中文标签
|
||||||
const FIELD_LABEL: Record<string, string> = {}
|
const FIELD_LABEL: Record<string, string> = {}
|
||||||
@@ -173,31 +174,6 @@ function ParamField({ def, value, onChange }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 评分权重字段
|
|
||||||
function ScoringField({ col, weight, pct, editing, onChange }: {
|
|
||||||
col: string; weight: number; pct: number; editing: boolean; onChange: (v: number) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{FIELD_LABEL[col] ?? col}</span>
|
|
||||||
{editing ? (
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
value={weight}
|
|
||||||
onChange={e => onChange(Number(e.target.value))}
|
|
||||||
min={0} max={100} step={1}
|
|
||||||
className="flex-1 h-1 accent-amber-400 cursor-pointer"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 h-1.5 bg-elevated rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-amber-400/70 rounded-full transition-all duration-300" style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="w-10 text-right text-[10px] font-mono text-muted">{editing ? weight : `${pct}%`}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) {
|
export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) {
|
||||||
const [detail, setDetail] = useState<StrategyDetail | null>(null)
|
const [detail, setDetail] = useState<StrategyDetail | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -210,6 +186,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
const [basicFilter, setBasicFilter] = useState<Record<string, any>>({})
|
const [basicFilter, setBasicFilter] = useState<Record<string, any>>({})
|
||||||
const [params, setParams] = useState<Record<string, any>>({})
|
const [params, setParams] = useState<Record<string, any>>({})
|
||||||
const [scoring, setScoring] = useState<Record<string, number>>({})
|
const [scoring, setScoring] = useState<Record<string, number>>({})
|
||||||
|
const [scoringDirections, setScoringDirections] = useState<Record<string, ScoringDirection>>({})
|
||||||
const [stopLoss, setStopLoss] = useState<number | null>(null)
|
const [stopLoss, setStopLoss] = useState<number | null>(null)
|
||||||
const [maxHoldDays, setMaxHoldDays] = useState<number | null>(null)
|
const [maxHoldDays, setMaxHoldDays] = useState<number | null>(null)
|
||||||
const [entrySignals, setEntrySignals] = useState<string[]>([])
|
const [entrySignals, setEntrySignals] = useState<string[]>([])
|
||||||
@@ -221,7 +198,6 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
// 可选子策略列表 + 添加面板开关(composite 设置用)
|
// 可选子策略列表 + 添加面板开关(composite 设置用)
|
||||||
const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([])
|
const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([])
|
||||||
const [showAddChild, setShowAddChild] = useState(false)
|
const [showAddChild, setShowAddChild] = useState(false)
|
||||||
const [editingScoring, setEditingScoring] = useState(false)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [deleteError, setDeleteError] = useState('')
|
const [deleteError, setDeleteError] = useState('')
|
||||||
@@ -245,7 +221,8 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
if (!bf.boards) bf.boards = ALL_BOARDS
|
if (!bf.boards) bf.boards = ALL_BOARDS
|
||||||
setBasicFilter(bf)
|
setBasicFilter(bf)
|
||||||
setParams(d.params_defaults)
|
setParams(d.params_defaults)
|
||||||
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
|
setScoring(d.scoring)
|
||||||
|
setScoringDirections(d.scoring_directions ?? {})
|
||||||
setStopLoss(d.stop_loss)
|
setStopLoss(d.stop_loss)
|
||||||
setMaxHoldDays(d.max_hold_days)
|
setMaxHoldDays(d.max_hold_days)
|
||||||
setEntrySignals(d.entry_signals ?? [])
|
setEntrySignals(d.entry_signals ?? [])
|
||||||
@@ -288,7 +265,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
description: strategyDesc,
|
description: strategyDesc,
|
||||||
basic_filter: { ...basicFilter, enabled: basicFilterEnabled },
|
basic_filter: { ...basicFilter, enabled: basicFilterEnabled },
|
||||||
params,
|
params,
|
||||||
scoring: Object.fromEntries(Object.entries(scoring).map(([k, v]) => [k, +(v / 100).toFixed(4)])),
|
...(detail?.source !== 'composite' ? {
|
||||||
|
scoring,
|
||||||
|
scoring_directions: scoringDirections,
|
||||||
|
scoring_replace: true,
|
||||||
|
} : {}),
|
||||||
stop_loss: stopLoss,
|
stop_loss: stopLoss,
|
||||||
max_hold_days: maxHoldDays,
|
max_hold_days: maxHoldDays,
|
||||||
entry_signals: entrySignals,
|
entry_signals: entrySignals,
|
||||||
@@ -321,17 +302,18 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
if (!bf.boards) bf.boards = ALL_BOARDS
|
if (!bf.boards) bf.boards = ALL_BOARDS
|
||||||
setBasicFilter(bf)
|
setBasicFilter(bf)
|
||||||
setParams(d.params_defaults)
|
setParams(d.params_defaults)
|
||||||
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
|
setScoring(d.scoring)
|
||||||
setStopLoss(d.stop_loss)
|
setScoringDirections(d.scoring_directions ?? {})
|
||||||
setMaxHoldDays(d.max_hold_days)
|
setStopLoss(d.stop_loss)
|
||||||
setEntrySignals(d.entry_signals ?? [])
|
setMaxHoldDays(d.max_hold_days)
|
||||||
setExitSignals(d.exit_signals ?? [])
|
setEntrySignals(d.entry_signals ?? [])
|
||||||
setDisplayLimit(d.display_limit ?? null)
|
setExitSignals(d.exit_signals ?? [])
|
||||||
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
|
setDisplayLimit(d.display_limit ?? null)
|
||||||
setCompositeChildren(d.composite_children ?? [])
|
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
|
||||||
} finally {
|
setCompositeChildren(d.composite_children ?? [])
|
||||||
setResetting(false)
|
} finally {
|
||||||
}
|
setResetting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@@ -560,54 +542,16 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
{/* 列3:评分 + 交易 */}
|
{/* 列3:评分 + 交易 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Section icon={Star} title="评分权重" accent="text-amber-400">
|
<Section icon={Star} title="评分权重" accent="text-amber-400">
|
||||||
{Object.entries(scoring).length > 0 ? (() => {
|
<ScoringEditor
|
||||||
const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
|
key={detail.id}
|
||||||
return (
|
value={scoring}
|
||||||
<div className="space-y-2">
|
directions={scoringDirections}
|
||||||
{Object.entries(scoring).map(([col, w]) => {
|
fallbackLabels={FIELD_LABEL}
|
||||||
const pct = Math.round((w / total) * 100)
|
onChange={(nextScoring, nextDirections) => {
|
||||||
return (
|
setScoring(nextScoring)
|
||||||
<ScoringField key={col} col={col} weight={w} pct={pct}
|
setScoringDirections(nextDirections)
|
||||||
editing={editingScoring}
|
}}
|
||||||
onChange={v => setScoring({ ...scoring, [col]: Math.max(0, v) })} />
|
/>
|
||||||
)
|
|
||||||
})}
|
|
||||||
<div className="flex items-center justify-between pt-1.5 border-t border-border/10">
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted">
|
|
||||||
<span>总和</span>
|
|
||||||
<span className={`font-mono font-medium text-xs ${editingScoring ? (total === 100 ? color.ok : color.scoreWarn) : color.ok}`}>{editingScoring ? total : '100'}</span>
|
|
||||||
<span className="text-muted/40">自动归权计算</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (editingScoring) {
|
|
||||||
// 确认:归一化到 100
|
|
||||||
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
|
|
||||||
const norm = Object.fromEntries(
|
|
||||||
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
|
|
||||||
)
|
|
||||||
// 修正舍入误差
|
|
||||||
const newSum = Object.values(norm).reduce((a: number, b: number) => a + b, 0)
|
|
||||||
if (newSum !== 100) {
|
|
||||||
const keys = Object.keys(norm)
|
|
||||||
norm[keys[0]] += (100 - newSum)
|
|
||||||
}
|
|
||||||
setScoring(norm)
|
|
||||||
} else {
|
|
||||||
// 进入编辑:展开为 0-100 范围
|
|
||||||
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
|
|
||||||
setScoring(Object.fromEntries(
|
|
||||||
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
|
|
||||||
))
|
|
||||||
}
|
|
||||||
setEditingScoring(v => !v)
|
|
||||||
}}
|
|
||||||
className="text-[10px] text-accent/80 hover:text-accent cursor-pointer"
|
|
||||||
>{editingScoring ? '确定' : '设置'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})() : <div className="text-[11px] text-muted">未配置</div>}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={TrendingUp} title="交易参数" accent="text-emerald-400">
|
<Section icon={TrendingUp} title="交易参数" accent="text-emerald-400">
|
||||||
@@ -657,15 +601,6 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
出入场触发器保存后对<b className="text-secondary">回测和监控</b>生效;选股扫描仍按策略本身的筛选规则,不受此影响。
|
出入场触发器保存后对<b className="text-secondary">回测和监控</b>生效;选股扫描仍按策略本身的筛选规则,不受此影响。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.alerts.length > 0 && (
|
|
||||||
<Section icon={Settings2} title="提醒" accent="text-muted">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{detail.alerts.map((a, i) => (
|
|
||||||
<div key={i} className="text-[10px] text-secondary">{a.message} <span className="text-muted font-mono">{a.op ? `${FIELD_LABEL[a.field] ?? a.field} ${a.op} ${a.value}` : FIELD_LABEL[a.field] ?? a.field}</span></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 前端二次开发目录
|
||||||
|
|
||||||
|
在独立子目录中创建 `extension.tsx`,构建时会自动发现,无需修改核心路由和导航文件。
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/src/custom/<namespace>/extension.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
以 [`_template/extension.tsx.example`](_template/extension.tsx.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与构建。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
import type { FrontendExtension } from '@/extensions/types'
|
||||||
|
|
||||||
|
function RiskPage() {
|
||||||
|
return <div>风险分析</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string }) {
|
||||||
|
return collapsed ? null : <div className="px-3 py-1 text-xs text-muted">二开内容</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension: FrontendExtension = {
|
||||||
|
id: 'company.risk',
|
||||||
|
apiVersion: 1,
|
||||||
|
routes: [
|
||||||
|
{ id: 'company-risk', path: '/company/risk', component: RiskPage },
|
||||||
|
],
|
||||||
|
navigation: [
|
||||||
|
{ id: 'company-risk', routeId: 'company-risk', label: '风险分析', icon: ShieldCheck },
|
||||||
|
],
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
name: 'layout.navigation.extra',
|
||||||
|
id: 'company-risk-summary',
|
||||||
|
component: NavigationExtra,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default extension
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
extensionId: string
|
||||||
|
children: ReactNode
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
failed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExtensionBoundary extends Component<Props, State> {
|
||||||
|
state: State = { failed: false }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { failed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error(`扩展 ${this.props.extensionId} 渲染失败`, error, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.failed) return this.props.children
|
||||||
|
if (this.props.compact) return null
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">
|
||||||
|
扩展 {this.props.extensionId} 暂时不可用
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { FrontendSlotContextMap, FrontendSlotName } from './types'
|
||||||
|
import { getFrontendSlotRegistrations } from './registry'
|
||||||
|
import { ExtensionBoundary } from './ExtensionBoundary'
|
||||||
|
|
||||||
|
type Props<K extends FrontendSlotName> = {
|
||||||
|
name: K
|
||||||
|
context: FrontendSlotContextMap[K]
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionSlot<K extends FrontendSlotName>({ name, context, compact }: Props<K>) {
|
||||||
|
const registrations = getFrontendSlotRegistrations(name)
|
||||||
|
if (registrations.length === 0) return null
|
||||||
|
return registrations.map(registration => {
|
||||||
|
const SlotComponent = registration.component
|
||||||
|
return (
|
||||||
|
<ExtensionBoundary
|
||||||
|
key={`${registration.extensionId}:${registration.id}`}
|
||||||
|
extensionId={registration.extensionId}
|
||||||
|
compact={compact}
|
||||||
|
>
|
||||||
|
<SlotComponent {...context} />
|
||||||
|
</ExtensionBoundary>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { FrontendExtensionModule } from './types'
|
||||||
|
import { loadFrontendExtensions } from './registry'
|
||||||
|
|
||||||
|
const modules = import.meta.glob<FrontendExtensionModule>(
|
||||||
|
'../custom/*/extension.tsx',
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function initializeFrontendExtensions() {
|
||||||
|
await loadFrontendExtensions(modules)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import type {
|
||||||
|
FrontendExtension,
|
||||||
|
FrontendExtensionLoadError,
|
||||||
|
FrontendExtensionModule,
|
||||||
|
FrontendExtensionNavigation,
|
||||||
|
FrontendExtensionRoute,
|
||||||
|
FrontendSlotName,
|
||||||
|
FrontendSlotRegistration,
|
||||||
|
} from './types'
|
||||||
|
import { FRONTEND_EXTENSION_API_VERSION } from './types'
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/
|
||||||
|
|
||||||
|
const extensions = new Map<string, FrontendExtension>()
|
||||||
|
const routes = new Map<string, FrontendExtensionRoute & { extensionId: string }>()
|
||||||
|
const navigation = new Map<string, FrontendExtensionNavigation & { extensionId: string }>()
|
||||||
|
const slots = new Map<FrontendSlotName, Array<FrontendSlotRegistration & { extensionId: string }>>()
|
||||||
|
const loadErrors: FrontendExtensionLoadError[] = []
|
||||||
|
let frozen = false
|
||||||
|
|
||||||
|
function assertMutable() {
|
||||||
|
if (frozen) throw new Error('前端扩展注册表已冻结')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertId(value: string, label: string) {
|
||||||
|
if (!ID_RE.test(value)) throw new Error(`${label} 非法: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerExtension(extension: FrontendExtension) {
|
||||||
|
assertMutable()
|
||||||
|
assertId(extension.id, '扩展 ID')
|
||||||
|
if (extension.apiVersion !== FRONTEND_EXTENSION_API_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`扩展 ${extension.id} 需要前端契约 v${extension.apiVersion}, 当前为 v${FRONTEND_EXTENSION_API_VERSION}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (extensions.has(extension.id)) throw new Error(`扩展 ID 重复: ${extension.id}`)
|
||||||
|
|
||||||
|
const localRouteIds = new Set<string>()
|
||||||
|
const localRoutePaths = new Set<string>()
|
||||||
|
for (const route of extension.routes ?? []) {
|
||||||
|
assertId(route.id, '路由 ID')
|
||||||
|
if (!route.path.startsWith('/') || route.path === '/') {
|
||||||
|
throw new Error(`扩展路由必须使用非根绝对路径: ${route.path}`)
|
||||||
|
}
|
||||||
|
if (routes.has(route.id) || localRouteIds.has(route.id)) {
|
||||||
|
throw new Error(`扩展路由 ID 重复: ${route.id}`)
|
||||||
|
}
|
||||||
|
const duplicatePath = [...routes.values()].find(item => item.path === route.path)
|
||||||
|
if (duplicatePath || localRoutePaths.has(route.path)) {
|
||||||
|
throw new Error(`扩展路由路径重复: ${route.path}`)
|
||||||
|
}
|
||||||
|
localRouteIds.add(route.id)
|
||||||
|
localRoutePaths.add(route.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localNavigationIds = new Set<string>()
|
||||||
|
for (const item of extension.navigation ?? []) {
|
||||||
|
assertId(item.id, '导航 ID')
|
||||||
|
if (navigation.has(item.id) || localNavigationIds.has(item.id)) {
|
||||||
|
throw new Error(`扩展导航 ID 重复: ${item.id}`)
|
||||||
|
}
|
||||||
|
localNavigationIds.add(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSlotIds = new Set<string>()
|
||||||
|
for (const slot of extension.slots ?? []) {
|
||||||
|
assertId(slot.id, '插槽实现 ID')
|
||||||
|
const key = `${slot.name}:${slot.id}`
|
||||||
|
if (localSlotIds.has(key)) throw new Error(`扩展内插槽实现重复: ${key}`)
|
||||||
|
if ((slots.get(slot.name) ?? []).some(item => item.id === slot.id)) {
|
||||||
|
throw new Error(`插槽实现 ID 重复: ${key}`)
|
||||||
|
}
|
||||||
|
localSlotIds.add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.set(extension.id, extension)
|
||||||
|
for (const route of extension.routes ?? []) {
|
||||||
|
routes.set(route.id, { ...route, extensionId: extension.id })
|
||||||
|
}
|
||||||
|
for (const item of extension.navigation ?? []) {
|
||||||
|
navigation.set(item.id, { ...item, extensionId: extension.id })
|
||||||
|
}
|
||||||
|
for (const slot of extension.slots ?? []) {
|
||||||
|
const items = slots.get(slot.name) ?? []
|
||||||
|
items.push({ ...slot, extensionId: extension.id })
|
||||||
|
slots.set(slot.name, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFrontendExtensions(
|
||||||
|
modules: Record<string, () => Promise<FrontendExtensionModule>>,
|
||||||
|
) {
|
||||||
|
for (const source of Object.keys(modules).sort()) {
|
||||||
|
let extension: FrontendExtension | undefined
|
||||||
|
try {
|
||||||
|
extension = (await modules[source]()).default
|
||||||
|
if (!extension) throw new Error('模块必须 default export FrontendExtension')
|
||||||
|
registerExtension(extension)
|
||||||
|
} catch (error) {
|
||||||
|
loadErrors.push({
|
||||||
|
source,
|
||||||
|
extensionId: extension?.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeFrontendExtensions(reservedPaths: ReadonlySet<string>) {
|
||||||
|
if (frozen) return
|
||||||
|
for (const extension of [...extensions.values()]) {
|
||||||
|
try {
|
||||||
|
for (const route of extension.routes ?? []) {
|
||||||
|
if (route.path.includes(':') || route.path.includes('*')) {
|
||||||
|
throw new Error(`扩展路由首版只支持静态路径: ${route.path}`)
|
||||||
|
}
|
||||||
|
if ([...reservedPaths].some(corePath => coreRouteMatches(corePath, route.path))) {
|
||||||
|
throw new Error(`试图覆盖核心路由 ${route.path}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ownRouteIds = new Set((extension.routes ?? []).map(route => route.id))
|
||||||
|
for (const item of extension.navigation ?? []) {
|
||||||
|
if (!ownRouteIds.has(item.routeId)) {
|
||||||
|
throw new Error(`导航 ${item.id} 引用了本扩展中不存在的路由 ${item.routeId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
loadErrors.push({
|
||||||
|
source: extension.id,
|
||||||
|
extensionId: extension.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
removeExtension(extension.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const items of slots.values()) {
|
||||||
|
items.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id))
|
||||||
|
}
|
||||||
|
frozen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExtension(extensionId: string) {
|
||||||
|
extensions.delete(extensionId)
|
||||||
|
for (const [id, route] of routes) {
|
||||||
|
if (route.extensionId === extensionId) routes.delete(id)
|
||||||
|
}
|
||||||
|
for (const [id, item] of navigation) {
|
||||||
|
if (item.extensionId === extensionId) navigation.delete(id)
|
||||||
|
}
|
||||||
|
for (const [name, items] of slots) {
|
||||||
|
slots.set(name, items.filter(item => item.extensionId !== extensionId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coreRouteMatches(pattern: string, path: string) {
|
||||||
|
const patternParts = pattern.split('/').filter(Boolean)
|
||||||
|
const pathParts = path.split('/').filter(Boolean)
|
||||||
|
return patternParts.length === pathParts.length
|
||||||
|
&& patternParts.every((part, index) => part.startsWith(':') || part === pathParts[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrontendExtensionRoutes() {
|
||||||
|
if (!frozen) throw new Error('读取扩展路由前必须冻结注册表')
|
||||||
|
return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrontendExtensionNavigation() {
|
||||||
|
if (!frozen) throw new Error('读取扩展导航前必须冻结注册表')
|
||||||
|
return [...navigation.values()]
|
||||||
|
.map(item => ({ ...item, route: routes.get(item.routeId)! }))
|
||||||
|
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrontendSlotRegistrations<K extends FrontendSlotName>(name: K) {
|
||||||
|
if (!frozen) throw new Error('读取扩展插槽前必须冻结注册表')
|
||||||
|
return (slots.get(name) ?? []) as Array<FrontendSlotRegistration<K> & { extensionId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrontendExtensionLoadErrors() {
|
||||||
|
return [...loadErrors]
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export const FRONTEND_EXTENSION_API_VERSION = 1 as const
|
||||||
|
|
||||||
|
export interface FrontendSlotContextMap {
|
||||||
|
'layout.navigation.extra': {
|
||||||
|
collapsed: boolean
|
||||||
|
pathname: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontendSlotName = keyof FrontendSlotContextMap
|
||||||
|
|
||||||
|
export type FrontendSlotRegistration<K extends FrontendSlotName = FrontendSlotName> = {
|
||||||
|
name: K
|
||||||
|
id: string
|
||||||
|
order?: number
|
||||||
|
component: ComponentType<FrontendSlotContextMap[K]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendExtensionRoute {
|
||||||
|
id: string
|
||||||
|
path: `/${string}`
|
||||||
|
component: ComponentType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendExtensionNavigation {
|
||||||
|
id: string
|
||||||
|
routeId: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
order?: number
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendExtension {
|
||||||
|
id: string
|
||||||
|
apiVersion: typeof FRONTEND_EXTENSION_API_VERSION
|
||||||
|
routes?: FrontendExtensionRoute[]
|
||||||
|
navigation?: FrontendExtensionNavigation[]
|
||||||
|
slots?: FrontendSlotRegistration[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendExtensionModule {
|
||||||
|
default: FrontendExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendExtensionLoadError {
|
||||||
|
source: string
|
||||||
|
extensionId?: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
+102
-1
@@ -543,6 +543,7 @@ export interface StrategyDetail {
|
|||||||
params: StrategyParamDef[]
|
params: StrategyParamDef[]
|
||||||
params_defaults: Record<string, any>
|
params_defaults: Record<string, any>
|
||||||
scoring: Record<string, number>
|
scoring: Record<string, number>
|
||||||
|
scoring_directions: Record<string, ScoringDirection>
|
||||||
entry_signals: string[]
|
entry_signals: string[]
|
||||||
exit_signals: string[]
|
exit_signals: string[]
|
||||||
minute_exit_trigger_supported_signals: string[]
|
minute_exit_trigger_supported_signals: string[]
|
||||||
@@ -553,7 +554,6 @@ export interface StrategyDetail {
|
|||||||
trailing_take_profit_drawdown: number | null
|
trailing_take_profit_drawdown: number | null
|
||||||
max_hold_days: number | null
|
max_hold_days: number | null
|
||||||
display_limit?: number
|
display_limit?: number
|
||||||
alerts: { field: string; op?: string; value?: number; message: string }[]
|
|
||||||
order_by: string
|
order_by: string
|
||||||
descending: boolean
|
descending: boolean
|
||||||
limit: number
|
limit: number
|
||||||
@@ -561,6 +561,8 @@ export interface StrategyDetail {
|
|||||||
composite_children?: CompositeChildInfo[] | null
|
composite_children?: CompositeChildInfo[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScoringDirection = 'high' | 'low'
|
||||||
|
|
||||||
export interface StrategyBuildResult {
|
export interface StrategyBuildResult {
|
||||||
code: string
|
code: string
|
||||||
meta: Record<string, any>
|
meta: Record<string, any>
|
||||||
@@ -655,6 +657,8 @@ export interface MonitorRule {
|
|||||||
strategy_id?: string | null
|
strategy_id?: string | null
|
||||||
direction: 'entry' | 'exit' | 'both' | 'up' | 'down'
|
direction: 'entry' | 'exit' | 'both' | 'up' | 'down'
|
||||||
notify_events?: StrategyNotifyEvent[]
|
notify_events?: StrategyNotifyEvent[]
|
||||||
|
score_min?: number | null
|
||||||
|
score_max?: number | null
|
||||||
conditions: MonitorCondition[]
|
conditions: MonitorCondition[]
|
||||||
logic: 'and' | 'or'
|
logic: 'and' | 'or'
|
||||||
cooldown_seconds: number
|
cooldown_seconds: number
|
||||||
@@ -817,6 +821,57 @@ export interface FactorBacktestResult {
|
|||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FactorBatchItem {
|
||||||
|
factor_name: string
|
||||||
|
label: string
|
||||||
|
group: string
|
||||||
|
ic_mean: number | null
|
||||||
|
ir: number | null
|
||||||
|
ic_win_rate: number | null
|
||||||
|
long_short_return: number | null
|
||||||
|
long_short_max_drawdown: number | null
|
||||||
|
n_symbols: number
|
||||||
|
n_dates: number
|
||||||
|
elapsed_ms: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FactorBatchResult {
|
||||||
|
run_id: string
|
||||||
|
config: Record<string, any>
|
||||||
|
results: FactorBatchItem[]
|
||||||
|
elapsed_ms: number
|
||||||
|
n_symbols: number
|
||||||
|
n_dates: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResearchCandidateKind = 'factor' | 'strategy'
|
||||||
|
export type ResearchCandidateStatus = 'pending' | 'validated' | 'rejected'
|
||||||
|
|
||||||
|
export interface ResearchCandidate {
|
||||||
|
id: string
|
||||||
|
kind: ResearchCandidateKind
|
||||||
|
name: string
|
||||||
|
source_id: string
|
||||||
|
config: Record<string, unknown>
|
||||||
|
metrics: Record<string, number | string | boolean | null>
|
||||||
|
data_as_of: string | null
|
||||||
|
status: ResearchCandidateStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchCandidateCreate {
|
||||||
|
kind: ResearchCandidateKind
|
||||||
|
name: string
|
||||||
|
source_id: string
|
||||||
|
config: Record<string, unknown>
|
||||||
|
metrics: Record<string, number | string | boolean | null>
|
||||||
|
data_as_of?: string | null
|
||||||
|
status?: ResearchCandidateStatus
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Strategy Backtest =====
|
// ===== Strategy Backtest =====
|
||||||
export interface StrategyBacktestTrade {
|
export interface StrategyBacktestTrade {
|
||||||
symbol: string
|
symbol: string
|
||||||
@@ -1748,6 +1803,46 @@ export const api = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
factorBatch: (payload: {
|
||||||
|
factor_names: string[]
|
||||||
|
symbols?: string[] | null
|
||||||
|
start?: string | null
|
||||||
|
end?: string | null
|
||||||
|
n_groups?: number
|
||||||
|
rebalance?: 'daily' | 'weekly' | 'monthly'
|
||||||
|
weight?: 'equal' | 'factor_weight'
|
||||||
|
fees_pct?: number
|
||||||
|
slippage_bps?: number
|
||||||
|
asset_type?: 'stock' | 'etf' | 'index'
|
||||||
|
}) =>
|
||||||
|
request<FactorBatchResult>('/api/backtest/factor/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
researchCandidates: () =>
|
||||||
|
request<{ items: ResearchCandidate[] }>('/api/backtest/candidates'),
|
||||||
|
|
||||||
|
researchCandidateCreate: (payload: ResearchCandidateCreate) =>
|
||||||
|
request<ResearchCandidate>('/api/backtest/candidates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
researchCandidateUpdate: (
|
||||||
|
id: string,
|
||||||
|
payload: { name?: string; status?: ResearchCandidateStatus },
|
||||||
|
) =>
|
||||||
|
request<ResearchCandidate>(`/api/backtest/candidates/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
researchCandidateDelete: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/backtest/candidates/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
strategyBacktestRun: (payload: {
|
strategyBacktestRun: (payload: {
|
||||||
strategy_id: string
|
strategy_id: string
|
||||||
symbols?: string[] | null
|
symbols?: string[] | null
|
||||||
@@ -2254,6 +2349,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ strategy_id: strategyId, overrides }),
|
body: JSON.stringify({ strategy_id: strategyId, overrides }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
strategyPatchConfig: (strategyId: string, overrides: Record<string, any>) =>
|
||||||
|
request<{ ok: boolean }>('/api/strategies/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ strategy_id: strategyId, overrides }),
|
||||||
|
}),
|
||||||
|
|
||||||
strategyResetConfig: (strategyId: string) =>
|
strategyResetConfig: (strategyId: string) =>
|
||||||
request<{ ok: boolean }>(`/api/strategies/config/${strategyId}`, { method: 'DELETE' }),
|
request<{ ok: boolean }>(`/api/strategies/config/${strategyId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const QK = {
|
|||||||
|
|
||||||
// Backtest
|
// Backtest
|
||||||
backtestStatus: ['backtest-status'] as const,
|
backtestStatus: ['backtest-status'] as const,
|
||||||
|
factorColumns: ['backtest-factor-columns'] as const,
|
||||||
|
researchCandidates: ['research-candidates'] as const,
|
||||||
|
strategyLinkOptions: ['strategy-link-options'] as const,
|
||||||
strategyDetail: (id: string) => ['strategy-detail', id] as const,
|
strategyDetail: (id: string) => ['strategy-detail', id] as const,
|
||||||
|
|
||||||
// Data / Pipeline
|
// Data / Pipeline
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export function buildDefaultOverrides(detail: StrategyDetail): Record<string, an
|
|||||||
entry_signals: detail.entry_signals.map(toSignalId),
|
entry_signals: detail.entry_signals.map(toSignalId),
|
||||||
exit_signals: detail.exit_signals.map(toSignalId),
|
exit_signals: detail.exit_signals.map(toSignalId),
|
||||||
scoring: { ...detail.scoring },
|
scoring: { ...detail.scoring },
|
||||||
|
scoring_directions: { ...(detail.scoring_directions ?? {}) },
|
||||||
|
scoring_replace: true,
|
||||||
stop_loss: detail.stop_loss,
|
stop_loss: detail.stop_loss,
|
||||||
take_profit: detail.take_profit,
|
take_profit: detail.take_profit,
|
||||||
trailing_stop: detail.trailing_stop,
|
trailing_stop: detail.trailing_stop,
|
||||||
|
|||||||
+14
-8
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
|
||||||
import { router } from './router'
|
import { initializeFrontendExtensions } from './extensions/bootstrap'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
|
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
|
||||||
@@ -42,10 +42,16 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
async function bootstrap() {
|
||||||
<React.StrictMode>
|
await initializeFrontendExtensions()
|
||||||
<QueryClientProvider client={queryClient}>
|
const { router } = await import('./router')
|
||||||
<RouterProvider router={router} />
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
</QueryClientProvider>
|
<React.StrictMode>
|
||||||
</React.StrictMode>
|
<QueryClientProvider client={queryClient}>
|
||||||
)
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap()
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function Auth() {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-6 flex flex-col items-center gap-2">
|
<div className="mb-6 flex flex-col items-center gap-2">
|
||||||
<Logo className="h-10 w-10" />
|
<Logo className="h-10 w-10" />
|
||||||
<h1 className="text-lg font-semibold text-foreground">TickFlow Stock Panel</h1>
|
<h1 className="text-lg font-semibold text-foreground">Tick Stock Panel</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
||||||
|
|||||||
@@ -1,91 +1,89 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { BarChart3, BookmarkCheck, FlaskConical, ShieldCheck } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { FactorBacktest } from './backtest/FactorBacktest'
|
import { FactorDiscovery } from './backtest/FactorDiscovery'
|
||||||
|
import { ResearchCandidatesDialog } from './backtest/ResearchCandidatesDialog'
|
||||||
|
import { RobustnessValidation } from './backtest/RobustnessValidation'
|
||||||
import { StrategyBacktest } from './backtest/StrategyBacktest'
|
import { StrategyBacktest } from './backtest/StrategyBacktest'
|
||||||
import { StrategyOptimizer } from './backtest/StrategyOptimizer'
|
|
||||||
import { StrategyWalkForward } from './backtest/StrategyWalkForward'
|
|
||||||
import { BarChart3, FlaskConical, SlidersHorizontal, Waypoints } from 'lucide-react'
|
|
||||||
|
|
||||||
type Tab = 'factor' | 'strategy' | 'optimizer' | 'walkforward'
|
type Tab = 'factor' | 'strategy' | 'robustness'
|
||||||
|
|
||||||
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
|
const MODES: Record<Tab, { title: string; subtitle: string; icon: typeof BarChart3 }> = {
|
||||||
factor: {
|
factor: {
|
||||||
title: '因子回测',
|
title: '因子',
|
||||||
subtitle: '验证单个因子是否有预测能力',
|
subtitle: '批量筛选与单因子检验',
|
||||||
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
|
icon: BarChart3,
|
||||||
},
|
},
|
||||||
strategy: {
|
strategy: {
|
||||||
title: '策略回测',
|
title: '策略',
|
||||||
subtitle: '验证完整选股和交易规则',
|
subtitle: '现有策略评估与候选沉淀',
|
||||||
hint: '看净值曲线、回撤、胜率和交易明细,适合评估策略的历史表现。',
|
icon: FlaskConical,
|
||||||
},
|
},
|
||||||
optimizer: {
|
robustness: {
|
||||||
title: '参数优化',
|
title: '验证',
|
||||||
subtitle: '网格搜索最优参数组合',
|
subtitle: '参数敏感性与滚动样本外',
|
||||||
hint: '在独立 worker 中复用基础数据并串行回测参数组合,按夏普/索提诺等目标排序。',
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
walkforward: {
|
|
||||||
title: '步进优化',
|
|
||||||
subtitle: '滚动窗口样本外验证',
|
|
||||||
hint: '每折训练区间优化、测试区间验证,看样本外是否退化以识别过拟合。',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<Tab, typeof BarChart3> = {
|
|
||||||
factor: BarChart3,
|
|
||||||
strategy: FlaskConical,
|
|
||||||
optimizer: SlidersHorizontal,
|
|
||||||
walkforward: Waypoints,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Backtest() {
|
export function Backtest() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('strategy')
|
const [activeTab, setActiveTab] = useState<Tab>('strategy')
|
||||||
|
const [candidatesOpen, setCandidatesOpen] = useState(false)
|
||||||
const modeSwitch = (
|
|
||||||
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
|
|
||||||
{(['factor', 'strategy', 'optimizer', 'walkforward'] as const).map(tab => {
|
|
||||||
const Icon = TAB_ICONS[tab]
|
|
||||||
const active = activeTab === tab
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
active
|
|
||||||
? 'bg-accent text-white shadow-sm'
|
|
||||||
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{MODES[tab].title}
|
|
||||||
{(tab === 'optimizer' || tab === 'walkforward') && (
|
|
||||||
<span className={`rounded border px-1 py-px text-[8px] font-semibold uppercase ${
|
|
||||||
active ? 'border-white/40 bg-white/15 text-white' : 'border-amber-400/30 bg-amber-400/10 text-amber-400'
|
|
||||||
}`}>
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-base flex flex-col">
|
<div className="flex min-h-full flex-col bg-base">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="回测工作台"
|
title="量化研究"
|
||||||
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
|
subtitle={<span className="hidden md:inline">{MODES[activeTab].subtitle}</span>}
|
||||||
right={modeSwitch}
|
className="shrink-0 flex-wrap gap-x-4 gap-y-2 bg-base/95 px-3 lg:flex-nowrap lg:px-5"
|
||||||
className="shrink-0 bg-base/95"
|
right={(
|
||||||
|
<div className="flex w-full min-w-0 items-center gap-1.5 sm:gap-2 lg:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCandidatesOpen(true)}
|
||||||
|
aria-label="打开候选方案"
|
||||||
|
title="候选方案"
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-btn border border-border bg-surface px-2 text-[11px] font-medium text-secondary transition-colors hover:border-accent/40 hover:text-accent sm:px-2.5 sm:text-xs"
|
||||||
|
>
|
||||||
|
<BookmarkCheck className="h-3.5 w-3.5" />
|
||||||
|
<span>候选方案</span>
|
||||||
|
</button>
|
||||||
|
<span className="h-5 w-px shrink-0 bg-border" aria-hidden="true" />
|
||||||
|
<nav className="min-w-0 flex-1 overflow-x-auto lg:flex-none" aria-label="量化研究视图">
|
||||||
|
<div className="inline-flex min-w-max items-center gap-0.5 rounded-btn border border-border bg-surface/80 p-0.5">
|
||||||
|
{(Object.keys(MODES) as Tab[]).map(tab => {
|
||||||
|
const mode = MODES[tab]
|
||||||
|
const Icon = mode.icon
|
||||||
|
const active = activeTab === tab
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(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
|
||||||
|
? '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" />
|
||||||
|
{mode.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex-1 min-h-0 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
|
<main className="min-h-0 flex-1 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
|
||||||
{activeTab === 'factor' && <FactorBacktest />}
|
{activeTab === 'factor' && <FactorDiscovery />}
|
||||||
{activeTab === 'strategy' && <StrategyBacktest />}
|
{activeTab === 'strategy' && <StrategyBacktest />}
|
||||||
{activeTab === 'optimizer' && <StrategyOptimizer />}
|
{activeTab === 'robustness' && <RobustnessValidation />}
|
||||||
{activeTab === 'walkforward' && <StrategyWalkForward />}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{candidatesOpen && <ResearchCandidatesDialog onClose={() => setCandidatesOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ interface Variant {
|
|||||||
glow?: string // 名字下方的发光线条 hex
|
glow?: string // 名字下方的发光线条 hex
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一个名字 "TickFlow Stock Panel" 在 4 种风格语言里的呈现
|
// 同一个名字 "Tick Stock Panel" 在 4 种风格语言里的呈现
|
||||||
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
|
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
|
||||||
const VARIANTS: Variant[] = [
|
const VARIANTS: Variant[] = [
|
||||||
{
|
{
|
||||||
id: 'pulsar',
|
id: 'pulsar',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'Tick Stock Panel',
|
||||||
tagline: 'A-SHARE · SIGNAL TERMINAL',
|
tagline: 'A-SHARE · SIGNAL TERMINAL',
|
||||||
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
|
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
|
||||||
icon: RadioTower,
|
icon: RadioTower,
|
||||||
@@ -40,7 +40,7 @@ const VARIANTS: Variant[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vanta',
|
id: 'vanta',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'Tick Stock Panel',
|
||||||
tagline: 'MARKET · INTELLIGENCE',
|
tagline: 'MARKET · INTELLIGENCE',
|
||||||
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
|
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
|
||||||
icon: Square,
|
icon: Square,
|
||||||
@@ -50,7 +50,7 @@ const VARIANTS: Variant[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'helix',
|
id: 'helix',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'Tick Stock Panel',
|
||||||
tagline: 'QUANT · TERMINAL',
|
tagline: 'QUANT · TERMINAL',
|
||||||
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
|
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
|
||||||
icon: GitFork,
|
icon: GitFork,
|
||||||
@@ -60,7 +60,7 @@ const VARIANTS: Variant[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'aurora',
|
id: 'aurora',
|
||||||
name: 'TickFlow Stock Panel',
|
name: 'Tick Stock Panel',
|
||||||
tagline: 'A-SHARE · DASHBOARD',
|
tagline: 'A-SHARE · DASHBOARD',
|
||||||
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
|
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
@@ -85,7 +85,7 @@ export function Branding() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="视觉风格预览"
|
title="视觉风格预览"
|
||||||
subtitle="名字保持 TickFlow Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
|
subtitle="名字保持 Tick Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
|
|||||||
@@ -780,6 +780,11 @@ function RulesList({ rulesQuery, onEdit }: {
|
|||||||
</div>
|
</div>
|
||||||
) : r.type === 'strategy' && r.strategy_id ? (
|
) : r.type === 'strategy' && r.strategy_id ? (
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
|
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
|
||||||
|
{(r.score_min != null || r.score_max != null) && (
|
||||||
|
<span className="rounded bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-mono text-amber-500 dark:text-amber-300">
|
||||||
|
评分 {r.score_min ?? 0}–{r.score_max ?? 100}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(r.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).map(event => {
|
{(r.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).map(event => {
|
||||||
const option = STRATEGY_NOTIFY_EVENT_OPTIONS.find(item => item.key === event)
|
const option = STRATEGY_NOTIFY_EVENT_OPTIONS.find(item => item.key === event)
|
||||||
return option ? (
|
return option ? (
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function Onboarding() {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
|
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold tracking-tight">TickFlow Stock Panel</span>
|
<span className="text-sm font-semibold tracking-tight">Tick Stock Panel</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 步骤进度条 —— 胶囊式 */}
|
{/* 步骤进度条 —— 胶囊式 */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -237,7 +237,7 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
|
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
|
||||||
欢迎使用 TickFlow Stock Panel
|
欢迎使用 Tick Stock Panel
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
|
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
|
||||||
一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。
|
一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Play, BarChart3, Clock } from 'lucide-react'
|
import { Play, BarChart3, BookmarkPlus, Clock } from 'lucide-react'
|
||||||
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
|
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
|
||||||
import { fmtPct, priceColorClass } from '@/lib/format'
|
import { fmtPct, priceColorClass } from '@/lib/format'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { DatePicker } from '@/components/DatePicker'
|
import { DatePicker } from '@/components/DatePicker'
|
||||||
|
import { toast } from '@/components/Toast'
|
||||||
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { FactorICChart } from './charts/FactorICChart'
|
import { FactorICChart } from './charts/FactorICChart'
|
||||||
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
|
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
|
||||||
|
import { factorResultCandidate } from './researchCandidates'
|
||||||
|
|
||||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
||||||
const monthsAgo = (months: number) => {
|
const monthsAgo = (months: number) => {
|
||||||
@@ -80,8 +83,9 @@ function LoadingPanel({ symbolsText }: { symbolsText: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FactorBacktest() {
|
export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initialFactorName?: string }) {
|
||||||
const [factorName, setFactorName] = useState('momentum_20d')
|
const queryClient = useQueryClient()
|
||||||
|
const [factorName, setFactorName] = useState(initialFactorName)
|
||||||
const [symbols, setSymbols] = useState('')
|
const [symbols, setSymbols] = useState('')
|
||||||
const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock')
|
const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock')
|
||||||
const [start, setStart] = useState(THREE_MONTHS_AGO)
|
const [start, setStart] = useState(THREE_MONTHS_AGO)
|
||||||
@@ -92,7 +96,7 @@ export function FactorBacktest() {
|
|||||||
const [result, setResult] = useState<FactorBacktestResult | null>(null)
|
const [result, setResult] = useState<FactorBacktestResult | null>(null)
|
||||||
|
|
||||||
const columns = useQuery({
|
const columns = useQuery({
|
||||||
queryKey: ['backtest-factor-columns'],
|
queryKey: QK.factorColumns,
|
||||||
queryFn: api.factorColumns,
|
queryFn: api.factorColumns,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -111,6 +115,11 @@ export function FactorBacktest() {
|
|||||||
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
|
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
|
||||||
}, [columns.data, factorName])
|
}, [columns.data, factorName])
|
||||||
|
|
||||||
|
const resultFactorLabel = useMemo(() => {
|
||||||
|
const resultFactorName = String(result?.config.factor_name ?? factorName)
|
||||||
|
return columns.data?.columns.find(c => c.id === resultFactorName)?.label ?? resultFactorName
|
||||||
|
}, [columns.data, factorName, result])
|
||||||
|
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api.factorRun({
|
api.factorRun({
|
||||||
@@ -133,6 +142,18 @@ export function FactorBacktest() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const saveCandidate = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!result) throw new Error('暂无因子结果')
|
||||||
|
return api.researchCandidateCreate(factorResultCandidate(result, resultFactorLabel))
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK.researchCandidates })
|
||||||
|
toast('已保存到候选方案', 'success')
|
||||||
|
},
|
||||||
|
onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
const applyRange = (months: number) => {
|
const applyRange = (months: number) => {
|
||||||
setStart(monthsAgo(months))
|
setStart(monthsAgo(months))
|
||||||
setEnd(formatDate(new Date()))
|
setEnd(formatDate(new Date()))
|
||||||
@@ -291,7 +312,7 @@ export function FactorBacktest() {
|
|||||||
onClick={() => run.mutate()}
|
onClick={() => run.mutate()}
|
||||||
disabled={run.isPending}
|
disabled={run.isPending}
|
||||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
|
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
|
||||||
bg-accent text-sm font-medium hover:bg-accent/90
|
bg-accent text-sm font-medium text-white hover:bg-accent/90
|
||||||
transition-colors duration-150 ease-smooth disabled:opacity-50"
|
transition-colors duration-150 ease-smooth disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
@@ -343,6 +364,15 @@ export function FactorBacktest() {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveCandidate.mutate()}
|
||||||
|
disabled={saveCandidate.isPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded-btn border border-border bg-base/50 px-2 py-1 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-3 w-3" />
|
||||||
|
{saveCandidate.isPending ? '保存中' : '保存候选'}
|
||||||
|
</button>
|
||||||
<span className="text-[11px] text-muted">
|
<span className="text-[11px] text-muted">
|
||||||
Rank IC · 日度调仓
|
Rank IC · 日度调仓
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { BookmarkPlus, ChevronRight, Clock, Layers3, ListFilter, ListPlus, Play, Search } from 'lucide-react'
|
||||||
|
import { DatePicker } from '@/components/DatePicker'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { toast } from '@/components/Toast'
|
||||||
|
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
|
||||||
|
import { api, type FactorBatchItem, type FactorColumn } from '@/lib/api'
|
||||||
|
import { fmtPct, priceColorClass } from '@/lib/format'
|
||||||
|
import { QK } from '@/lib/queryKeys'
|
||||||
|
import { FactorBacktest } from './FactorBacktest'
|
||||||
|
import { factorBatchCandidate } from './researchCandidates'
|
||||||
|
|
||||||
|
const formatDate = (value: Date) => value.toISOString().slice(0, 10)
|
||||||
|
const monthsAgo = (months: number) => {
|
||||||
|
const value = new Date()
|
||||||
|
value.setMonth(value.getMonth() - months)
|
||||||
|
return formatDate(value)
|
||||||
|
}
|
||||||
|
const TODAY = formatDate(new Date())
|
||||||
|
const INPUT_CLS = 'w-full rounded-input border border-border bg-surface px-2.5 py-1.5 text-xs focus:border-accent focus:outline-none'
|
||||||
|
|
||||||
|
type View = 'batch' | 'single'
|
||||||
|
type SortKey = 'ic' | 'ir' | 'return'
|
||||||
|
|
||||||
|
function valueOrBottom(value: number | null) {
|
||||||
|
return value == null || !Number.isFinite(value) ? Number.NEGATIVE_INFINITY : Math.abs(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchDiscovery({ onInspect }: { onInspect: (factorName: string) => void }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const initialized = useRef(false)
|
||||||
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
|
const [symbols, setSymbols] = useState('')
|
||||||
|
const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock')
|
||||||
|
const [start, setStart] = useState(monthsAgo(3))
|
||||||
|
const [end, setEnd] = useState(TODAY)
|
||||||
|
const [nGroups, setNGroups] = useState(5)
|
||||||
|
const [rebalance, setRebalance] = useState<'daily' | 'weekly' | 'monthly'>('daily')
|
||||||
|
const [fees, setFees] = useState('2')
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('ic')
|
||||||
|
|
||||||
|
const columns = useQuery({
|
||||||
|
queryKey: QK.factorColumns,
|
||||||
|
queryFn: api.factorColumns,
|
||||||
|
})
|
||||||
|
const watchlist = useQuery({
|
||||||
|
queryKey: QK.watchlist,
|
||||||
|
queryFn: api.watchlistList,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
const watchlistEntries = watchlist.data?.symbols ?? []
|
||||||
|
const watchlistCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = { ungrouped: 0 }
|
||||||
|
for (const entry of watchlistEntries) {
|
||||||
|
const groupId = entry.group_id ?? 'ungrouped'
|
||||||
|
counts[groupId] = (counts[groupId] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [watchlistEntries])
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current || !columns.data?.columns.length) return
|
||||||
|
initialized.current = true
|
||||||
|
setSelected(columns.data.columns.map(column => column.id))
|
||||||
|
}, [columns.data])
|
||||||
|
|
||||||
|
const factorGroups = useMemo(() => {
|
||||||
|
const groups: Record<string, FactorColumn[]> = {}
|
||||||
|
for (const column of columns.data?.columns ?? []) {
|
||||||
|
;(groups[column.group] ??= []).push(column)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}, [columns.data])
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: () => api.factorBatch({
|
||||||
|
factor_names: selected,
|
||||||
|
symbols: symbols ? symbols.split(',').map(value => value.trim()).filter(Boolean) : null,
|
||||||
|
asset_type: assetType,
|
||||||
|
start: start || null,
|
||||||
|
end: end || null,
|
||||||
|
n_groups: nGroups,
|
||||||
|
rebalance,
|
||||||
|
fees_pct: Number(fees) / 10000,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (item: FactorBatchItem) => {
|
||||||
|
if (!run.data) throw new Error('暂无批量结果')
|
||||||
|
return api.researchCandidateCreate(factorBatchCandidate(run.data, item))
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK.researchCandidates })
|
||||||
|
toast('已保存到候选方案', 'success')
|
||||||
|
},
|
||||||
|
onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedResults = useMemo(() => {
|
||||||
|
const values = [...(run.data?.results ?? [])]
|
||||||
|
const getter = sortKey === 'ic'
|
||||||
|
? (item: FactorBatchItem) => valueOrBottom(item.ic_mean)
|
||||||
|
: sortKey === 'ir'
|
||||||
|
? (item: FactorBatchItem) => valueOrBottom(item.ir)
|
||||||
|
: (item: FactorBatchItem) => valueOrBottom(item.long_short_return)
|
||||||
|
return values.sort((left, right) => getter(right) - getter(left))
|
||||||
|
}, [run.data, sortKey])
|
||||||
|
|
||||||
|
const toggleFactor = (factorName: string) => {
|
||||||
|
setSelected(current => current.includes(factorName)
|
||||||
|
? current.filter(name => name !== factorName)
|
||||||
|
: [...current, factorName])
|
||||||
|
}
|
||||||
|
const toggleGroup = (items: FactorColumn[]) => {
|
||||||
|
const ids = items.map(item => item.id)
|
||||||
|
const allSelected = ids.every(id => selected.includes(id))
|
||||||
|
setSelected(current => allSelected
|
||||||
|
? current.filter(id => !ids.includes(id))
|
||||||
|
: [...current, ...ids.filter(id => !current.includes(id))]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const importFromWatchlist = (groupId: string | null) => {
|
||||||
|
const entries = groupId === 'all'
|
||||||
|
? watchlistEntries
|
||||||
|
: watchlistEntries.filter(entry => (entry.group_id ?? null) === groupId)
|
||||||
|
const current = symbols.split(',').map(value => value.trim()).filter(Boolean)
|
||||||
|
setSymbols(Array.from(new Set([...current, ...entries.map(entry => entry.symbol)])).join(','))
|
||||||
|
}
|
||||||
|
const allColumns = columns.data?.columns ?? []
|
||||||
|
const allSelected = allColumns.length > 0 && allColumns.every(item => selected.includes(item.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full min-h-0 grid-cols-1 overflow-hidden rounded-card border border-border bg-surface/80 xl:grid-cols-[18rem_minmax(0,1fr)]">
|
||||||
|
<section className="space-y-3 border-b border-border bg-base/25 px-3 py-3 xl:overflow-y-auto xl:border-b-0 xl:border-r">
|
||||||
|
<div className="flex items-center justify-between border-b border-border/70 pb-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-foreground">筛选配置</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-muted">已选 {selected.length} / {allColumns.length}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(allSelected ? [] : allColumns.map(item => item.id))}
|
||||||
|
className="rounded-btn px-2 py-1 text-[10px] text-accent transition-colors hover:bg-accent/10"
|
||||||
|
>
|
||||||
|
{allSelected ? '清空' : '全选'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(factorGroups).map(([group, items]) => {
|
||||||
|
const groupSelected = items.filter(item => selected.includes(item.id)).length
|
||||||
|
return (
|
||||||
|
<div key={group} className="border-b border-border/50 pb-2 last:border-b-0">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium text-secondary">{group}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGroup(items)}
|
||||||
|
className="text-[9px] text-muted transition-colors hover:text-accent"
|
||||||
|
>
|
||||||
|
{groupSelected === items.length ? '取消本组' : `选择本组 ${groupSelected}/${items.length}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
|
||||||
|
{items.map(item => (
|
||||||
|
<label key={item.id} className="flex min-w-0 cursor-pointer items-center gap-1.5 text-[10px] text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(item.id)}
|
||||||
|
onChange={() => toggleFactor(item.id)}
|
||||||
|
className="h-3 w-3 shrink-0 accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="truncate" title={item.desc}>{item.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-secondary">资产与范围</label>
|
||||||
|
<div className="mb-2 inline-flex h-8 overflow-hidden rounded-btn border border-border">
|
||||||
|
{(['stock', 'etf'] as const).map(value => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAssetType(value); setSymbols('') }}
|
||||||
|
className={`h-full px-3 text-xs font-medium transition-colors ${assetType === value
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value === 'stock' ? '股票' : 'ETF'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={symbols}
|
||||||
|
onChange={event => setSymbols(event.target.value)}
|
||||||
|
placeholder="留空使用全市场"
|
||||||
|
className={`${INPUT_CLS} min-w-0 flex-1 font-mono`}
|
||||||
|
/>
|
||||||
|
<WatchlistGroupMenu
|
||||||
|
onSelect={importFromWatchlist}
|
||||||
|
disabled={watchlist.isLoading || watchlistEntries.length === 0}
|
||||||
|
includeAll
|
||||||
|
counts={watchlistCounts}
|
||||||
|
total={watchlistEntries.length}
|
||||||
|
disableEmpty
|
||||||
|
menuLabel="选择自选分组"
|
||||||
|
align="right"
|
||||||
|
triggerClassName="inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title="从自选分组加入筛选范围"
|
||||||
|
ariaLabel="从自选加入筛选范围"
|
||||||
|
>
|
||||||
|
<ListPlus className="h-3 w-3" />
|
||||||
|
{watchlist.isLoading ? '加载中' : watchlistEntries.length === 0 ? '自选为空' : '从自选加入'}
|
||||||
|
</WatchlistGroupMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-btn border border-border bg-surface p-2.5">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] text-secondary">开始</label>
|
||||||
|
<DatePicker value={start} onChange={setStart} max={end || undefined} className="w-full" buttonClassName="w-full justify-start" align="left" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] text-secondary">结束</label>
|
||||||
|
<DatePicker value={end} onChange={setEnd} min={start || undefined} className="w-full" buttonClassName="w-full justify-start" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex rounded-input bg-base/60 p-0.5">
|
||||||
|
{[3, 6, 12].map(months => (
|
||||||
|
<button
|
||||||
|
key={months}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStart(monthsAgo(months)); setEnd(TODAY) }}
|
||||||
|
className="flex-1 rounded-btn px-2 py-1 text-[10px] text-muted transition-colors hover:bg-elevated hover:text-secondary"
|
||||||
|
>
|
||||||
|
{months === 12 ? '1年' : `${months}个月`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[10px] text-muted">调仓</span>
|
||||||
|
<select value={rebalance} onChange={event => setRebalance(event.target.value as typeof rebalance)} className={INPUT_CLS}>
|
||||||
|
<option value="daily">日度</option>
|
||||||
|
<option value="weekly">周度</option>
|
||||||
|
<option value="monthly">月度</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[10px] text-muted">分组</span>
|
||||||
|
<select value={nGroups} onChange={event => setNGroups(Number(event.target.value))} className={INPUT_CLS}>
|
||||||
|
<option value={3}>3组</option>
|
||||||
|
<option value={5}>5组</option>
|
||||||
|
<option value={10}>10组</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[10px] text-muted">佣金/万</span>
|
||||||
|
<input type="number" value={fees} onChange={event => setFees(event.target.value)} className={INPUT_CLS} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => run.mutate()}
|
||||||
|
disabled={run.isPending || selected.length === 0}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-1.5 rounded-btn bg-accent px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
{run.isPending ? '筛选中…' : `筛选 ${selected.length} 个因子`}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-w-0 bg-base/15 xl:overflow-y-auto">
|
||||||
|
{run.isPending && (
|
||||||
|
<div className="m-3 flex items-center gap-3 rounded-btn border border-accent/30 bg-accent/5 px-3 py-2.5 text-xs text-secondary">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-accent/25 border-t-accent" />
|
||||||
|
正在加载共享数据面板并评估 {selected.length} 个因子
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{run.isError && (
|
||||||
|
<div className="m-3 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||||
|
{String((run.error as Error).message)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{run.data?.error && (
|
||||||
|
<div className="m-3 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{run.data.error}</div>
|
||||||
|
)}
|
||||||
|
{!run.data && !run.isPending && (
|
||||||
|
<EmptyState icon={Search} title="运行因子筛选" hint="批量结果将按预测能力排序。" />
|
||||||
|
)}
|
||||||
|
{run.data && !run.data.error && (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground">筛选结果</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-muted">
|
||||||
|
<span>{run.data.results.length} 个因子</span>
|
||||||
|
<span>{run.data.n_symbols} 只标的</span>
|
||||||
|
<span>{run.data.n_dates} 个交易日</span>
|
||||||
|
<span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" />{run.data.elapsed_ms.toFixed(0)} ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="ml-auto flex items-center gap-2 text-[11px] text-muted">
|
||||||
|
排序
|
||||||
|
<select value={sortKey} onChange={event => setSortKey(event.target.value as SortKey)} className="h-8 rounded-input border border-border bg-surface px-2 text-xs text-secondary focus:border-accent focus:outline-none">
|
||||||
|
<option value="ic">|IC|</option>
|
||||||
|
<option value="ir">|IR|</option>
|
||||||
|
<option value="return">|多空收益|</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[820px] text-xs">
|
||||||
|
<thead className="sticky top-0 bg-elevated text-left text-[11px] text-secondary">
|
||||||
|
<tr>
|
||||||
|
<th className="w-12 px-3 py-2.5 text-center font-medium">排名</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium">因子</th>
|
||||||
|
<th className="px-3 py-2.5 text-right font-medium">IC 均值</th>
|
||||||
|
<th className="px-3 py-2.5 text-right font-medium">IR</th>
|
||||||
|
<th className="px-3 py-2.5 text-right font-medium">IC 胜率</th>
|
||||||
|
<th className="px-3 py-2.5 text-right font-medium">多空收益</th>
|
||||||
|
<th className="px-3 py-2.5 text-right font-medium">最大回撤</th>
|
||||||
|
<th className="w-24 px-3 py-2.5 text-right font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedResults.map((item, index) => (
|
||||||
|
<tr key={item.factor_name} className="border-t border-border/70 transition-colors hover:bg-elevated/40">
|
||||||
|
<td className="px-3 py-3 text-center font-mono text-muted">{index + 1}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="font-medium text-foreground">{item.label}</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-muted">{item.group} · {item.factor_name}</div>
|
||||||
|
{item.error && <div className="mt-1 text-[10px] text-danger">{item.error}</div>}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-3 text-right font-mono ${priceColorClass(item.ic_mean)}`}>{item.ic_mean == null ? '—' : fmtPct(item.ic_mean)}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono text-foreground">{item.ir == null ? '—' : item.ir.toFixed(2)}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono text-secondary">{item.ic_win_rate == null ? '—' : fmtPct(item.ic_win_rate)}</td>
|
||||||
|
<td className={`px-3 py-3 text-right font-mono ${priceColorClass(item.long_short_return)}`}>{item.long_short_return == null ? '—' : fmtPct(item.long_short_return)}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono text-bear">{item.long_short_max_drawdown == null ? '—' : fmtPct(item.long_short_max_drawdown)}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => save.mutate(item)}
|
||||||
|
disabled={!!item.error || save.isPending}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-40"
|
||||||
|
title="保存候选"
|
||||||
|
aria-label={`保存 ${item.label} 为候选`}
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInspect(item.factor_name)}
|
||||||
|
disabled={!!item.error}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-btn text-muted transition-colors hover:bg-elevated hover:text-foreground disabled:opacity-40"
|
||||||
|
title="单因子检验"
|
||||||
|
aria-label={`查看 ${item.label} 的单因子检验`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FactorDiscovery() {
|
||||||
|
const [view, setView] = useState<View>('batch')
|
||||||
|
const [detailFactor, setDetailFactor] = useState('momentum_20d')
|
||||||
|
const inspect = (factorName: string) => {
|
||||||
|
setDetailFactor(factorName)
|
||||||
|
setView('single')
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
|
<div className="flex shrink-0 items-center border-b border-border/70 px-1 pb-2">
|
||||||
|
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5">
|
||||||
|
{([
|
||||||
|
['batch', '批量筛选', ListFilter],
|
||||||
|
['single', '单因子检验', Layers3],
|
||||||
|
] as const).map(([value, label, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(value)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors ${view === value
|
||||||
|
? 'bg-accent text-white shadow-sm'
|
||||||
|
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{view === 'batch'
|
||||||
|
? <BatchDiscovery onInspect={inspect} />
|
||||||
|
: <FactorBacktest key={detailFactor} initialFactorName={detailFactor} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { ArrowDown, ArrowUp, BookmarkCheck, CheckCircle2, Clock3, Link2, Loader2, Trash2, X, XCircle } from 'lucide-react'
|
||||||
|
import { Modal } from '@/components/Modal'
|
||||||
|
import { toast } from '@/components/Toast'
|
||||||
|
import { api, type ResearchCandidate, type ResearchCandidateStatus, type ScoringDirection } from '@/lib/api'
|
||||||
|
import { fmtPct } from '@/lib/format'
|
||||||
|
import { QK } from '@/lib/queryKeys'
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: ResearchCandidateStatus; label: string; icon: typeof Clock3 }[] = [
|
||||||
|
{ value: 'pending', label: '待验证', icon: Clock3 },
|
||||||
|
{ value: 'validated', label: '已验证', icon: CheckCircle2 },
|
||||||
|
{ value: 'rejected', label: '已排除', icon: XCircle },
|
||||||
|
]
|
||||||
|
|
||||||
|
type LinkDraft = {
|
||||||
|
candidateId: string
|
||||||
|
strategyId: string
|
||||||
|
direction: ScoringDirection
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricSummary(item: ResearchCandidate) {
|
||||||
|
const metric = (key: string) => {
|
||||||
|
const value = item.metrics[key]
|
||||||
|
return typeof value === 'number' ? value : null
|
||||||
|
}
|
||||||
|
if (item.kind === 'factor') {
|
||||||
|
const ic = metric('ic_mean')
|
||||||
|
const ir = metric('ir')
|
||||||
|
return [
|
||||||
|
ic == null ? null : `IC ${fmtPct(ic)}`,
|
||||||
|
ir == null ? null : `IR ${ir.toFixed(2)}`,
|
||||||
|
].filter(Boolean).join(' · ') || '暂无指标摘要'
|
||||||
|
}
|
||||||
|
const totalReturn = metric('total_return')
|
||||||
|
const sharpe = metric('sharpe')
|
||||||
|
return [
|
||||||
|
totalReturn == null ? null : `收益 ${fmtPct(totalReturn)}`,
|
||||||
|
sharpe == null ? null : `夏普 ${sharpe.toFixed(2)}`,
|
||||||
|
].filter(Boolean).join(' · ') || '暂无指标摘要'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResearchCandidatesDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [kind, setKind] = useState<'all' | 'factor' | 'strategy'>('all')
|
||||||
|
const [linkDraft, setLinkDraft] = useState<LinkDraft | null>(null)
|
||||||
|
const candidates = useQuery({ queryKey: QK.researchCandidates, queryFn: api.researchCandidates })
|
||||||
|
const strategies = useQuery({ queryKey: QK.strategyLinkOptions, queryFn: () => api.strategyList() })
|
||||||
|
const factorColumns = useQuery({ queryKey: QK.factorColumns, queryFn: api.factorColumns })
|
||||||
|
const supportedFactors = useMemo(
|
||||||
|
() => new Set((factorColumns.data?.columns ?? []).map(item => item.id)),
|
||||||
|
[factorColumns.data],
|
||||||
|
)
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const items = candidates.data?.items ?? []
|
||||||
|
return kind === 'all' ? items : items.filter(item => item.kind === kind)
|
||||||
|
}, [candidates.data, kind])
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: ResearchCandidateStatus }) =>
|
||||||
|
api.researchCandidateUpdate(id, { status }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: QK.researchCandidates }),
|
||||||
|
onError: error => toast(`更新失败 · ${String((error as Error).message || error)}`, 'error'),
|
||||||
|
})
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: api.researchCandidateDelete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK.researchCandidates })
|
||||||
|
toast('候选方案已删除', 'success')
|
||||||
|
},
|
||||||
|
onError: error => toast(`删除失败 · ${String((error as Error).message || error)}`, 'error'),
|
||||||
|
})
|
||||||
|
const applyFactor = useMutation({
|
||||||
|
mutationFn: async ({ candidate, draft }: { candidate: ResearchCandidate; draft: LinkDraft }) => {
|
||||||
|
const detail = await api.strategyGet(draft.strategyId)
|
||||||
|
const factorName = candidate.source_id
|
||||||
|
const targetWeight = Math.min(100, Math.max(1, draft.weight)) / 100
|
||||||
|
const otherEntries = Object.entries(detail.scoring)
|
||||||
|
.filter(([name, weight]) => name !== factorName && Number(weight) > 0)
|
||||||
|
const otherTotal = otherEntries.reduce((sum, [, weight]) => sum + Number(weight), 0)
|
||||||
|
const scoring = otherTotal > 0
|
||||||
|
? Object.fromEntries([
|
||||||
|
...otherEntries.map(([name, weight]) => [name, +(Number(weight) / otherTotal * (1 - targetWeight)).toFixed(6)]),
|
||||||
|
[factorName, targetWeight],
|
||||||
|
])
|
||||||
|
: { [factorName]: 1 }
|
||||||
|
await api.strategyPatchConfig(draft.strategyId, {
|
||||||
|
scoring,
|
||||||
|
scoring_directions: {
|
||||||
|
...(detail.scoring_directions ?? {}),
|
||||||
|
[factorName]: draft.direction,
|
||||||
|
},
|
||||||
|
scoring_replace: true,
|
||||||
|
})
|
||||||
|
return { strategyId: draft.strategyId, strategyName: detail.name }
|
||||||
|
},
|
||||||
|
onSuccess: result => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK.strategyDetail(result.strategyId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['screener-strategies'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK.strategyLinkOptions })
|
||||||
|
setLinkDraft(null)
|
||||||
|
toast(`已加入“${result.strategyName}”评分方案`, 'success')
|
||||||
|
},
|
||||||
|
onError: error => toast(`加入失败 · ${String((error as Error).message || error)}`, 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const compatibleStrategies = (item: ResearchCandidate) => {
|
||||||
|
const assetType = typeof item.config.asset_type === 'string' ? item.config.asset_type : null
|
||||||
|
return (strategies.data?.strategies ?? []).filter(strategy => (
|
||||||
|
strategy.execution_backend !== 'composite'
|
||||||
|
&& (!assetType || strategy.asset_types.includes(assetType))
|
||||||
|
&& strategy.timeframes.includes('1d')
|
||||||
|
))
|
||||||
|
}
|
||||||
|
const startLink = (item: ResearchCandidate) => {
|
||||||
|
const options = compatibleStrategies(item)
|
||||||
|
const ic = typeof item.metrics.ic_mean === 'number' ? item.metrics.ic_mean : null
|
||||||
|
setLinkDraft({
|
||||||
|
candidateId: item.id,
|
||||||
|
strategyId: options[0]?.id ?? '',
|
||||||
|
direction: ic != null && ic < 0 ? 'low' : 'high',
|
||||||
|
weight: 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy="research-candidates-title"
|
||||||
|
panelClassName="flex max-h-[82vh] w-[94vw] max-w-4xl flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<BookmarkCheck className="h-4 w-4 text-accent" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 id="research-candidates-title" className="text-sm font-semibold text-foreground">候选方案</h2>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted">仅保存研究定义、配置与指标摘要</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto flex h-8 w-8 items-center justify-center rounded-btn text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
||||||
|
title="关闭"
|
||||||
|
aria-label="关闭候选方案"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1 border-b border-border bg-surface/50 px-4 py-2">
|
||||||
|
{([['all', '全部'], ['factor', '因子'], ['strategy', '策略']] as const).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKind(value)}
|
||||||
|
aria-pressed={kind === value}
|
||||||
|
className={`rounded-btn px-3 py-1.5 text-xs font-medium transition-colors ${kind === value
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto text-[11px] text-muted">{visible.length} 个</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{candidates.isLoading && (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-muted">加载中…</div>
|
||||||
|
)}
|
||||||
|
{candidates.isError && (
|
||||||
|
<div className="m-4 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||||
|
{String((candidates.error as Error).message)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!candidates.isLoading && !candidates.isError && visible.length === 0 && (
|
||||||
|
<div className="px-4 py-12 text-center">
|
||||||
|
<BookmarkCheck className="mx-auto h-7 w-7 text-muted/50" />
|
||||||
|
<div className="mt-3 text-sm font-medium text-secondary">暂无候选方案</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.map(item => {
|
||||||
|
const linking = linkDraft?.candidateId === item.id
|
||||||
|
const options = compatibleStrategies(item)
|
||||||
|
const factorSupported = supportedFactors.has(item.source_id)
|
||||||
|
const canLink = item.kind === 'factor' && item.status === 'validated' && factorSupported
|
||||||
|
const ic = typeof item.metrics.ic_mean === 'number' ? item.metrics.ic_mean : null
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="border-b border-border/70 last:border-b-0">
|
||||||
|
<div className="grid grid-cols-1 gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_8rem_7rem] md:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-medium ${item.kind === 'factor'
|
||||||
|
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-400'
|
||||||
|
: 'border-amber-400/30 bg-amber-400/10 text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{item.kind === 'factor' ? '因子' : '策略'}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||||
|
<span>{metricSummary(item)}</span>
|
||||||
|
{item.data_as_of && <span>数据截至 {item.data_as_of}</span>}
|
||||||
|
{item.kind === 'factor' && !factorSupported && !factorColumns.isLoading && (
|
||||||
|
<span className="text-danger">当前因子目录不支持</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={item.status}
|
||||||
|
onChange={event => update.mutate({ id: item.id, status: event.target.value as ResearchCandidateStatus })}
|
||||||
|
disabled={update.isPending}
|
||||||
|
aria-label={`更新 ${item.name} 的状态`}
|
||||||
|
className="h-8 rounded-input border border-border bg-surface px-2 text-xs text-secondary focus:border-accent focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{item.kind === 'factor' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => linking ? setLinkDraft(null) : startLink(item)}
|
||||||
|
disabled={!canLink || strategies.isLoading || factorColumns.isLoading}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-btn px-2 text-[11px] text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:text-muted disabled:opacity-50"
|
||||||
|
title={item.status !== 'validated' ? '验证通过后可加入策略' : factorSupported ? '加入策略评分' : '当前因子不可用于策略评分'}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
加入策略
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`确认删除候选方案“${item.name}”?`)) remove.mutate(item.id)
|
||||||
|
}}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-btn text-muted transition-colors hover:bg-danger/10 hover:text-danger disabled:opacity-50"
|
||||||
|
title="删除候选"
|
||||||
|
aria-label={`删除候选 ${item.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{linking && linkDraft && (
|
||||||
|
<div className="grid gap-3 border-t border-border/60 bg-surface/45 px-4 py-3 md:grid-cols-[minmax(12rem,1fr)_9rem_8rem_auto] md:items-end">
|
||||||
|
<label className="block min-w-0">
|
||||||
|
<span className="mb-1 block text-[10px] text-muted">目标策略</span>
|
||||||
|
<select
|
||||||
|
value={linkDraft.strategyId}
|
||||||
|
onChange={event => setLinkDraft(current => current ? { ...current, strategyId: event.target.value } : current)}
|
||||||
|
aria-label="目标策略"
|
||||||
|
className="h-8 w-full rounded-input border border-border bg-base px-2 text-xs text-secondary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{options.length === 0 && <option value="">没有兼容策略</option>}
|
||||||
|
{options.map(strategy => (
|
||||||
|
<option key={strategy.id} value={strategy.id}>{strategy.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-[10px] text-muted">评分方向</span>
|
||||||
|
<div className="grid h-8 grid-cols-2 overflow-hidden rounded-input border border-border bg-base">
|
||||||
|
{([['high', ArrowUp, '高值'], ['low', ArrowDown, '低值']] as const).map(([value, Icon, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLinkDraft(current => current ? { ...current, direction: value } : current)}
|
||||||
|
className={`flex items-center justify-center gap-1 text-[11px] transition-colors ${linkDraft.direction === value
|
||||||
|
? value === 'high' ? 'bg-emerald-400/15 text-emerald-400' : 'bg-cyan-400/15 text-cyan-400'
|
||||||
|
: 'text-muted hover:bg-elevated'
|
||||||
|
}`}
|
||||||
|
title={`偏好${label}`}
|
||||||
|
aria-label={`评分方向:偏好${label}`}
|
||||||
|
aria-pressed={linkDraft.direction === value}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 flex items-center justify-between text-[10px] text-muted">
|
||||||
|
初始权重
|
||||||
|
{ic != null && <span>{ic < 0 ? 'IC<0 推荐低值' : 'IC≥0 推荐高值'}</span>}
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={linkDraft.weight}
|
||||||
|
aria-label="因子初始权重百分比"
|
||||||
|
onChange={event => setLinkDraft(current => current ? {
|
||||||
|
...current,
|
||||||
|
weight: Math.min(100, Math.max(1, Number(event.target.value) || 1)),
|
||||||
|
} : current)}
|
||||||
|
className="h-8 w-full rounded-input border border-border bg-base px-2 pr-7 text-xs text-foreground focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-muted">%</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLinkDraft(null)}
|
||||||
|
className="h-8 rounded-btn px-2.5 text-xs text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFactor.mutate({ candidate: item, draft: linkDraft })}
|
||||||
|
disabled={!linkDraft.strategyId || applyFactor.isPending}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-btn bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{applyFactor.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SlidersHorizontal, Waypoints } from 'lucide-react'
|
||||||
|
import { StrategyOptimizer } from './StrategyOptimizer'
|
||||||
|
import { StrategyWalkForward } from './StrategyWalkForward'
|
||||||
|
|
||||||
|
type Mode = 'sensitivity' | 'walkforward'
|
||||||
|
|
||||||
|
export function RobustnessValidation() {
|
||||||
|
const [mode, setMode] = useState<Mode>('sensitivity')
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
|
<div className="flex shrink-0 items-center border-b border-border/70 px-1 pb-2">
|
||||||
|
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5">
|
||||||
|
{([
|
||||||
|
['sensitivity', '参数优化', SlidersHorizontal],
|
||||||
|
['walkforward', '步进优化', Waypoints],
|
||||||
|
] as const).map(([value, label, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors ${mode === value
|
||||||
|
? 'bg-accent text-white shadow-sm'
|
||||||
|
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{mode === 'sensitivity' ? <StrategyOptimizer /> : <StrategyWalkForward />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers } from 'lucide-react'
|
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers, BookmarkPlus } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type StrategyBacktestResult,
|
type StrategyBacktestResult,
|
||||||
type StrategyBacktestTrade,
|
type StrategyBacktestTrade,
|
||||||
type StrategyDetail,
|
type StrategyDetail,
|
||||||
type StrategyParamDef,
|
type StrategyParamDef,
|
||||||
|
type ScoringDirection,
|
||||||
REGIME_STATE_LABELS,
|
REGIME_STATE_LABELS,
|
||||||
REGIME_STATE_COLORS,
|
REGIME_STATE_COLORS,
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
@@ -30,6 +31,8 @@ import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
|
|||||||
import { TradeKlineModal } from './components/TradeKlineModal'
|
import { TradeKlineModal } from './components/TradeKlineModal'
|
||||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||||
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
|
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
|
||||||
|
import { ScoringEditor } from '@/components/ScoringEditor'
|
||||||
|
import { strategyResultCandidate } from './researchCandidates'
|
||||||
|
|
||||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
||||||
const monthsAgo = (months: number) => {
|
const monthsAgo = (months: number) => {
|
||||||
@@ -270,6 +273,15 @@ const mergeStrategyParams = (detail: StrategyDetail, values?: Record<string, any
|
|||||||
})
|
})
|
||||||
const normalizeStrategyOverrides = (detail: StrategyDetail, values?: Record<string, any> | null) => {
|
const normalizeStrategyOverrides = (detail: StrategyDetail, values?: Record<string, any> | null) => {
|
||||||
const next = { ...(values ?? {}) }
|
const next = { ...(values ?? {}) }
|
||||||
|
const savedScoring = next.scoring && typeof next.scoring === 'object' ? next.scoring : {}
|
||||||
|
next.scoring = next.scoring_replace === true
|
||||||
|
? { ...savedScoring }
|
||||||
|
: { ...detail.scoring, ...savedScoring }
|
||||||
|
next.scoring_directions = {
|
||||||
|
...(detail.scoring_directions ?? {}),
|
||||||
|
...(next.scoring_directions ?? {}),
|
||||||
|
}
|
||||||
|
next.scoring_replace = true
|
||||||
if (detail.execution_backend === 'matrix_native') {
|
if (detail.execution_backend === 'matrix_native') {
|
||||||
// MatrixStrategy.compute_signals() owns entry/exit formulas. Remove both
|
// MatrixStrategy.compute_signals() owns entry/exit formulas. Remove both
|
||||||
// current and legacy persisted column overrides before any request.
|
// current and legacy persisted column overrides before any request.
|
||||||
@@ -283,6 +295,8 @@ const buildDefaultOverrides = (detail: StrategyDetail) => normalizeStrategyOverr
|
|||||||
entry_signals: detail.entry_signals.map(toSignalId),
|
entry_signals: detail.entry_signals.map(toSignalId),
|
||||||
exit_signals: detail.exit_signals.map(toSignalId),
|
exit_signals: detail.exit_signals.map(toSignalId),
|
||||||
scoring: { ...detail.scoring },
|
scoring: { ...detail.scoring },
|
||||||
|
scoring_directions: { ...(detail.scoring_directions ?? {}) },
|
||||||
|
scoring_replace: true,
|
||||||
stop_loss: detail.stop_loss,
|
stop_loss: detail.stop_loss,
|
||||||
take_profit: detail.take_profit,
|
take_profit: detail.take_profit,
|
||||||
trailing_stop: detail.trailing_stop,
|
trailing_stop: detail.trailing_stop,
|
||||||
@@ -299,6 +313,7 @@ const strategyBacktestConfigSignature = (detail: StrategyDetail) => JSON.stringi
|
|||||||
params: detail.params,
|
params: detail.params,
|
||||||
params_defaults: detail.params_defaults,
|
params_defaults: detail.params_defaults,
|
||||||
scoring: detail.scoring,
|
scoring: detail.scoring,
|
||||||
|
scoring_directions: detail.scoring_directions,
|
||||||
entry_signals: detail.entry_signals,
|
entry_signals: detail.entry_signals,
|
||||||
exit_signals: detail.exit_signals,
|
exit_signals: detail.exit_signals,
|
||||||
stop_loss: detail.stop_loss,
|
stop_loss: detail.stop_loss,
|
||||||
@@ -662,49 +677,6 @@ function ConfigSection({ title, hint, actions, children }: { title: string; hint
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const scoringToPct = (values: Record<string, number>) => {
|
|
||||||
const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
|
|
||||||
if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record<string, number>
|
|
||||||
return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Math.round((Math.max(0, Number(v) || 0) / total) * 100)])) as Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizePctWeights = (values: Record<string, number>) => {
|
|
||||||
const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
|
|
||||||
if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record<string, number>
|
|
||||||
return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, +(Math.max(0, Number(v) || 0) / total).toFixed(4)])) as Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScoringWeightRow({ name, weight, pct, editing, onChange }: {
|
|
||||||
name: string
|
|
||||||
weight: number
|
|
||||||
pct: number
|
|
||||||
editing: boolean
|
|
||||||
onChange: (value: number) => void
|
|
||||||
}) {
|
|
||||||
const label = FIELD_LABEL[name] ?? name
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="w-20 shrink-0 truncate text-right text-[11px] text-secondary" title={name}>{label}</span>
|
|
||||||
{editing ? (
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={weight}
|
|
||||||
onChange={e => onChange(Number(e.target.value))}
|
|
||||||
className="h-1 flex-1 cursor-pointer accent-amber-400"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-elevated">
|
|
||||||
<div className="h-full rounded-full bg-amber-400/70 transition-all duration-300" style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="w-10 text-right font-mono text-[10px] text-muted">{editing ? weight : `${pct}%`}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StrategyParamInput({ param, value, onChange }: {
|
function StrategyParamInput({ param, value, onChange }: {
|
||||||
param: StrategyParamDef
|
param: StrategyParamDef
|
||||||
value: any
|
value: any
|
||||||
@@ -922,6 +894,7 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StrategyBacktest() {
|
export function StrategyBacktest() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const signalNames = useSignalNames()
|
const signalNames = useSignalNames()
|
||||||
const [saved] = useState(() => storage.strategyBacktestLast.get(null))
|
const [saved] = useState(() => storage.strategyBacktestLast.get(null))
|
||||||
const [selectedStrategy, setSelectedStrategy] = useState<string | null>(saved?.selectedStrategy ?? null)
|
const [selectedStrategy, setSelectedStrategy] = useState<string | null>(saved?.selectedStrategy ?? null)
|
||||||
@@ -963,8 +936,6 @@ export function StrategyBacktest() {
|
|||||||
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
|
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
|
||||||
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
|
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
|
||||||
const [settingsTab, setSettingsTab] = useState<AdvancedSettingsTab>('params')
|
const [settingsTab, setSettingsTab] = useState<AdvancedSettingsTab>('params')
|
||||||
const [editingScoring, setEditingScoring] = useState(false)
|
|
||||||
const [scoringDraft, setScoringDraft] = useState<Record<string, number>>({})
|
|
||||||
const [strategyParams, setStrategyParams] = useState<Record<string, any>>(saved?.params ?? {})
|
const [strategyParams, setStrategyParams] = useState<Record<string, any>>(saved?.params ?? {})
|
||||||
const [overrides, setOverrides] = useState<Record<string, any>>(saved?.overrides ?? {})
|
const [overrides, setOverrides] = useState<Record<string, any>>(saved?.overrides ?? {})
|
||||||
// result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时,
|
// result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时,
|
||||||
@@ -982,12 +953,10 @@ export function StrategyBacktest() {
|
|||||||
queryKey: QK.screenerStrategies(assetType),
|
queryKey: QK.screenerStrategies(assetType),
|
||||||
queryFn: () => api.screenerStrategies(assetType),
|
queryFn: () => api.screenerStrategies(assetType),
|
||||||
})
|
})
|
||||||
|
|
||||||
const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data])
|
const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data])
|
||||||
const filteredStrategyList = useMemo(() => (
|
const filteredStrategyList = useMemo(() => (
|
||||||
strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup)
|
strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup)
|
||||||
), [strategyGroup, strategyList])
|
), [strategyGroup, strategyList])
|
||||||
|
|
||||||
// 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略
|
// 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略
|
||||||
// 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效,
|
// 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效,
|
||||||
// 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物,
|
// 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物,
|
||||||
@@ -1010,6 +979,17 @@ export function StrategyBacktest() {
|
|||||||
|
|
||||||
const backtestTask = useBacktestTask()
|
const backtestTask = useBacktestTask()
|
||||||
const isPending = backtestTask?.isPending ?? false
|
const isPending = backtestTask?.isPending ?? false
|
||||||
|
const saveCandidate = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!result) throw new Error('暂无策略结果')
|
||||||
|
return api.researchCandidateCreate(strategyResultCandidate(result))
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK.researchCandidates })
|
||||||
|
toast('已保存到候选方案', 'success')
|
||||||
|
},
|
||||||
|
onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
const dataStatus = useDataStatus()
|
const dataStatus = useDataStatus()
|
||||||
const backtestDataStatus = assetType === 'etf'
|
const backtestDataStatus = assetType === 'etf'
|
||||||
@@ -1309,6 +1289,10 @@ export function StrategyBacktest() {
|
|||||||
}, [exitFill, highGranularity, minuteExitTriggerSupported])
|
}, [exitFill, highGranularity, minuteExitTriggerSupported])
|
||||||
|
|
||||||
const scoring = useMemo(() => (overrides.scoring ?? {}) as Record<string, number>, [overrides.scoring])
|
const scoring = useMemo(() => (overrides.scoring ?? {}) as Record<string, number>, [overrides.scoring])
|
||||||
|
const scoringDirections = useMemo(
|
||||||
|
() => (overrides.scoring_directions ?? {}) as Record<string, ScoringDirection>,
|
||||||
|
[overrides.scoring_directions],
|
||||||
|
)
|
||||||
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
|
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
|
||||||
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
|
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
|
||||||
const stopLossPct = overrides.stop_loss == null ? '' : String(round4(Math.abs(Number(overrides.stop_loss)) * 100))
|
const stopLossPct = overrides.stop_loss == null ? '' : String(round4(Math.abs(Number(overrides.stop_loss)) * 100))
|
||||||
@@ -1319,10 +1303,6 @@ export function StrategyBacktest() {
|
|||||||
const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days)
|
const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days)
|
||||||
const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0
|
const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editingScoring) setScoringDraft(scoringToPct(scoring))
|
|
||||||
}, [scoring, editingScoring])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (matrixStrategy && (settingsTab === 'entry' || settingsTab === 'exit')) {
|
if (matrixStrategy && (settingsTab === 'entry' || settingsTab === 'exit')) {
|
||||||
setSettingsTab('params')
|
setSettingsTab('params')
|
||||||
@@ -1335,18 +1315,6 @@ export function StrategyBacktest() {
|
|||||||
const updateBasicFilter = (key: string, value: any) => {
|
const updateBasicFilter = (key: string, value: any) => {
|
||||||
updateOverride('basic_filter', { ...basicFilter, [key]: value })
|
updateOverride('basic_filter', { ...basicFilter, [key]: value })
|
||||||
}
|
}
|
||||||
const startScoringEdit = () => {
|
|
||||||
setScoringDraft(scoringToPct(scoring))
|
|
||||||
setEditingScoring(true)
|
|
||||||
}
|
|
||||||
const cancelScoringEdit = () => {
|
|
||||||
setScoringDraft(scoringToPct(scoring))
|
|
||||||
setEditingScoring(false)
|
|
||||||
}
|
|
||||||
const saveScoringDraft = () => {
|
|
||||||
updateOverride('scoring', normalizePctWeights(scoringDraft))
|
|
||||||
setEditingScoring(false)
|
|
||||||
}
|
|
||||||
const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== ''
|
const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== ''
|
||||||
? `评分 ${scoreMinValue}~${scoreMaxValue}`
|
? `评分 ${scoreMinValue}~${scoreMaxValue}`
|
||||||
: scoreMinValue !== ''
|
: scoreMinValue !== ''
|
||||||
@@ -1793,32 +1761,45 @@ export function StrategyBacktest() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{simMode === 'full' && (
|
<div className="flex items-center gap-2">
|
||||||
maxHoldDaysValue !== '' ? (
|
{simMode === 'full' && (
|
||||||
<div className="rounded-btn border border-border bg-surface px-2 py-1 text-[11px] text-secondary">
|
maxHoldDaysValue !== '' ? (
|
||||||
策略最长 <span className="font-mono text-foreground">{maxHoldDaysValue}</span> 天
|
<div className="rounded-btn border border-border bg-surface px-2 py-1 text-[11px] text-secondary">
|
||||||
</div>
|
策略最长 <span className="font-mono text-foreground">{maxHoldDaysValue}</span> 天
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-secondary">
|
|
||||||
<span>兜底上限</span>
|
|
||||||
<div className="flex rounded-btn border border-border overflow-hidden">
|
|
||||||
{(['1', '5', '10', '20'] as const).map(d => (
|
|
||||||
<button
|
|
||||||
key={d}
|
|
||||||
onClick={() => setHoldingDays(d)}
|
|
||||||
className={`px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
|
|
||||||
holdingDays === d
|
|
||||||
? 'bg-accent/10 text-accent'
|
|
||||||
: 'text-muted hover:text-secondary hover:bg-elevated'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{d}天
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)
|
<div className="flex items-center gap-1.5 text-[11px] text-secondary">
|
||||||
)}
|
<span>兜底上限</span>
|
||||||
|
<div className="flex rounded-btn border border-border overflow-hidden">
|
||||||
|
{(['1', '5', '10', '20'] as const).map(d => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setHoldingDays(d)}
|
||||||
|
className={`px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
|
||||||
|
holdingDays === d
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'text-muted hover:text-secondary hover:bg-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}天
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{result && !result.error && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveCandidate.mutate()}
|
||||||
|
disabled={saveCandidate.isPending}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-btn border border-border bg-surface px-2.5 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||||
|
{saveCandidate.isPending ? '保存中' : '保存候选'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 市场环境过滤: 只在指定环境的交易日入场(强制 T-1, 用前一日环境判定) */}
|
{/* 市场环境过滤: 只在指定环境的交易日入场(强制 T-1, 用前一日环境判定) */}
|
||||||
@@ -2575,53 +2556,19 @@ export function StrategyBacktest() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{settingsTab === 'scoring' && (
|
{settingsTab === 'scoring' && (
|
||||||
<ConfigSection title="评分权重" hint="临时拖动滑块,保存时统一归权">
|
<ConfigSection title="评分方案" hint="选择因子、方向与权重,保存时自动归一化">
|
||||||
{Object.entries(scoring).length > 0 ? (() => {
|
<ScoringEditor
|
||||||
const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring)
|
key={detail.id}
|
||||||
const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0)
|
value={scoring}
|
||||||
return (
|
directions={scoringDirections}
|
||||||
<div className="space-y-3">
|
fallbackLabels={FIELD_LABEL}
|
||||||
<div className="space-y-2">
|
onChange={(nextScoring, nextDirections) => setOverrides(previous => ({
|
||||||
{Object.keys(scoring).map(key => (
|
...previous,
|
||||||
<ScoringWeightRow
|
scoring: nextScoring,
|
||||||
key={key}
|
scoring_directions: nextDirections,
|
||||||
name={key}
|
scoring_replace: true,
|
||||||
weight={visibleWeights[key] ?? 0}
|
}))}
|
||||||
pct={visibleWeights[key] ?? 0}
|
/>
|
||||||
editing={editingScoring}
|
|
||||||
onChange={value => setScoringDraft(prev => ({ ...prev, [key]: Math.max(0, value) }))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/40 pt-2">
|
|
||||||
<div className="text-[10px] text-muted">
|
|
||||||
总和 <span className={`font-mono text-xs font-medium ${editingScoring && total !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}>{editingScoring ? total : 100}</span>
|
|
||||||
<span className="ml-1 text-muted/70">保存时自动归一化计算</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{editingScoring && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cancelScoringEdit}
|
|
||||||
className="rounded-btn border border-border bg-base px-2.5 py-1 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={editingScoring ? saveScoringDraft : startScoringEdit}
|
|
||||||
className="rounded-btn border border-amber-400/40 bg-amber-400/10 px-2.5 py-1 text-[11px] text-amber-400 transition-colors hover:bg-amber-400/15"
|
|
||||||
>
|
|
||||||
{editingScoring ? '保存归权' : '调整权重'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})() : (
|
|
||||||
<div className="text-xs text-muted">当前策略没有评分权重。</div>
|
|
||||||
)}
|
|
||||||
<div className="border-t border-border/40 pt-3">
|
<div className="border-t border-border/40 pt-3">
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="text-[11px] font-medium text-secondary">评分过滤</span>
|
<span className="text-[11px] font-medium text-secondary">评分过滤</span>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type {
|
||||||
|
FactorBacktestResult,
|
||||||
|
FactorBatchItem,
|
||||||
|
FactorBatchResult,
|
||||||
|
ResearchCandidateCreate,
|
||||||
|
StrategyBacktestResult,
|
||||||
|
} from '@/lib/api'
|
||||||
|
|
||||||
|
const FACTOR_CONFIG_FIELDS = [
|
||||||
|
'symbols', 'start', 'end', 'n_groups', 'rebalance', 'weight', 'fees_pct',
|
||||||
|
'slippage_bps', 'asset_type',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const STRATEGY_CONFIG_FIELDS = [
|
||||||
|
'strategy_id', 'symbols', 'start', 'end', 'params', 'overrides', 'matching',
|
||||||
|
'entry_fill', 'exit_fill', 'fees_pct', 'commission_pct', 'stamp_tax_pct',
|
||||||
|
'slippage_bps', 'max_positions', 'max_exposure_pct', 'initial_capital',
|
||||||
|
'position_sizing', 'mode', 'holding_days', 'asset_type', 'minute_fill',
|
||||||
|
'regime_filter',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function pickConfig(source: Record<string, any>, fields: readonly string[]) {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const field of fields) {
|
||||||
|
if (source[field] !== undefined) result[field] = source[field]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function factorResultCandidate(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
label: string,
|
||||||
|
): ResearchCandidateCreate {
|
||||||
|
const factorName = String(result.config.factor_name)
|
||||||
|
return {
|
||||||
|
kind: 'factor',
|
||||||
|
name: `${label}候选`,
|
||||||
|
source_id: factorName,
|
||||||
|
config: {
|
||||||
|
factor_name: factorName,
|
||||||
|
...pickConfig(result.config, FACTOR_CONFIG_FIELDS),
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
ic_mean: result.ic_mean,
|
||||||
|
ic_std: result.ic_std,
|
||||||
|
ir: result.ir,
|
||||||
|
ic_win_rate: result.ic_win_rate,
|
||||||
|
long_short_return: result.long_short_stats?.total_return ?? null,
|
||||||
|
long_short_max_drawdown: result.long_short_stats?.max_drawdown ?? null,
|
||||||
|
n_symbols: result.n_symbols,
|
||||||
|
n_dates: result.n_dates,
|
||||||
|
elapsed_ms: result.elapsed_ms,
|
||||||
|
},
|
||||||
|
data_as_of: String(result.config.end || '') || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function factorBatchCandidate(
|
||||||
|
batch: FactorBatchResult,
|
||||||
|
item: FactorBatchItem,
|
||||||
|
): ResearchCandidateCreate {
|
||||||
|
return {
|
||||||
|
kind: 'factor',
|
||||||
|
name: `${item.label}候选`,
|
||||||
|
source_id: item.factor_name,
|
||||||
|
config: {
|
||||||
|
factor_name: item.factor_name,
|
||||||
|
...pickConfig(batch.config, FACTOR_CONFIG_FIELDS),
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
ic_mean: item.ic_mean,
|
||||||
|
ir: item.ir,
|
||||||
|
ic_win_rate: item.ic_win_rate,
|
||||||
|
long_short_return: item.long_short_return,
|
||||||
|
long_short_max_drawdown: item.long_short_max_drawdown,
|
||||||
|
n_symbols: item.n_symbols,
|
||||||
|
n_dates: item.n_dates,
|
||||||
|
elapsed_ms: item.elapsed_ms,
|
||||||
|
},
|
||||||
|
data_as_of: String(batch.config.end || '') || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyResultCandidate(result: StrategyBacktestResult): ResearchCandidateCreate {
|
||||||
|
const stats = result.stats ?? {}
|
||||||
|
const sourceId = String(result.config.strategy_id || result.strategy_info?.id || '')
|
||||||
|
return {
|
||||||
|
kind: 'strategy',
|
||||||
|
name: `${result.strategy_info?.name || sourceId}候选`,
|
||||||
|
source_id: sourceId,
|
||||||
|
config: pickConfig({ ...result.config, strategy_id: sourceId }, STRATEGY_CONFIG_FIELDS),
|
||||||
|
metrics: {
|
||||||
|
total_return: stats.total_return ?? null,
|
||||||
|
annual_return: stats.annual_return ?? null,
|
||||||
|
max_drawdown: stats.max_drawdown ?? null,
|
||||||
|
sharpe: stats.sharpe ?? null,
|
||||||
|
sortino: stats.sortino ?? null,
|
||||||
|
win_rate: stats.win_rate ?? null,
|
||||||
|
n_trades: stats.n_trades ?? null,
|
||||||
|
profit_factor: stats.profit_factor ?? null,
|
||||||
|
avg_return: stats.avg_return ?? null,
|
||||||
|
median_return: stats.median_return ?? null,
|
||||||
|
elapsed_ms: result.elapsed_ms,
|
||||||
|
},
|
||||||
|
data_as_of: String(result.config.end || '') || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user