feat(v0.2): 完善量化研究与二次开发能力

This commit is contained in:
shy3130
2026-08-13 20:16:02 +08:00
parent 57eb641275
commit 8fe5dac7bd
105 changed files with 4974 additions and 678 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ jobs:
run: |
brew install create-dmg
create-dmg \
--volname "TickFlow Stock Panel" \
--volname "Tick Stock Panel" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
+2
View File
@@ -2,6 +2,8 @@
修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。
涉及代码二次开发、前端插槽、后端可替换策略、扩展注册或上游升级兼容时,还必须阅读 [`docs/secondary-development.md`](docs/secondary-development.md)。该文档区分当前已实现能力与目标扩展契约;不得根据设计示例虚构尚不存在的 API。
同时遵守以下规则:
- 先理解调用链和现有测试,再进行修改。
+3 -1
View File
@@ -1,9 +1,11 @@
# TickFlow Stock Panel 贡献、AI 开发与复审指南
# Tick Stock Panel 贡献、AI 开发与复审指南
本文档适用于整个仓库,供贡献者、AI 编码代理和 PR 审查者共同使用。目标是让改动落在正确的模块,保持数据口径、插件化和兼容性一致,并通过可复现的验证减少返工。
`CONTRIBUTING.md` 是项目贡献与审查规范,不替代 `README.md``docs/` 中的用户文档和领域文档。所有贡献者和 AI 编码代理在修改代码、提交或审查 PR 前都应阅读本文档。
涉及代码二次开发时,同时遵循 [`docs/secondary-development.md`](docs/secondary-development.md)。前端优先使用真实存在的受控插槽或注册入口,后端优先使用小粒度策略接口和依赖注入;现有扩展点无法表达核心行为变化时允许直接修改源码,但必须保持改动聚焦并补足兼容性验证。
## 1. 基本原则
### 1.1 修改前先理解
+1
View File
@@ -238,6 +238,7 @@ PORT=3018 # 服务端口
| [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) |
| [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、YAML 配置与 mock 联调示例 |
| [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 生成与手写) |
fork同时请点个star哦,欢迎 Issue 和 PR。
+1 -1
View File
@@ -1,4 +1,4 @@
"""TickFlow Stock Panel backend."""
"""Tick Stock Panel backend."""
import sys
+139 -3
View File
@@ -134,11 +134,11 @@ def factor_columns():
class FactorBacktestRequest(BaseModel):
factor_name: str
factor_name: str = Field(..., min_length=1, max_length=64)
symbols: list[str] | None = None
start: 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"
weight: Literal["equal", "factor_weight"] = "equal"
fees_pct: float = 0.0002
@@ -149,7 +149,10 @@ class FactorBacktestRequest(BaseModel):
@router.post("/factor/run")
def factor_run(req: FactorBacktestRequest, request: Request):
"""因子回测 — 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)
svc = FactorBacktestService(engine)
@@ -180,6 +183,139 @@ def factor_run(req: FactorBacktestRequest, request: Request):
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)
# ================================================================
# 策略回测
# ================================================================
+3 -2
View File
@@ -90,6 +90,8 @@ class RuleModel(BaseModel):
strategy_id: str | None = None
direction: str = "entry" # entry | exit | both
notify_events: list[str] | None = None
score_min: float | None = None
score_max: float | None = None
conditions: list[ConditionModel] = []
logic: str = "and" # and | or
cooldown_seconds: int = 3600
@@ -317,7 +319,6 @@ def delete_rule(rule_id: str, request: Request):
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
import time as _time
from datetime import datetime, timezone
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
try:
alert_store.append_many(repo.store.data_dir, rule_events)
except Exception as e: # noqa: BLE001
except Exception: # noqa: BLE001
pass # 落盘失败不阻断推送
# 2. SSE 推送 (入 pending_alerts 队列)
+51 -4
View File
@@ -24,6 +24,11 @@ from app.strategy.ai_generator import AIStrategyGenerator, find_meta_assignment
from app.strategy.engine import StrategyDef, StrategyEngine
from app.strategy.monitor import StrategyMonitorService
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"])
logger = logging.getLogger(__name__)
@@ -136,14 +141,13 @@ def _strategy_detail(
) -> dict:
"""策略详情(含用户覆盖)"""
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", [])}
if overrides:
if overrides.get("basic_filter"):
bf.update(overrides["basic_filter"])
if overrides.get("scoring"):
scoring.update(overrides["scoring"])
# 用户保存的参数覆盖默认值: 合并进 params_defaults, 前端据此回显
if overrides.get("params"):
params_defaults.update(overrides["params"])
@@ -166,6 +170,11 @@ def _strategy_detail(
"params": s.meta.get("params", []),
"params_defaults": params_defaults,
"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,
"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),
@@ -175,7 +184,6 @@ def _strategy_detail(
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", 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,
"alerts": s.alerts,
"order_by": s.meta.get("order_by", "score"),
"descending": s.meta.get("descending", True),
"limit": s.meta.get("limit", 30),
@@ -407,6 +415,7 @@ def save_config(req: SaveConfigRequest, request: Request):
if not engine.has(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)
@@ -414,6 +423,44 @@ def save_config(req: SaveConfigRequest, request: Request):
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:
"""剥离与策略默认值相同的字段,避免默认值被固化到 override 中。
+227
View File
@@ -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
View File
@@ -15,27 +15,67 @@ import numpy as np
import polars as pl
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__)
# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标)
# 可研究因子目录。保留历史 ID 兼容已有候选方案; 价格尺度相关指标优先提供归一化版本。
FACTOR_COLUMNS: list[dict] = [
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"},
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"},
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"},
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"},
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"},
{"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": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"},
{"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"},
{"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"},
{"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"},
{"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"},
{"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"},
{"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"},
{"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"},
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5个交易日累计收益率"},
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10个交易日累计收益率"},
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "20个交易日累计收益率"},
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30个交易日累计收益率"},
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "60个交易日累计收益率"},
{"id": "change_pct", "label": "日涨跌幅", "group": "动量", "desc": "当日收盘相对前收盘的收益率"},
{"id": "ma5_bias", "label": "MA5乖离", "group": "均线偏离", "desc": "收盘价 / MA5 - 1"},
{"id": "ma10_bias", "label": "MA10乖离", "group": "均线偏离", "desc": "收盘价 / MA10 - 1"},
{"id": "ma20_bias", "label": "MA20乖离", "group": "均线偏离", "desc": "收盘价 / MA20 - 1"},
{"id": "ma30_bias", "label": "MA30乖离", "group": "均线偏离", "desc": "收盘价 / MA30 - 1"},
{"id": "ma60_bias", "label": "MA60乖离", "group": "均线偏离", "desc": "收盘价 / MA60 - 1"},
{"id": "ema5_bias", "label": "EMA5乖离", "group": "均线偏离", "desc": "收盘价 / EMA5 - 1"},
{"id": "ema10_bias", "label": "EMA10乖离", "group": "均线偏离", "desc": "收盘价 / EMA10 - 1"},
{"id": "ema20_bias", "label": "EMA20乖离", "group": "均线偏离", "desc": "收盘价 / EMA20 - 1"},
{"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
@@ -89,6 +129,47 @@ class FactorResult:
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:
def __init__(self, engine: BacktestEngine) -> None:
self.engine = engine
@@ -96,21 +177,106 @@ class FactorBacktestService:
def run(self, config: FactorConfig) -> FactorResult:
t0 = time.perf_counter()
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 FactorResult(
return self._evaluate_panel(panel, config, run_id, t0)
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,
config=self._config_to_dict(config),
error=msg,
elapsed_ms=(time.perf_counter() - t0) * 1000,
config=result_config,
error="至少选择一个因子",
)
# 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。
panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"]
if config.factor_name not in panel_columns:
panel_columns.append(config.factor_name)
panel = self._load_factor_panel(config, factor_names)
if panel.is_empty():
return FactorBatchResult(
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
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)
panel = self.engine.load_panel(
@@ -121,16 +287,29 @@ class FactorBacktestService:
asset_type=config.asset_type,
)
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
if factor_col not in panel.columns:
panel = self._compute_missing_factor(panel, factor_col)
if factor_col not in panel.columns:
if factor_col not in source_panel.columns:
return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
if "close" not in panel.columns:
if "close" not in source_panel.columns:
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))
# 过滤有效行
@@ -198,20 +377,39 @@ class FactorBacktestService:
)
@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"}
if not required.issubset(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
from app.indicators.pipeline import compute_indicators
# 只需要单个因子列 → 用 needed 裁剪, 跳过无关的 EMA/KDJ/RSI 等计算 pass
computed = compute_indicators(panel, needed={factor_col})
if factor_col not in computed.columns:
return panel
return computed.select(["symbol", "date", "close", factor_col])
derived = factor_cols & set(DERIVED_FACTOR_DEPENDENCIES)
indicator_columns = factor_cols - derived
for factor_name in derived:
indicator_columns.update(DERIVED_FACTOR_DEPENDENCIES[factor_name])
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 计算 ──
@@ -527,4 +725,20 @@ class FactorBacktestService:
"weight": c.weight,
"fees_pct": c.fees_pct,
"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
View File
@@ -34,6 +34,7 @@ from app.price_limits import (
numpy_limit_price,
write_numpy_price_limit_matrix,
)
from app.strategy.scoring import SCORING_DIRECTION_LOW
try:
from numba import njit, prange
@@ -3397,6 +3398,7 @@ class MatrixPipelineConfig:
scoring: dict[str, float]
order_by: str | None
descending: bool
scoring_directions: dict[str, str] = field(default_factory=dict)
asset_mask: np.ndarray | None = None
protect_strategy_cache: bool = False
@@ -3462,6 +3464,7 @@ class MatrixStrategyPipeline:
config.order_by,
config.descending,
fallback=signals.score,
directions=config.scoring_directions,
)
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)
@@ -3502,14 +3505,7 @@ def _estimate_pipeline_cache_bytes(
for name in feature_names:
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
continue
if name == "vol_ratio_5d":
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
estimated += 5 * float_bytes
return estimated
@@ -3588,6 +3584,7 @@ def build_matrix_score(
descending: bool,
*,
fallback: np.ndarray,
directions: Mapping[str, str] | None = None,
) -> np.ndarray:
weights = {name: float(weight) for name, weight in scoring.items() if float(weight) != 0.0}
total_weight = sum(weights.values())
@@ -3639,6 +3636,8 @@ def build_matrix_score(
np.divide(scratch, row_range[:, None], out=scratch, where=mask)
np.logical_and(finite, ~varying_rows[:, None], out=mask)
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
score[:, start:stop] += scratch
score *= np.float32(100.0)
@@ -3665,38 +3664,38 @@ def build_matrix_score(
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:
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
return market.field(name)
close_feature = (
name in {
"prev_close",
"change_pct",
"change_amount",
"amplitude",
"boll_upper",
"boll_lower",
"high_60d",
"low_60d",
"annual_vol_20d",
"ma20_bias",
}
supported = (
name in _MATRIX_COMPUTED_FEATURES
or (name.startswith("ma") and name.endswith("_bias") and name[2:-5].isdigit())
or (name.startswith("ema") and name.endswith("_bias") and name[3:-5].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("momentum_") and name.endswith("d")
)
or (name.startswith("momentum_") and name.endswith("d"))
)
if close_feature:
source = market.close
elif name == "vol_ratio_5d":
source = market.volume
else:
if not supported:
raise ValueError(f"unsupported matrix feature: {name}")
with _activate_valid_bar_index(market.valid_bars):
return _cached_matrix_operation(
"matrix_feature",
(source,),
(market.close,),
{"name": 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:
raise ValueError(f"unsupported matrix feature: {name}") from exc
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)
previous_volume = valid_shift(market.volume, 1, volume_valid)
previous_mean = valid_rolling_mean(
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)
np.divide(
market.volume,
previous_mean,
out=out,
where=volume_valid & np.isfinite(previous_mean) & (previous_mean != 0),
)
valid = close_valid & np.isfinite(amount) & (amount >= 0)
np.log(amount + np.float32(1.0), out=out, where=valid)
return out
if name == "ma20_bias":
ma20 = valid_rolling_mean(market.close, close_valid, 20)
out = np.full(market.shape, np.nan, dtype=np.float32)
np.divide(
market.close,
ma20,
out=out,
where=close_valid & np.isfinite(ma20) & (ma20 != 0),
if name == "amount_ratio_5d":
amount = market.field("amount")
valid = close_valid & np.isfinite(amount)
previous = valid_shift(amount, 1, valid)
return _matrix_relative(
amount,
valid_rolling_mean(previous, np.isfinite(previous), 5),
)
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":
middle = valid_rolling_mean(market.close, close_valid, 20)
deviation = valid_rolling_std(market.close, close_valid, 20, ddof=1)
offset = np.float32(2.0) * deviation
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":
return valid_rolling_max(market.close, close_valid, 60)
if name == "low_60d":
@@ -3775,6 +3816,29 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
20,
ddof=1,
) * 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():
window = int(name[4:])
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))
out = np.float32(100.0) - np.float32(100.0) / (np.float32(1.0) + 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}")
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(
signals: SignalMatrix,
entry_time_mask: np.ndarray,
+36 -21
View File
@@ -42,7 +42,15 @@ from app.indicators.pipeline import (
get_signal_dependencies,
)
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__)
@@ -134,8 +142,7 @@ class StrategyDependencyResolver:
}
required_signals.update({"signal_limit_up", "signal_limit_down"})
scoring = dict(strategy.meta.get("scoring", {}) or {})
scoring.update(overrides.get("scoring") or {})
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
required_features.update(scoring_dependencies(scoring))
order_by = strategy.meta.get("order_by")
if order_by and order_by != "score":
@@ -187,7 +194,7 @@ class StrategyDependencyResolver:
plan = FeaturePlan(
required_features=frozenset(required_features),
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(
base_columns=base_columns,
@@ -218,8 +225,7 @@ class StrategyDependencyResolver:
required_features = set(strategy.required_features)
required_features.update(strategy.matrix_strategy.required_fields())
required_features.update(_basic_filter_dependencies(basic_filter))
scoring = dict(strategy.meta.get("scoring", {}) or {})
scoring.update(overrides.get("scoring") or {})
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
required_features.update(scoring_dependencies(scoring))
order_by = strategy.meta.get("order_by")
if order_by and order_by != "score":
@@ -229,7 +235,11 @@ class StrategyDependencyResolver:
base_columns = frozenset(set(base_columns) | set(_LIMIT_BASE_COLUMNS))
instrument_columns = frozenset(required_features & set(_INSTRUMENT_COLUMNS))
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) | {
"signal_limit_up",
"signal_limit_down",
@@ -662,12 +672,11 @@ class StrategyBacktestService:
plans.append(child_plan)
# pipeline 用 composite 统一的 basic_filter; scoring 用子策略自己的
# (默认 + 用户 override), 因为子策略内部排序影响合并器的排名融合。
child_scoring = dict(child_def.meta.get("scoring", {}) or {})
if isinstance(child_override.get("scoring"), dict):
child_scoring.update(child_override["scoring"])
child_scoring = effective_scoring(child_def.meta.get("scoring"), child_override)
child_pipeline_cfg = MatrixPipelineConfig(
basic_filter=basic_filter,
scoring=child_scoring,
scoring_directions=effective_scoring_directions(child_override),
order_by=child_def.meta.get("order_by"),
descending=bool(child_def.meta.get("descending", True)),
protect_strategy_cache=False,
@@ -1280,12 +1289,12 @@ class StrategyBacktestService:
else None
)
scoring = dict(s.meta.get("scoring", {}) or {})
scoring.update(overrides.get("scoring") or {})
scoring = effective_scoring(s.meta.get("scoring"), overrides)
try:
pipeline_config = MatrixPipelineConfig(
basic_filter=basic_filter,
scoring=scoring,
scoring_directions=effective_scoring_directions(overrides),
order_by=s.meta.get("order_by"),
descending=bool(s.meta.get("descending", True)),
protect_strategy_cache=prepared is not None,
@@ -2042,12 +2051,11 @@ class StrategyBacktestService:
overrides: dict | None,
universe_mask: pl.Series | None = None,
) -> pl.DataFrame:
scoring = s.meta.get("scoring", {})
scoring_overrides = (overrides or {}).get("scoring")
if scoring_overrides:
scoring = {**scoring, **scoring_overrides}
scoring = effective_scoring(s.meta.get("scoring"), overrides)
directions = effective_scoring_directions(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)
if has_universe:
work = work.with_columns(universe_mask.rename("_score_universe"))
@@ -2058,18 +2066,23 @@ class StrategyBacktestService:
return value
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:
executable = [
(value, weight)
(str(col), value, weight)
for col, weight in scoring.items()
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:
score_parts: list[pl.Expr] = []
for score_value, weight in executable:
for name, score_value, weight in executable:
w = weight / total_weight
value = _value_in_universe(score_value)
col_min = value.min().over("date")
@@ -2078,6 +2091,8 @@ class StrategyBacktestService:
normalized = pl.when(col_range > 0).then(
(score_value - col_min) / col_range
).otherwise(pl.lit(0.5))
if directions.get(name) == SCORING_DIRECTION_LOW:
normalized = 1.0 - normalized
if has_universe:
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
score_parts.append(normalized * w)
+5
View File
@@ -0,0 +1,5 @@
# 后端二次开发目录
在本目录新增普通 `.py` 模块即可由应用自动发现,无需修改 `app/main.py`。以下划线开头的模块不会加载。
以 [`_template.py.example`](_template.py.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与运行。
+4
View File
@@ -0,0 +1,4 @@
"""In-repository backend secondary-development modules.
Copy ``_template.py.example`` to a non-underscore ``.py`` module to enable it.
"""
+38
View File
@@ -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
+16
View File
@@ -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",
]
+52
View File
@@ -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 "")
+111
View File
@@ -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,
)
+134
View File
@@ -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
View File
@@ -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.routes import router as core_router
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.services.quote_service import QuoteService
from app.tickflow import client as tf_client
@@ -31,7 +36,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(
"TickFlow Stock Panel v%s starting (mode=%s)",
"Tick Stock Panel v%s starting (mode=%s)",
__version__, tf_client.current_mode(),
)
@@ -246,6 +251,13 @@ async def lifespan(app: FastAPI):
app.state.monitor_engine = monitor_engine
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
if app.state.scheduler:
@@ -269,7 +281,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="TickFlow Stock Panel",
title="Tick Stock Panel",
version=__version__,
description="A 股选股 + 回测面板 — TickFlow 适配",
lifespan=lifespan,
@@ -357,6 +369,11 @@ app.include_router(monitor_rules.router)
app.include_router(alerts.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)
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
+1 -1
View File
@@ -642,7 +642,7 @@ def _make_writable_and_retry(
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
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.",
"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.",
+37
View File
@@ -1149,6 +1149,7 @@ class QuoteService:
except Exception as e: # noqa: BLE001
logger.warning("指数监控评估失败 (不影响股票/ETF 告警): %s", e)
if rule_events:
rule_events = self._format_extension_notifications(rule_events)
# 落盘到 alerts.jsonl
try:
from app.services import alert_store
@@ -1207,6 +1208,42 @@ class QuoteService:
except Exception as e: # noqa: BLE001
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:
"""就地给告警事件按 symbol 追加行业/概念 ext 字段。
@@ -40,7 +40,6 @@ ENTRY_SIGNALS = ["signal_boll_breakout_upper"]
EXIT_SIGNALS = ["signal_boll_breakdown_lower"]
STOP_LOSS = -0.06
MAX_HOLD_DAYS = 15
ALERTS = []
class BollBreakoutMatrixStrategy:
@@ -53,7 +53,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.06
MAX_HOLD_DAYS = 10
ALERTS = []
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"]
STOP_LOSS = -0.06
MAX_HOLD_DAYS = 20
ALERTS = []
class BullishAlignmentMatrixStrategy:
@@ -35,7 +35,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 5
ALERTS = []
class ConsecutiveLimitUpsMatrixStrategy:
@@ -52,7 +52,6 @@ ENTRY_SIGNALS = ["signal_volume_surge"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 10
ALERTS = []
class HighTurnoverSurgeMatrixStrategy:
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 5
ALERTS = []
class LimitUpMomentumMatrixStrategy:
@@ -54,7 +54,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 30
ALERTS = []
class LowVolatilityLeaderMatrixStrategy:
@@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
EXIT_SIGNALS = ["signal_ma_dead_5_20"]
STOP_LOSS = -0.06
MAX_HOLD_DAYS = 15
ALERTS = []
class MAGoldenCrossMatrixStrategy:
@@ -46,7 +46,6 @@ EXIT_SIGNALS = ["signal_macd_dead"]
EXECUTION_BACKEND = "matrix_native"
STOP_LOSS = -0.07
MAX_HOLD_DAYS = 20
ALERTS = []
class MACDGoldenMatrixStrategy:
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_n_day_low"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.06
MAX_HOLD_DAYS = 15
ALERTS = []
class NDayLowReversalMatrixStrategy:
@@ -57,7 +57,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 5
ALERTS = []
class NearLimitUpMatrixStrategy:
@@ -53,9 +53,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 15
ALERTS = [
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
]
class OversoldBounceMatrixStrategy:
@@ -58,9 +58,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 15
ALERTS = [
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
]
class OversoldReversalMatrixStrategy:
@@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 15
ALERTS = []
class PullbackMA20BounceMatrixStrategy:
@@ -64,7 +64,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
class PullbackToSupportMatrixStrategy:
@@ -58,7 +58,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 10
ALERTS = []
class StrongOpenMatrixStrategy:
@@ -57,9 +57,6 @@ ENTRY_SIGNALS = ["signal_n_day_high"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.08
MAX_HOLD_DAYS = 20
ALERTS = [
{"field": "signal_volume_surge", "message": "放量异动"},
]
class TrendBreakoutMatrixStrategy:
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.06
MAX_HOLD_DAYS = 15
ALERTS = []
class VolumePriceSurgeMatrixStrategy:
+73 -23
View File
@@ -11,15 +11,24 @@ import logging
import sys
import threading
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field, replace
from datetime import date
from pathlib import Path
from typing import Any, Callable
from typing import Any
import numpy as np
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__)
@@ -181,7 +190,6 @@ class StrategyDef:
trailing_take_profit_activate: float | None
trailing_take_profit_drawdown: float | None
max_hold_days: int | None
alerts: list[dict]
filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None
filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None
lookback_days: int
@@ -520,7 +528,6 @@ class StrategyEngine:
trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None),
trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None),
max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None),
alerts=getattr(mod, "ALERTS", []),
filter_fn=filter_fn,
filter_history_fn=filter_history_fn,
required_features=frozenset(meta.get("required_features", []) or [])
@@ -655,11 +662,14 @@ class StrategyEngine:
required = 1
for strategy_id in strategy_ids:
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":
params = self.resolve_params(
strategy,
params_map.get(strategy_id),
overrides_map.get(strategy_id),
overrides,
)
required = max(
required,
@@ -714,6 +724,12 @@ class StrategyEngine:
max_warmup = max(
max_warmup,
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(
self._matrix_field_columns(strategy, overrides_map.get(strategy_id))
@@ -832,7 +848,15 @@ class StrategyEngine:
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:
signal_df = pl.DataFrame()
if not signal_df.is_empty() and "date" in signal_df.columns:
@@ -843,9 +867,9 @@ class StrategyEngine:
# 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。
if s.filter_history_fn:
if context.history is None:
if history is None:
raise ValueError(f"strategy {strategy_id} requires history data")
df = context.history
df = history
if df.is_empty():
return StrategyResult(
as_of=as_of,
@@ -856,9 +880,9 @@ class StrategyEngine:
if "date" in df.columns:
df = df.filter(pl.col("date") == as_of)
else:
if context.current is None:
if current is None:
raise ValueError(f"strategy {strategy_id} requires current data")
df = context.current
df = current
if df.is_empty():
return StrategyResult(
@@ -887,11 +911,7 @@ class StrategyEngine:
df = df.filter(expr)
# Stage 3: 评分
scoring = s.meta.get("scoring", {})
scoring_overrides = overrides.get("scoring")
if scoring_overrides:
scoring = {**scoring, **scoring_overrides}
df = self._apply_scoring(df, scoring)
df = self._apply_scoring(df, scoring, scoring_directions)
entry_signal_hits = self._collect_signal_hits(df, entry_signals)
if not entry_signals and (s.filter_history_fn or s.filter_fn):
entry_signal_hits = [
@@ -1048,8 +1068,7 @@ class StrategyEngine:
or basic_filter.get(f"{prefix}_max") is not None
):
fields.add(field_name)
scoring = dict(strategy.meta.get("scoring", {}) or {})
scoring.update((overrides or {}).get("scoring") or {})
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
fields.update(scoring_dependencies(scoring))
order_by = strategy.meta.get("order_by")
if order_by and order_by != "score":
@@ -1094,8 +1113,7 @@ class StrategyEngine:
basic_filter = dict(strategy.basic_filter or {})
if overrides.get("basic_filter"):
basic_filter.update(overrides["basic_filter"])
scoring = dict(strategy.meta.get("scoring", {}) or {})
scoring.update(overrides.get("scoring") or {})
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
asset_mask = None
if pool:
pool_set = set(pool)
@@ -1112,6 +1130,7 @@ class StrategyEngine:
MatrixPipelineConfig(
basic_filter=basic_filter,
scoring=scoring,
scoring_directions=effective_scoring_directions(overrides),
order_by=strategy.meta.get("order_by"),
descending=bool(strategy.meta.get("descending", True)),
asset_mask=asset_mask,
@@ -1398,28 +1417,34 @@ class StrategyEngine:
# ================================================================
@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 分"""
if not weights:
return df
executable = [
(value, weight)
(str(col), value, weight)
for col, weight in weights.items()
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:
return df
score_parts: list[pl.Expr] = []
for value, weight in executable:
for name, value, weight in executable:
w = weight / total_weight
col_min = value.min()
col_range = value.max() - col_min
normalized = pl.when(col_range > 0).then(
(value - col_min) / col_range
).otherwise(pl.lit(0.5))
if (directions or {}).get(name) == SCORING_DIRECTION_LOW:
normalized = 1.0 - normalized
score_parts.append(normalized * w)
if not score_parts:
@@ -1430,6 +1455,31 @@ class StrategyEngine:
score_expr = score_expr + part
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]:
for r in rows:
+48 -67
View File
@@ -1,6 +1,6 @@
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件
"""策略实时监控 — 订阅行情更新,检查策略买卖信号。
职责: 接收实时行情 DataFrame 检查监控中策略的信号/提醒 推送告警
职责: 接收实时行情 DataFrame 检查监控中策略的信号 推送告警
不知道: 策略加载逻辑AIAPI配置持久化回测
依赖: 外部调用 on_quote_update() 传入实时数据
@@ -13,6 +13,7 @@ from __future__ import annotations
import datetime as _dt
import logging
import math
import threading
import time
from dataclasses import dataclass, field
@@ -72,7 +73,7 @@ def _signal_cn_name(name: str) -> str:
@dataclass
class StrategyAlert:
"""策略告警"""
type: str # "entry" | "exit" | "alert"
type: str # "entry" | "exit"
strategy_id: str
symbol: str
name: str | None
@@ -104,7 +105,6 @@ class StrategyMonitorService:
config: {
"entry_signals": ["signal_n_day_high", ...],
"exit_signals": ["signal_ma20_breakdown", ...],
"alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
}
"""
with self._watching_lock:
@@ -152,7 +152,7 @@ class StrategyMonitorService:
strategy_id=strategy_id,
symbol=sym,
name=name,
message=f"入场信号触发",
message="入场信号触发",
price=price,
change_pct=pct,
signals=hit_sigs,
@@ -169,7 +169,7 @@ class StrategyMonitorService:
strategy_id=strategy_id,
symbol=sym,
name=name,
message=f"出场信号触发",
message="出场信号触发",
price=price,
change_pct=pct,
signals=hit_sigs,
@@ -177,21 +177,6 @@ class StrategyMonitorService:
all_alerts.append(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
def _emit(self, alert: StrategyAlert) -> None:
@@ -230,46 +215,6 @@ class StrategyMonitorService:
results.append((sym, name, price, pct, hit_sigs))
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
# ================================================================
@@ -414,6 +359,8 @@ class MonitorRuleEngine:
return (
rule.get("type"),
rule.get("strategy_id"),
rule.get("score_min"),
rule.get("score_max"),
rule.get("asset_type", "stock"),
rule.get("scope", "symbols"),
tuple(sorted(str(symbol) for symbol in rule.get("symbols", []))),
@@ -987,7 +934,16 @@ class MonitorRuleEngine:
current=df,
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)
if history_loader is None:
logger.debug("策略 %s 需要历史数据但未注入 history_loader (asset_type=%s), 跳过实时监控",
@@ -995,7 +951,7 @@ class MonitorRuleEngine:
return []
try:
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)
if hist_df is None or hist_df.is_empty():
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
@@ -1039,7 +995,6 @@ class MonitorRuleEngine:
# 避免并发读到半填充状态。
if at == "stock":
try:
import math
self._building_strategy_results[sid] = {
"total": result.total,
"as_of": str(cn_today()),
@@ -1053,7 +1008,27 @@ class MonitorRuleEngine:
except Exception: # noqa: BLE001
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)
self._strategy_pools[pool_key] = current_pool
@@ -1066,9 +1041,15 @@ class MonitorRuleEngine:
except Exception:
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]] = {
"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(
pool_key, "sell_signal", result.as_of, result.exit_signal_hits,
@@ -1081,7 +1062,7 @@ class MonitorRuleEngine:
signal_map = {
"buy_signal": {
str(hit["symbol"]): list(hit.get("signals") or [])
for hit in result.entry_signal_hits
for hit in entry_signal_hits
},
"sell_signal": {
str(hit["symbol"]): list(hit.get("signals") or [])
+16
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import json
import logging
import math
import re
from datetime import datetime, timezone
from pathlib import Path
@@ -132,6 +133,17 @@ def validate(rule: dict) -> None:
invalid_events = set(notify_events) - STRATEGY_NOTIFY_EVENTS
if 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":
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
@@ -231,6 +243,8 @@ def normalize(rule: dict) -> dict:
# direction 默认值: ladder/sector 用 "up", 其余用 "entry"
r.setdefault("direction", "up" if r.get("type") in {"ladder", "sector"} else "entry")
if r.get("type") == "strategy":
r.setdefault("score_min", None)
r.setdefault("score_max", None)
if r.get("notify_events") is None:
# 兼容统一监控上线后的旧规则: 当时实际行为是同时通知进入和移出。
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"]))
else:
r.pop("notify_events", None)
r.pop("score_min", None)
r.pop("score_max", None)
r.setdefault("conditions", [])
# ladder 专属默认字段
r.setdefault("metric", "sealed_vol")
+1 -1
View File
@@ -30,7 +30,7 @@ def build_step1(
strategy_id: str = "",
execution_backend: str = "polars_expr",
) -> str:
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分)
注意: 生成规范已在 ai_generator.py system prompt 中加载
此处只拼用户输入以降低网关超时概率
@@ -35,9 +35,8 @@
1. **META**id(name, description, tags, params, scoring, basic_filter, limit 等)
2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例
3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天
4. **ALERTS**列出需要监控提醒的条件
5. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整
6. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑
4. **RULES**中文逐条列出核心筛选逻辑(至少 3 条),准确完整
5. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑
## 性能原则
@@ -84,8 +83,6 @@ EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 规则一
2. 规则二
@@ -136,8 +133,6 @@ EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 规则一(包含时序逻辑)
2. 规则二
@@ -23,7 +23,6 @@
- 增/删/改参数 → 更新 META["params"],同步修改当前执行后端对应的 `filter()``filter_history()``MATRIX_STRATEGY`
- 调整信号 → 更新 ENTRY_SIGNALS / EXIT_SIGNALS
- 修改止损/持有 → 更新 STOP_LOSS / MAX_HOLD_DAYS
- 增减告警 → 更新 ALERTS
- 调整评分 → 更新 META["scoring"];只使用真实数值字段或受控虚拟字段 `ma20_bias`,权重总和保持 1.0
- 修改筛选逻辑 → 更新唯一公式;新增历史回溯时切换为 `python_history_legacy` + `filter_history()`,移除回溯时切回 `polars_expr` + `filter()`,不得同时保留两套公式
@@ -38,3 +37,4 @@
7. 优先使用 Polars 表达式、窗口函数、聚合和 join,不要默认改成逐行/逐股 Python 循环
8. **输出前自我检查**:完整通读修改后的代码,确认 Python 语法正确、括号匹配、引号闭合、缩进一致。有错误直接修正再输出。
9. 直接输出完整 Python 代码
10. 历史代码中的 `ALERTS` 已废弃,输出时删除;实时提醒由监控中心统一管理
@@ -15,7 +15,7 @@
| 方向 | 做多 |
| 规则 | 前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。 |
点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分、告警):
点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分):
```python
"""强势反包 — 前日阴线下跌 + 今日放量阳线反包"""
@@ -82,9 +82,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 10
ALERTS = [
{"field": "signal_broken_board_recovery", "message": "反包信号"},
]
RULES = """
1. 前一交易日为阴线,且跌幅不小于设定阈值
@@ -47,7 +47,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 规则一
@@ -61,10 +61,6 @@ STOP_LOSS = -0.05
# 最长持有天数 (短线 5~20, 中线 20~60)
MAX_HOLD_DAYS = 20
# 提醒条件 (监控用)
ALERTS = []
# 策略规则(人类可读,逐条编号,至少 3 条)
RULES = """
1. 规则描述一
@@ -330,7 +326,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 10
ALERTS = [{"field": "signal_broken_board_recovery", "message": "反包信号"}]
RULES = """
1. 前一交易日为阴线,且跌幅不小于设定阈值
+137 -5
View File
@@ -6,11 +6,75 @@ from typing import Any
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]] = {
"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]:
"""把受控虚拟评分字段展开为实际数据依赖。"""
@@ -30,8 +94,76 @@ def scoring_value_expr(columns: Collection[str], name: str) -> pl.Expr | None:
dependencies = VIRTUAL_SCORING_DEPENDENCIES.get(name)
if dependencies is None or not dependencies.issubset(available):
return None
if name == "ma20_bias":
return pl.when(pl.col("ma20") != 0).then(
pl.col("close") / pl.col("ma20") - 1.0
).otherwise(None)
if name.startswith("ma") and name.endswith("_bias"):
period = name.removeprefix("ma").removesuffix("_bias")
if period.isdigit():
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
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 -1
View File
@@ -17,7 +17,6 @@ def _strategy(**overrides) -> StrategyDef:
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=lambda df, params: pl.col("rsi_14") < params["rsi_max"],
filter_history_fn=None,
lookback_days=20,
@@ -65,6 +64,23 @@ def test_resolver_expands_virtual_scoring_dependencies():
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):
strategy = _strategy(
filter_fn=None,
+188
View File
@@ -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
from app.backtest import matrix as matrix_module
from app.backtest.factor import FACTOR_COLUMNS, FactorBacktestService
from app.backtest.matrix import (
MatrixPipelineConfig,
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:
rows = []
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)
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():
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_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=lambda df, params: pl.lit(True),
filter_history_fn=None,
lookback_days=1,
@@ -33,7 +33,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = None
MAX_HOLD_DAYS = 1
ALERTS = []
class AlwaysEntry:
def required_fields(self):
@@ -22,7 +22,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
return pl.lit(True)
+154
View File
@@ -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 = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 测试规则一
-1
View File
@@ -30,7 +30,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1. 测试规则一
@@ -28,7 +28,6 @@ def _make_strategy(
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=10,
alerts=[],
filter_fn=None,
filter_history_fn=None,
lookback_days=60,
@@ -43,6 +42,7 @@ def test_no_overrides_returns_default_signals():
exit_signals=["signal_ma20_breakdown"],
)
detail = _strategy_detail(s, overrides=None)
assert "alerts" not in detail
assert detail["entry_signals"] == ["signal_ma20_breakout", "signal_n_day_high"]
assert detail["exit_signals"] == ["signal_ma20_breakdown"]
+72 -3
View File
@@ -55,12 +55,23 @@ def _result(
pool: tuple[str, ...] = (),
buys: tuple[str, ...] = (),
sells: tuple[str, ...] = (),
scores: dict[str, float] | None = None,
) -> StrategyResult:
scores = scores or {}
return StrategyResult(
as_of=as_of,
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),
scores=scores,
entry_signal_hits=[{"symbol": symbol, "signals": ["signal_buy"]} for symbol in buys],
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")
assert loaded is not None
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"]
with pytest.raises(ValueError, match="至少选择一个通知事件"):
monitor_rules.validate(_rule())
with pytest.raises(ValueError, match="非法事件"):
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"))
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():
day1 = date(2026, 7, 24)
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_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=None,
filter_history_fn=None,
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_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=None,
filter_history_fn=None,
lookback_days=1,
@@ -113,7 +113,7 @@ def _make_strategy_with_params(params) -> StrategyDef:
entry_signals=[], exit_signals=[],
stop_loss=None, trailing_stop=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,
lookback_days=60, source="custom",
)
+14
View File
@@ -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)
assert set(results) == set(strategy_ids.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")
+46 -1
View File
@@ -1,7 +1,12 @@
from datetime import date
from types import SimpleNamespace
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
@@ -18,7 +23,6 @@ def _make_engine() -> tuple[StrategyEngine, StrategyDataContext]:
trailing_take_profit_activate=None,
trailing_take_profit_drawdown=None,
max_hold_days=None,
alerts=[],
filter_fn=lambda _df, params: pl.col("value") >= params.get("min_value", 1),
filter_history_fn=None,
lookback_days=1,
@@ -53,3 +57,44 @@ def test_explicit_params_override_saved_strategy_params():
)
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)
+74 -1
View File
@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
@@ -6,6 +6,7 @@ import pytest
from app.backtest.strategy import StrategyBacktestService
from app.strategy.engine import StrategyEngine
from app.strategy.scoring import effective_scoring
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])
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,
}) == {}
+1 -1
View File
@@ -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)...'
}
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 }
Log-Ok 'backend deps installed'
}
+2 -2
View File
@@ -127,7 +127,7 @@ if [ ! -d "$BACKEND_DIR/.venv" ] || [ "${#BACKEND_EXTRA_ARGS[@]}" -gt 0 ]; then
else
info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..."
fi
( cd "$BACKEND_DIR" && uv sync "${BACKEND_EXTRA_ARGS[@]}" )
( cd "$BACKEND_DIR" && uv sync --frozen "${BACKEND_EXTRA_ARGS[@]}" )
ok "后端依赖装好了"
fi
@@ -173,7 +173,7 @@ echo
(
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 \
| prefix_awk "$(printf "${BLUE}[backend ]${NC} ")"
) &
+397
View File
@@ -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
View File
@@ -18,7 +18,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
+126 -63
View File
@@ -58,6 +58,8 @@ import { cn } from '@/lib/cn'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { toggleTheme, useTheme } from '@/lib/theme'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
import { getFrontendExtensionNavigation } from '@/extensions/registry'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6'
@@ -76,7 +78,7 @@ const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
@@ -140,7 +142,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
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 => {
const q = quoteBySymbol.get(item.symbol)
const value = q?.last_price ?? q?.close
@@ -332,8 +334,22 @@ export function Layout() {
const [dismissFreeHint, setDismissFreeHint] = useState(false)
// 侧边栏收起状态 — 持久化到 localStorage
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 }
})
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 = () => {
setNavCollapsed(prev => {
const next = !prev
@@ -372,6 +388,34 @@ export function Layout() {
const realtimeProviderName = realtimeProvider && realtimeProvider !== 'tickflow'
? (dataSources?.custom?.find(s => s.name === realtimeProvider)?.display_name || realtimeProvider)
: 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'
@@ -399,8 +443,14 @@ export function Layout() {
const analysisNav: NavItem[] = (analysisMenus?.items ?? [])
.filter(m => m.visible)
.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 navItems = savedOrder.length > 0
@@ -478,13 +528,14 @@ export function Layout() {
{/* 状态卡 — 收起时隐藏 */}
{!navCollapsed && (
<div className="mt-2.5 space-y-0.5">
<div className="mt-2.5 border-t border-border/60 pt-1">
<TierBadge
label={caps?.label ?? ''}
hasKey={settingsState?.mode !== 'none'}
providerName={activeProviderName}
isTickflow={!isCustomActive}
/>
<div className="mx-2 border-t border-border/45" aria-hidden="true" />
<AIConfigBadge
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
model={settingsState?.ai_model}
@@ -608,6 +659,11 @@ export function Layout() {
</div>
)
})}
<ExtensionSlot
name="layout.navigation.extra"
context={{ collapsed: navCollapsed, pathname: location.pathname }}
compact
/>
</nav>
{/* 全局行情开关 — 收起时只显示状态指示点 */}
@@ -615,15 +671,13 @@ export function Layout() {
<div className="border-t border-border px-2 py-2.5 shrink-0 flex justify-center">
<button
onClick={() => handleToggle(!realtimeEnabled)}
disabled={toggleQuote.isPending || isPaused}
title={realtimeEnabled ? (isRunning && isTrading ? '行情运行中 · 点击关闭' : '实时行情已开启') : '实时行情已关闭 · 点击开启'}
className="flex items-center justify-center rounded-btn p-1.5 transition-colors hover:bg-elevated/70"
disabled={realtimeToggleDisabled}
aria-label={realtimeToggleTitle}
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 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled ? 'bg-warning/60' : 'bg-muted'
}`} />
<span className={`inline-block h-2 w-2 rounded-full ${realtimeIndicatorClass}`} />
</button>
</div>
) : (
@@ -652,67 +706,76 @@ export function Layout() {
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled
? 'bg-warning/60'
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeProviderName || realtimeModeLabel}
</span>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${realtimeIndicatorClass}`} />
<div className="min-w-0">
<div className="text-xs font-medium leading-none text-foreground"></div>
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] leading-none">
<span className="truncate text-muted">{realtimeProviderName || realtimeModeLabel}</span>
<span className="shrink-0 text-border" aria-hidden="true">·</span>
<span className={`shrink-0 ${realtimeStatusClass}`}>{realtimeStatusLabel}</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
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="实时监控设置"
>
<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>
</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>
)}
{/* 状态提示 */}
{realtimeEnabled && (!isNoneTier || realtimeProviderName) && (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && !realtimeProviderName && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isPaused ? (
<div className="text-warning/80"></div>
) : isRunning && isTrading ? (
<div className="text-accent"></div>
) : realtimeEnabled && !isTrading ? (
<div className="text-warning/70"></div>
) : null}
</div>
)}
{realtimeEnabled
&& (!isNoneTier || realtimeProviderName)
&& (isPaused || (isWatchlistMode && !dismissFreeHint && !realtimeProviderName))
&& (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && !realtimeProviderName && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isPaused && (
<div className="text-warning/80"></div>
)}
</div>
)}
{showSidebarQuotes && !isWatchlistMode && (!isNoneTier || !!realtimeProviderName) && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
+1 -1
View File
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
className={className}
style={style}
role="img"
aria-label="TickFlow Stock Panel"
aria-label="Tick Stock Panel"
>
{/* 左方括号 */}
<path
+1 -1
View File
@@ -2,7 +2,7 @@ import { cn } from '@/lib/cn'
interface Props {
title: string
subtitle?: string
subtitle?: React.ReactNode
/** 标题右侧、subtitle 之前的额外节点(如状态徽标) */
titleExtra?: React.ReactNode
right?: React.ReactNode
+272
View File
@@ -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,
window_minutes: 5,
strategy_id: null,
score_min: null,
score_max: null,
direction: 'entry',
conditions: [],
logic: 'or',
@@ -138,7 +140,17 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
if (d.type === 'strategy') {
if (!d.strategy_id) 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') {
delete d.score_min
delete d.score_max
d.scope = 'all'
d.symbols = []
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.threshold_pct ?? 0) <= 0 || (d.threshold_pct ?? 0) > 20) throw new Error('阈值必须大于 0 且不超过 20%')
} else {
delete d.score_min
delete d.score_max
delete d.notify_events
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
for (const c of d.conditions) {
@@ -843,6 +857,50 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
})}
</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="mb-2 flex items-center justify-between gap-3">
<span className="text-[11px] text-muted"></span>
@@ -112,7 +112,6 @@ ENTRY_SIGNALS = ["signal_n_day_high"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
RULES = """
1.
@@ -150,7 +149,6 @@ ENTRY_SIGNALS = []
EXIT_SIGNALS = []
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 20
ALERTS = []
class CustomMatrixStrategy:
def required_fields(self) -> frozenset[str]:
@@ -598,7 +596,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
AI
</button>
</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 { motion, AnimatePresence } from 'framer-motion'
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 { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
import { Modal } from '@/components/Modal'
import { ScoringEditor } from '@/components/ScoringEditor'
// 内置列名 → 中文标签
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) {
const [detail, setDetail] = useState<StrategyDetail | null>(null)
const [loading, setLoading] = useState(false)
@@ -210,6 +186,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
const [basicFilter, setBasicFilter] = useState<Record<string, any>>({})
const [params, setParams] = useState<Record<string, any>>({})
const [scoring, setScoring] = useState<Record<string, number>>({})
const [scoringDirections, setScoringDirections] = useState<Record<string, ScoringDirection>>({})
const [stopLoss, setStopLoss] = useState<number | null>(null)
const [maxHoldDays, setMaxHoldDays] = useState<number | null>(null)
const [entrySignals, setEntrySignals] = useState<string[]>([])
@@ -221,7 +198,6 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
// 可选子策略列表 + 添加面板开关(composite 设置用)
const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([])
const [showAddChild, setShowAddChild] = useState(false)
const [editingScoring, setEditingScoring] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteError, setDeleteError] = useState('')
@@ -245,7 +221,8 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
if (!bf.boards) bf.boards = ALL_BOARDS
setBasicFilter(bf)
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)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
@@ -288,7 +265,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
description: strategyDesc,
basic_filter: { ...basicFilter, enabled: basicFilterEnabled },
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,
max_hold_days: maxHoldDays,
entry_signals: entrySignals,
@@ -321,17 +302,18 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
if (!bf.boards) bf.boards = ALL_BOARDS
setBasicFilter(bf)
setParams(d.params_defaults)
setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)])))
setStopLoss(d.stop_loss)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
setCompositeChildren(d.composite_children ?? [])
} finally {
setResetting(false)
}
setScoring(d.scoring)
setScoringDirections(d.scoring_directions ?? {})
setStopLoss(d.stop_loss)
setMaxHoldDays(d.max_hold_days)
setEntrySignals(d.entry_signals ?? [])
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
setCompositeChildren(d.composite_children ?? [])
} finally {
setResetting(false)
}
}
const handleDelete = async () => {
@@ -560,54 +542,16 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
{/* 列3:评分 + 交易 */}
<div className="space-y-3">
<Section icon={Star} title="评分权重" accent="text-amber-400">
{Object.entries(scoring).length > 0 ? (() => {
const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
return (
<div className="space-y-2">
{Object.entries(scoring).map(([col, w]) => {
const pct = Math.round((w / total) * 100)
return (
<ScoringField key={col} col={col} weight={w} pct={pct}
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>}
<ScoringEditor
key={detail.id}
value={scoring}
directions={scoringDirections}
fallbackLabels={FIELD_LABEL}
onChange={(nextScoring, nextDirections) => {
setScoring(nextScoring)
setScoringDirections(nextDirections)
}}
/>
</Section>
<Section icon={TrendingUp} title="交易参数" accent="text-emerald-400">
@@ -657,15 +601,6 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
<b className="text-secondary"></b>;,
</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>
)}
+9
View File
@@ -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>
)
}
}
+26
View File
@@ -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>
)
})
}
+10
View File
@@ -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)
}
+182
View File
@@ -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]
}
+53
View File
@@ -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
View File
@@ -543,6 +543,7 @@ export interface StrategyDetail {
params: StrategyParamDef[]
params_defaults: Record<string, any>
scoring: Record<string, number>
scoring_directions: Record<string, ScoringDirection>
entry_signals: string[]
exit_signals: string[]
minute_exit_trigger_supported_signals: string[]
@@ -553,7 +554,6 @@ export interface StrategyDetail {
trailing_take_profit_drawdown: number | null
max_hold_days: number | null
display_limit?: number
alerts: { field: string; op?: string; value?: number; message: string }[]
order_by: string
descending: boolean
limit: number
@@ -561,6 +561,8 @@ export interface StrategyDetail {
composite_children?: CompositeChildInfo[] | null
}
export type ScoringDirection = 'high' | 'low'
export interface StrategyBuildResult {
code: string
meta: Record<string, any>
@@ -655,6 +657,8 @@ export interface MonitorRule {
strategy_id?: string | null
direction: 'entry' | 'exit' | 'both' | 'up' | 'down'
notify_events?: StrategyNotifyEvent[]
score_min?: number | null
score_max?: number | null
conditions: MonitorCondition[]
logic: 'and' | 'or'
cooldown_seconds: number
@@ -817,6 +821,57 @@ export interface FactorBacktestResult {
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 =====
export interface StrategyBacktestTrade {
symbol: string
@@ -1748,6 +1803,46 @@ export const api = {
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: {
strategy_id: string
symbols?: string[] | null
@@ -2254,6 +2349,12 @@ export const api = {
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) =>
request<{ ok: boolean }>(`/api/strategies/config/${strategyId}`, { method: 'DELETE' }),
+3
View File
@@ -44,6 +44,9 @@ export const QK = {
// Backtest
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,
// Data / Pipeline
+2
View File
@@ -12,6 +12,8 @@ export function buildDefaultOverrides(detail: StrategyDetail): Record<string, an
entry_signals: detail.entry_signals.map(toSignalId),
exit_signals: detail.exit_signals.map(toSignalId),
scoring: { ...detail.scoring },
scoring_directions: { ...(detail.scoring_directions ?? {}) },
scoring_replace: true,
stop_loss: detail.stop_loss,
take_profit: detail.take_profit,
trailing_stop: detail.trailing_stop,
+14 -8
View File
@@ -2,7 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { router } from './router'
import { initializeFrontendExtensions } from './extensions/bootstrap'
import './index.css'
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
@@ -42,10 +42,16 @@ const queryClient = new QueryClient({
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
)
async function bootstrap() {
await initializeFrontendExtensions()
const { router } = await import('./router')
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
)
}
void bootstrap()
+1 -1
View File
@@ -89,7 +89,7 @@ export function Auth() {
{/* Logo */}
<div className="mb-6 flex flex-col items-center gap-2">
<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 className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
+65 -67
View File
@@ -1,91 +1,89 @@
import { useState } from 'react'
import { BarChart3, BookmarkCheck, FlaskConical, ShieldCheck } from 'lucide-react'
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 { 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: {
title: '因子回测',
subtitle: '验证单个因子是否有预测能力',
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
title: '因子',
subtitle: '批量筛选与单因子检验',
icon: BarChart3,
},
strategy: {
title: '策略回测',
subtitle: '验证完整选股和交易规则',
hint: '看净值曲线、回撤、胜率和交易明细,适合评估策略的历史表现。',
title: '策略',
subtitle: '现有策略评估与候选沉淀',
icon: FlaskConical,
},
optimizer: {
title: '参数优化',
subtitle: '网格搜索最优参数组合',
hint: '在独立 worker 中复用基础数据并串行回测参数组合,按夏普/索提诺等目标排序。',
robustness: {
title: '验证',
subtitle: '参数敏感性与滚动样本外',
icon: ShieldCheck,
},
walkforward: {
title: '步进优化',
subtitle: '滚动窗口样本外验证',
hint: '每折训练区间优化、测试区间验证,看样本外是否退化以识别过拟合。',
},
}
const TAB_ICONS: Record<Tab, typeof BarChart3> = {
factor: BarChart3,
strategy: FlaskConical,
optimizer: SlidersHorizontal,
walkforward: Waypoints,
}
export function Backtest() {
const [activeTab, setActiveTab] = useState<Tab>('strategy')
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>
)
const [candidatesOpen, setCandidatesOpen] = useState(false)
return (
<div className="min-h-full bg-base flex flex-col">
<div className="flex min-h-full flex-col bg-base">
<PageHeader
title="回测工作台"
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
right={modeSwitch}
className="shrink-0 bg-base/95"
title="量化研究"
subtitle={<span className="hidden md:inline">{MODES[activeTab].subtitle}</span>}
className="shrink-0 flex-wrap gap-x-4 gap-y-2 bg-base/95 px-3 lg:flex-nowrap lg:px-5"
right={(
<div className="flex w-full min-w-0 items-center gap-1.5 sm:gap-2 lg:w-auto">
<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">
{activeTab === 'factor' && <FactorBacktest />}
<main className="min-h-0 flex-1 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
{activeTab === 'factor' && <FactorDiscovery />}
{activeTab === 'strategy' && <StrategyBacktest />}
{activeTab === 'optimizer' && <StrategyOptimizer />}
{activeTab === 'walkforward' && <StrategyWalkForward />}
{activeTab === 'robustness' && <RobustnessValidation />}
</main>
{candidatesOpen && <ResearchCandidatesDialog onClose={() => setCandidatesOpen(false)} />}
</div>
)
}
+6 -6
View File
@@ -25,12 +25,12 @@ interface Variant {
glow?: string // 名字下方的发光线条 hex
}
// 同一个名字 "TickFlow Stock Panel" 在 4 种风格语言里的呈现
// 同一个名字 "Tick Stock Panel" 在 4 种风格语言里的呈现
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
const VARIANTS: Variant[] = [
{
id: 'pulsar',
name: 'TickFlow Stock Panel',
name: 'Tick Stock Panel',
tagline: 'A-SHARE · SIGNAL TERMINAL',
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
icon: RadioTower,
@@ -40,7 +40,7 @@ const VARIANTS: Variant[] = [
},
{
id: 'vanta',
name: 'TickFlow Stock Panel',
name: 'Tick Stock Panel',
tagline: 'MARKET · INTELLIGENCE',
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
icon: Square,
@@ -50,7 +50,7 @@ const VARIANTS: Variant[] = [
},
{
id: 'helix',
name: 'TickFlow Stock Panel',
name: 'Tick Stock Panel',
tagline: 'QUANT · TERMINAL',
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
icon: GitFork,
@@ -60,7 +60,7 @@ const VARIANTS: Variant[] = [
},
{
id: 'aurora',
name: 'TickFlow Stock Panel',
name: 'Tick Stock Panel',
tagline: 'A-SHARE · DASHBOARD',
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
icon: Sparkles,
@@ -85,7 +85,7 @@ export function Branding() {
<>
<PageHeader
title="视觉风格预览"
subtitle="名字保持 TickFlow Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
subtitle="名字保持 Tick Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
/>
<div className="px-8 py-6">
+5
View File
@@ -780,6 +780,11 @@ function RulesList({ rulesQuery, onEdit }: {
</div>
) : r.type === 'strategy' && r.strategy_id ? (
<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 => {
const option = STRATEGY_NOTIFY_EVENT_OPTIONS.find(item => item.key === event)
return option ? (
+2 -2
View File
@@ -108,7 +108,7 @@ export function Onboarding() {
className="shrink-0"
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 className="flex items-center gap-1.5">
@@ -237,7 +237,7 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
</motion.div>
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
使 TickFlow Stock Panel
使 Tick Stock Panel
</h1>
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
A
+36 -6
View File
@@ -1,13 +1,16 @@
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 { 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 { fmtPct, priceColorClass } from '@/lib/format'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { toast } from '@/components/Toast'
import { QK } from '@/lib/queryKeys'
import { FactorICChart } from './charts/FactorICChart'
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
import { factorResultCandidate } from './researchCandidates'
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
const monthsAgo = (months: number) => {
@@ -80,8 +83,9 @@ function LoadingPanel({ symbolsText }: { symbolsText: string }) {
)
}
export function FactorBacktest() {
const [factorName, setFactorName] = useState('momentum_20d')
export function FactorBacktest({ initialFactorName = 'momentum_20d' }: { initialFactorName?: string }) {
const queryClient = useQueryClient()
const [factorName, setFactorName] = useState(initialFactorName)
const [symbols, setSymbols] = useState('')
const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock')
const [start, setStart] = useState(THREE_MONTHS_AGO)
@@ -92,7 +96,7 @@ export function FactorBacktest() {
const [result, setResult] = useState<FactorBacktestResult | null>(null)
const columns = useQuery({
queryKey: ['backtest-factor-columns'],
queryKey: QK.factorColumns,
queryFn: api.factorColumns,
})
@@ -111,6 +115,11 @@ export function FactorBacktest() {
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
}, [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({
mutationFn: () =>
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) => {
setStart(monthsAgo(months))
setEnd(formatDate(new Date()))
@@ -291,7 +312,7 @@ export function FactorBacktest() {
onClick={() => run.mutate()}
disabled={run.isPending}
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"
>
<Play className="h-3.5 w-3.5" />
@@ -343,6 +364,15 @@ export function FactorBacktest() {
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground"></h3>
<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">
Rank IC ·
</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>
)
}
+84 -137
View File
@@ -1,13 +1,14 @@
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 { 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 {
api,
type StrategyBacktestResult,
type StrategyBacktestTrade,
type StrategyDetail,
type StrategyParamDef,
type ScoringDirection,
REGIME_STATE_LABELS,
REGIME_STATE_COLORS,
} from '@/lib/api'
@@ -30,6 +31,8 @@ import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
import { TradeKlineModal } from './components/TradeKlineModal'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
import { ScoringEditor } from '@/components/ScoringEditor'
import { strategyResultCandidate } from './researchCandidates'
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
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 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') {
// MatrixStrategy.compute_signals() owns entry/exit formulas. Remove both
// 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),
exit_signals: detail.exit_signals.map(toSignalId),
scoring: { ...detail.scoring },
scoring_directions: { ...(detail.scoring_directions ?? {}) },
scoring_replace: true,
stop_loss: detail.stop_loss,
take_profit: detail.take_profit,
trailing_stop: detail.trailing_stop,
@@ -299,6 +313,7 @@ const strategyBacktestConfigSignature = (detail: StrategyDetail) => JSON.stringi
params: detail.params,
params_defaults: detail.params_defaults,
scoring: detail.scoring,
scoring_directions: detail.scoring_directions,
entry_signals: detail.entry_signals,
exit_signals: detail.exit_signals,
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 }: {
param: StrategyParamDef
value: any
@@ -922,6 +894,7 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
}
export function StrategyBacktest() {
const queryClient = useQueryClient()
const signalNames = useSignalNames()
const [saved] = useState(() => storage.strategyBacktestLast.get(null))
const [selectedStrategy, setSelectedStrategy] = useState<string | null>(saved?.selectedStrategy ?? null)
@@ -963,8 +936,6 @@ export function StrategyBacktest() {
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
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 [overrides, setOverrides] = useState<Record<string, any>>(saved?.overrides ?? {})
// result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时,
@@ -982,12 +953,10 @@ export function StrategyBacktest() {
queryKey: QK.screenerStrategies(assetType),
queryFn: () => api.screenerStrategies(assetType),
})
const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data])
const filteredStrategyList = useMemo(() => (
strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup)
), [strategyGroup, strategyList])
// 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略
// 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效,
// 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物,
@@ -1010,6 +979,17 @@ export function StrategyBacktest() {
const backtestTask = useBacktestTask()
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 backtestDataStatus = assetType === 'etf'
@@ -1309,6 +1289,10 @@ export function StrategyBacktest() {
}, [exitFill, highGranularity, minuteExitTriggerSupported])
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 scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
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 targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0
useEffect(() => {
if (!editingScoring) setScoringDraft(scoringToPct(scoring))
}, [scoring, editingScoring])
useEffect(() => {
if (matrixStrategy && (settingsTab === 'entry' || settingsTab === 'exit')) {
setSettingsTab('params')
@@ -1335,18 +1315,6 @@ export function StrategyBacktest() {
const updateBasicFilter = (key: string, value: any) => {
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 !== ''
? `评分 ${scoreMinValue}~${scoreMaxValue}`
: scoreMinValue !== ''
@@ -1793,32 +1761,45 @@ export function StrategyBacktest() {
</button>
))}
</div>
{simMode === 'full' && (
maxHoldDaysValue !== '' ? (
<div className="rounded-btn border border-border bg-surface px-2 py-1 text-[11px] text-secondary">
<span className="font-mono text-foreground">{maxHoldDaysValue}</span>
</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 className="flex items-center gap-2">
{simMode === 'full' && (
maxHoldDaysValue !== '' ? (
<div className="rounded-btn border border-border bg-surface px-2 py-1 text-[11px] text-secondary">
<span className="font-mono text-foreground">{maxHoldDaysValue}</span>
</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>
{/* 市场环境过滤: 只在指定环境的交易日入场(强制 T-1, 用前一日环境判定) */}
@@ -2575,53 +2556,19 @@ export function StrategyBacktest() {
)}
{settingsTab === 'scoring' && (
<ConfigSection title="评分权重" hint="临时拖动滑块,保存时统一归权">
{Object.entries(scoring).length > 0 ? (() => {
const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring)
const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0)
return (
<div className="space-y-3">
<div className="space-y-2">
{Object.keys(scoring).map(key => (
<ScoringWeightRow
key={key}
name={key}
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>
)}
<ConfigSection title="评分方案" hint="选择因子、方向与权重,保存时自动归一化">
<ScoringEditor
key={detail.id}
value={scoring}
directions={scoringDirections}
fallbackLabels={FIELD_LABEL}
onChange={(nextScoring, nextDirections) => setOverrides(previous => ({
...previous,
scoring: nextScoring,
scoring_directions: nextDirections,
scoring_replace: true,
}))}
/>
<div className="border-t border-border/40 pt-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<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