From 8fe5dac7bd0db0fc1fd42a3b6989b7dd98347b48 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Thu, 13 Aug 2026 20:16:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(v0.2):=20=E5=AE=8C=E5=96=84=E9=87=8F?= =?UTF-8?q?=E5=8C=96=E7=A0=94=E7=A9=B6=E4=B8=8E=E4=BA=8C=E6=AC=A1=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 2 +- AGENTS.md | 2 + CONTRIBUTING.md | 4 +- README.md | 1 + backend/app/__init__.py | 2 +- backend/app/api/backtest.py | 142 +++++- backend/app/api/monitor_rules.py | 5 +- backend/app/api/strategy.py | 55 ++- backend/app/backtest/candidates.py | 227 ++++++++++ backend/app/backtest/factor.py | 294 ++++++++++-- backend/app/backtest/matrix.py | 198 ++++++-- backend/app/backtest/strategy.py | 57 ++- backend/app/custom/README.md | 5 + backend/app/custom/__init__.py | 4 + backend/app/custom/_template.py.example | 38 ++ backend/app/extensions/__init__.py | 16 + backend/app/extensions/contracts.py | 52 +++ backend/app/extensions/loader.py | 111 +++++ backend/app/extensions/registry.py | 134 ++++++ backend/app/main.py | 21 +- backend/app/services/ai_provider.py | 2 +- backend/app/services/quote_service.py | 37 ++ backend/app/strategy/builtin/boll_breakout.py | 1 - .../strategy/builtin/broken_board_recovery.py | 1 - .../app/strategy/builtin/bullish_alignment.py | 1 - .../strategy/builtin/consecutive_limit_ups.py | 1 - .../strategy/builtin/high_turnover_surge.py | 1 - .../app/strategy/builtin/limit_up_momentum.py | 1 - .../strategy/builtin/low_volatility_leader.py | 1 - .../app/strategy/builtin/ma_golden_cross.py | 1 - backend/app/strategy/builtin/macd_golden.py | 1 - .../strategy/builtin/n_day_low_reversal.py | 1 - backend/app/strategy/builtin/near_limit_up.py | 1 - .../app/strategy/builtin/oversold_bounce.py | 3 - .../app/strategy/builtin/oversold_reversal.py | 3 - .../strategy/builtin/pullback_ma20_bounce.py | 1 - .../strategy/builtin/pullback_to_support.py | 1 - backend/app/strategy/builtin/strong_open.py | 1 - .../app/strategy/builtin/trend_breakout.py | 3 - .../strategy/builtin/volume_price_surge.py | 1 - backend/app/strategy/engine.py | 96 +++- backend/app/strategy/monitor.py | 115 ++--- backend/app/strategy/monitor_rules.py | 16 + backend/app/strategy/prompt_builder.py | 2 +- .../prompts/strategy-builder-step1.md | 9 +- .../prompts/strategy-builder-step2.md | 2 +- .../app/strategy/prompts/strategy-example.md | 5 +- .../prompts/strategy-guide-compact.md | 1 - .../app/strategy/prompts/strategy-guide.md | 5 - backend/app/strategy/scoring.py | 142 +++++- backend/tests/backtest/test_dependencies.py | 18 +- backend/tests/backtest/test_factor_batch.py | 188 ++++++++ .../tests/backtest/test_matrix_strategy.py | 51 +++ backend/tests/backtest/test_research_api.py | 60 +++ .../backtest/test_research_candidates.py | 97 ++++ .../test_strategy_backtest_correctness.py | 1 - backend/tests/backtest/test_worker_process.py | 1 - .../tests/test_ai_strategy_meta_normalize.py | 1 - backend/tests/test_extensions.py | 154 +++++++ backend/tests/test_strategy_build_stream.py | 1 - backend/tests/test_strategy_code_save.py | 1 - backend/tests/test_strategy_detail_signals.py | 2 +- backend/tests/test_strategy_monitor_events.py | 75 ++- .../tests/test_strategy_param_normalize.py | 2 +- backend/tests/test_strategy_registry.py | 14 + backend/tests/test_strategy_saved_params.py | 47 +- backend/tests/test_strategy_scoring.py | 75 ++- dev.ps1 | 2 +- dev.sh | 4 +- docs/secondary-development.md | 397 ++++++++++++++++ frontend/index.html | 2 +- frontend/src/components/Layout.tsx | 189 +++++--- frontend/src/components/Logo.tsx | 2 +- frontend/src/components/PageHeader.tsx | 2 +- frontend/src/components/ScoringEditor.tsx | 272 +++++++++++ .../src/components/monitor/RuleEditor.tsx | 58 +++ .../screener/StrategyBuilderDialog.tsx | 4 +- .../screener/StrategySettingsDialog.tsx | 129 ++---- frontend/src/custom/README.md | 9 + .../custom/_template/extension.tsx.example | 30 ++ frontend/src/extensions/ExtensionBoundary.tsx | 33 ++ frontend/src/extensions/ExtensionSlot.tsx | 26 ++ frontend/src/extensions/bootstrap.ts | 10 + frontend/src/extensions/registry.ts | 182 ++++++++ frontend/src/extensions/types.ts | 53 +++ frontend/src/lib/api.ts | 103 ++++- frontend/src/lib/queryKeys.ts | 3 + frontend/src/lib/strategyOverrides.ts | 2 + frontend/src/main.tsx | 22 +- frontend/src/pages/Auth.tsx | 2 +- frontend/src/pages/Backtest.tsx | 132 +++--- frontend/src/pages/Branding.tsx | 12 +- frontend/src/pages/Monitor.tsx | 5 + frontend/src/pages/Onboarding.tsx | 4 +- .../src/pages/backtest/FactorBacktest.tsx | 42 +- .../src/pages/backtest/FactorDiscovery.tsx | 426 ++++++++++++++++++ .../backtest/ResearchCandidatesDialog.tsx | 334 ++++++++++++++ .../pages/backtest/RobustnessValidation.tsx | 38 ++ .../src/pages/backtest/StrategyBacktest.tsx | 221 ++++----- .../src/pages/backtest/researchCandidates.ts | 107 +++++ frontend/src/router.tsx | 52 +++ packaging/tickflow-logo.svg | 2 +- packaging/tickflow.iss | 4 +- packaging/tickflow.spec | 2 +- scripts/upgrade_check.py | 121 +++++ 105 files changed, 4974 insertions(+), 678 deletions(-) create mode 100644 backend/app/backtest/candidates.py create mode 100644 backend/app/custom/README.md create mode 100644 backend/app/custom/__init__.py create mode 100644 backend/app/custom/_template.py.example create mode 100644 backend/app/extensions/__init__.py create mode 100644 backend/app/extensions/contracts.py create mode 100644 backend/app/extensions/loader.py create mode 100644 backend/app/extensions/registry.py create mode 100644 backend/tests/backtest/test_factor_batch.py create mode 100644 backend/tests/backtest/test_research_api.py create mode 100644 backend/tests/backtest/test_research_candidates.py create mode 100644 backend/tests/test_extensions.py create mode 100644 docs/secondary-development.md create mode 100644 frontend/src/components/ScoringEditor.tsx create mode 100644 frontend/src/custom/README.md create mode 100644 frontend/src/custom/_template/extension.tsx.example create mode 100644 frontend/src/extensions/ExtensionBoundary.tsx create mode 100644 frontend/src/extensions/ExtensionSlot.tsx create mode 100644 frontend/src/extensions/bootstrap.ts create mode 100644 frontend/src/extensions/registry.ts create mode 100644 frontend/src/extensions/types.ts create mode 100644 frontend/src/pages/backtest/FactorDiscovery.tsx create mode 100644 frontend/src/pages/backtest/ResearchCandidatesDialog.tsx create mode 100644 frontend/src/pages/backtest/RobustnessValidation.tsx create mode 100644 frontend/src/pages/backtest/researchCandidates.ts create mode 100644 scripts/upgrade_check.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56eb0e..a9bbdf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 \ diff --git a/AGENTS.md b/AGENTS.md index c8cba0b..0145a14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ 修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。 +涉及代码二次开发、前端插槽、后端可替换策略、扩展注册或上游升级兼容时,还必须阅读 [`docs/secondary-development.md`](docs/secondary-development.md)。该文档区分当前已实现能力与目标扩展契约;不得根据设计示例虚构尚不存在的 API。 + 同时遵守以下规则: - 先理解调用链和现有测试,再进行修改。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7601f87..fecfc7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 修改前先理解 diff --git a/README.md b/README.md index 521f2bc..c7e0b5e 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b8252fd..d9f1257 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,4 @@ -"""TickFlow Stock Panel backend.""" +"""Tick Stock Panel backend.""" import sys diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 8c03b5c..d834f32 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -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) + + # ================================================================ # 策略回测 # ================================================================ diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 16a08c2..eec1f3b 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -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 队列) diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 6e40c1c..48c5390 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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 中。 diff --git a/backend/app/backtest/candidates.py b/backend/app/backtest/candidates.py new file mode 100644 index 0000000..6af771d --- /dev/null +++ b/backend/app/backtest/candidates.py @@ -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 限制") diff --git a/backend/app/backtest/factor.py b/backend/app/backtest/factor.py index 2b02b76..0c375b0 100644 --- a/backend/app/backtest/factor.py +++ b/backend/app/backtest/factor.py @@ -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, } diff --git a/backend/app/backtest/matrix.py b/backend/app/backtest/matrix.py index d488e42..5b8625f 100644 --- a/backend/app/backtest/matrix.py +++ b/backend/app/backtest/matrix.py @@ -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, diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 4a3836e..811b70c 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -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) diff --git a/backend/app/custom/README.md b/backend/app/custom/README.md new file mode 100644 index 0000000..6063157 --- /dev/null +++ b/backend/app/custom/README.md @@ -0,0 +1,5 @@ +# 后端二次开发目录 + +在本目录新增普通 `.py` 模块即可由应用自动发现,无需修改 `app/main.py`。以下划线开头的模块不会加载。 + +以 [`_template.py.example`](_template.py.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与运行。 diff --git a/backend/app/custom/__init__.py b/backend/app/custom/__init__.py new file mode 100644 index 0000000..aa53925 --- /dev/null +++ b/backend/app/custom/__init__.py @@ -0,0 +1,4 @@ +"""In-repository backend secondary-development modules. + +Copy ``_template.py.example`` to a non-underscore ``.py`` module to enable it. +""" diff --git a/backend/app/custom/_template.py.example b/backend/app/custom/_template.py.example new file mode 100644 index 0000000..b11ad86 --- /dev/null +++ b/backend/app/custom/_template.py.example @@ -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 diff --git a/backend/app/extensions/__init__.py b/backend/app/extensions/__init__.py new file mode 100644 index 0000000..cef4187 --- /dev/null +++ b/backend/app/extensions/__init__.py @@ -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", +] diff --git a/backend/app/extensions/contracts.py b/backend/app/extensions/contracts.py new file mode 100644 index 0000000..19f968c --- /dev/null +++ b/backend/app/extensions/contracts.py @@ -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 "") diff --git a/backend/app/extensions/loader.py b/backend/app/extensions/loader.py new file mode 100644 index 0000000..58a1e8b --- /dev/null +++ b/backend/app/extensions/loader.py @@ -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, + ) diff --git a/backend/app/extensions/registry.py b/backend/app/extensions/registry.py new file mode 100644 index 0000000..13b0d60 --- /dev/null +++ b/backend/app/extensions/registry.py @@ -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}") diff --git a/backend/app/main.py b/backend/app/main.py index 61a8769..902022f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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; diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 754a485..a7cd3a6 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -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.", diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index cac3c68..a8fe985 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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 字段。 diff --git a/backend/app/strategy/builtin/boll_breakout.py b/backend/app/strategy/builtin/boll_breakout.py index 87b809a..94c5336 100644 --- a/backend/app/strategy/builtin/boll_breakout.py +++ b/backend/app/strategy/builtin/boll_breakout.py @@ -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: diff --git a/backend/app/strategy/builtin/broken_board_recovery.py b/backend/app/strategy/builtin/broken_board_recovery.py index d45f191..d71170c 100644 --- a/backend/app/strategy/builtin/broken_board_recovery.py +++ b/backend/app/strategy/builtin/broken_board_recovery.py @@ -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: diff --git a/backend/app/strategy/builtin/bullish_alignment.py b/backend/app/strategy/builtin/bullish_alignment.py index fb05474..2bf70b5 100644 --- a/backend/app/strategy/builtin/bullish_alignment.py +++ b/backend/app/strategy/builtin/bullish_alignment.py @@ -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: diff --git a/backend/app/strategy/builtin/consecutive_limit_ups.py b/backend/app/strategy/builtin/consecutive_limit_ups.py index 7bc234c..c8c9bfa 100644 --- a/backend/app/strategy/builtin/consecutive_limit_ups.py +++ b/backend/app/strategy/builtin/consecutive_limit_ups.py @@ -35,7 +35,6 @@ ENTRY_SIGNALS = ["signal_limit_up"] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 5 -ALERTS = [] class ConsecutiveLimitUpsMatrixStrategy: diff --git a/backend/app/strategy/builtin/high_turnover_surge.py b/backend/app/strategy/builtin/high_turnover_surge.py index e4eaee2..9b42369 100644 --- a/backend/app/strategy/builtin/high_turnover_surge.py +++ b/backend/app/strategy/builtin/high_turnover_surge.py @@ -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: diff --git a/backend/app/strategy/builtin/limit_up_momentum.py b/backend/app/strategy/builtin/limit_up_momentum.py index 7f4b52a..1cb7de2 100644 --- a/backend/app/strategy/builtin/limit_up_momentum.py +++ b/backend/app/strategy/builtin/limit_up_momentum.py @@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_limit_up"] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 5 -ALERTS = [] class LimitUpMomentumMatrixStrategy: diff --git a/backend/app/strategy/builtin/low_volatility_leader.py b/backend/app/strategy/builtin/low_volatility_leader.py index f750f49..167dd49 100644 --- a/backend/app/strategy/builtin/low_volatility_leader.py +++ b/backend/app/strategy/builtin/low_volatility_leader.py @@ -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: diff --git a/backend/app/strategy/builtin/ma_golden_cross.py b/backend/app/strategy/builtin/ma_golden_cross.py index fe4f57b..82500ac 100644 --- a/backend/app/strategy/builtin/ma_golden_cross.py +++ b/backend/app/strategy/builtin/ma_golden_cross.py @@ -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: diff --git a/backend/app/strategy/builtin/macd_golden.py b/backend/app/strategy/builtin/macd_golden.py index 0785ef7..a599a4a 100644 --- a/backend/app/strategy/builtin/macd_golden.py +++ b/backend/app/strategy/builtin/macd_golden.py @@ -46,7 +46,6 @@ EXIT_SIGNALS = ["signal_macd_dead"] EXECUTION_BACKEND = "matrix_native" STOP_LOSS = -0.07 MAX_HOLD_DAYS = 20 -ALERTS = [] class MACDGoldenMatrixStrategy: diff --git a/backend/app/strategy/builtin/n_day_low_reversal.py b/backend/app/strategy/builtin/n_day_low_reversal.py index f263c22..eb68c7d 100644 --- a/backend/app/strategy/builtin/n_day_low_reversal.py +++ b/backend/app/strategy/builtin/n_day_low_reversal.py @@ -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: diff --git a/backend/app/strategy/builtin/near_limit_up.py b/backend/app/strategy/builtin/near_limit_up.py index 467ab91..7e5efdc 100644 --- a/backend/app/strategy/builtin/near_limit_up.py +++ b/backend/app/strategy/builtin/near_limit_up.py @@ -57,7 +57,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 5 -ALERTS = [] class NearLimitUpMatrixStrategy: diff --git a/backend/app/strategy/builtin/oversold_bounce.py b/backend/app/strategy/builtin/oversold_bounce.py index c187996..5365533 100644 --- a/backend/app/strategy/builtin/oversold_bounce.py +++ b/backend/app/strategy/builtin/oversold_bounce.py @@ -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: diff --git a/backend/app/strategy/builtin/oversold_reversal.py b/backend/app/strategy/builtin/oversold_reversal.py index 4334e63..afca00d 100644 --- a/backend/app/strategy/builtin/oversold_reversal.py +++ b/backend/app/strategy/builtin/oversold_reversal.py @@ -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: diff --git a/backend/app/strategy/builtin/pullback_ma20_bounce.py b/backend/app/strategy/builtin/pullback_ma20_bounce.py index e7491c9..3412ede 100644 --- a/backend/app/strategy/builtin/pullback_ma20_bounce.py +++ b/backend/app/strategy/builtin/pullback_ma20_bounce.py @@ -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: diff --git a/backend/app/strategy/builtin/pullback_to_support.py b/backend/app/strategy/builtin/pullback_to_support.py index 07fe8b1..792aae5 100644 --- a/backend/app/strategy/builtin/pullback_to_support.py +++ b/backend/app/strategy/builtin/pullback_to_support.py @@ -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: diff --git a/backend/app/strategy/builtin/strong_open.py b/backend/app/strategy/builtin/strong_open.py index 537a25e..e00b663 100644 --- a/backend/app/strategy/builtin/strong_open.py +++ b/backend/app/strategy/builtin/strong_open.py @@ -58,7 +58,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 10 -ALERTS = [] class StrongOpenMatrixStrategy: diff --git a/backend/app/strategy/builtin/trend_breakout.py b/backend/app/strategy/builtin/trend_breakout.py index a2824ff..6048adf 100644 --- a/backend/app/strategy/builtin/trend_breakout.py +++ b/backend/app/strategy/builtin/trend_breakout.py @@ -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: diff --git a/backend/app/strategy/builtin/volume_price_surge.py b/backend/app/strategy/builtin/volume_price_surge.py index ec70ac0..02c08ef 100644 --- a/backend/app/strategy/builtin/volume_price_surge.py +++ b/backend/app/strategy/builtin/volume_price_surge.py @@ -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: diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 1d3d4c2..fca60e7 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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: diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 8eabbd2..896e377 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -1,6 +1,6 @@ -"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。 +"""策略实时监控 — 订阅行情更新,检查策略买卖信号。 -职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。 +职责: 接收实时行情 DataFrame → 检查监控中策略的信号 → 推送告警。 不知道: 策略加载逻辑、AI、API、配置持久化、回测。 依赖: 外部调用 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 []) diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 8d9b877..0e16b28 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -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") diff --git a/backend/app/strategy/prompt_builder.py b/backend/app/strategy/prompt_builder.py index f3f008d..1118133 100644 --- a/backend/app/strategy/prompt_builder.py +++ b/backend/app/strategy/prompt_builder.py @@ -30,7 +30,7 @@ def build_step1( strategy_id: str = "", execution_backend: str = "polars_expr", ) -> str: - """步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警) + """步骤1:规则 → 完整策略代码(参数 + 信号 + 评分) 注意: 生成规范已在 ai_generator.py 的 system prompt 中加载, 此处只拼用户输入以降低网关超时概率。 diff --git a/backend/app/strategy/prompts/strategy-builder-step1.md b/backend/app/strategy/prompts/strategy-builder-step1.md index a11c5f5..c6475f9 100644 --- a/backend/app/strategy/prompts/strategy-builder-step1.md +++ b/backend/app/strategy/prompts/strategy-builder-step1.md @@ -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. 规则二 diff --git a/backend/app/strategy/prompts/strategy-builder-step2.md b/backend/app/strategy/prompts/strategy-builder-step2.md index 05d5e67..6330ba6 100644 --- a/backend/app/strategy/prompts/strategy-builder-step2.md +++ b/backend/app/strategy/prompts/strategy-builder-step2.md @@ -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` 已废弃,输出时删除;实时提醒由监控中心统一管理 diff --git a/backend/app/strategy/prompts/strategy-example.md b/backend/app/strategy/prompts/strategy-example.md index 788c2cc..4f8be73 100644 --- a/backend/app/strategy/prompts/strategy-example.md +++ b/backend/app/strategy/prompts/strategy-example.md @@ -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. 前一交易日为阴线,且跌幅不小于设定阈值 diff --git a/backend/app/strategy/prompts/strategy-guide-compact.md b/backend/app/strategy/prompts/strategy-guide-compact.md index 27b2e68..ecdba53 100644 --- a/backend/app/strategy/prompts/strategy-guide-compact.md +++ b/backend/app/strategy/prompts/strategy-guide-compact.md @@ -47,7 +47,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] RULES = """ 1. 规则一 diff --git a/backend/app/strategy/prompts/strategy-guide.md b/backend/app/strategy/prompts/strategy-guide.md index 069711b..bfa2943 100644 --- a/backend/app/strategy/prompts/strategy-guide.md +++ b/backend/app/strategy/prompts/strategy-guide.md @@ -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. 前一交易日为阴线,且跌幅不小于设定阈值 diff --git a/backend/app/strategy/scoring.py b/backend/app/strategy/scoring.py index 8bff57a..4456983 100644 --- a/backend/app/strategy/scoring.py +++ b/backend/app/strategy/scoring.py @@ -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 diff --git a/backend/tests/backtest/test_dependencies.py b/backend/tests/backtest/test_dependencies.py index 891dbe9..d5d44ee 100644 --- a/backend/tests/backtest/test_dependencies.py +++ b/backend/tests/backtest/test_dependencies.py @@ -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, diff --git a/backend/tests/backtest/test_factor_batch.py b/backend/tests/backtest/test_factor_batch.py new file mode 100644 index 0000000..debdb4f --- /dev/null +++ b/backend/tests/backtest/test_factor_batch.py @@ -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) diff --git a/backend/tests/backtest/test_matrix_strategy.py b/backend/tests/backtest/test_matrix_strategy.py index 8f1b8be..91c3519 100644 --- a/backend/tests/backtest/test_matrix_strategy.py +++ b/backend/tests/backtest/test_matrix_strategy.py @@ -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) diff --git a/backend/tests/backtest/test_research_api.py b/backend/tests/backtest/test_research_api.py new file mode 100644 index 0000000..af0b920 --- /dev/null +++ b/backend/tests/backtest/test_research_api.py @@ -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) diff --git a/backend/tests/backtest/test_research_candidates.py b/backend/tests/backtest/test_research_candidates.py new file mode 100644 index 0000000..b391e2c --- /dev/null +++ b/backend/tests/backtest/test_research_candidates.py @@ -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" diff --git a/backend/tests/backtest/test_strategy_backtest_correctness.py b/backend/tests/backtest/test_strategy_backtest_correctness.py index 61e080b..1b941f0 100644 --- a/backend/tests/backtest/test_strategy_backtest_correctness.py +++ b/backend/tests/backtest/test_strategy_backtest_correctness.py @@ -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, diff --git a/backend/tests/backtest/test_worker_process.py b/backend/tests/backtest/test_worker_process.py index 30bba1a..94c3c84 100644 --- a/backend/tests/backtest/test_worker_process.py +++ b/backend/tests/backtest/test_worker_process.py @@ -33,7 +33,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = None MAX_HOLD_DAYS = 1 -ALERTS = [] class AlwaysEntry: def required_fields(self): diff --git a/backend/tests/test_ai_strategy_meta_normalize.py b/backend/tests/test_ai_strategy_meta_normalize.py index 95c031b..ac3289e 100644 --- a/backend/tests/test_ai_strategy_meta_normalize.py +++ b/backend/tests/test_ai_strategy_meta_normalize.py @@ -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) diff --git a/backend/tests/test_extensions.py b/backend/tests/test_extensions.py new file mode 100644 index 0000000..43decdd --- /dev/null +++ b/backend/tests/test_extensions.py @@ -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) diff --git a/backend/tests/test_strategy_build_stream.py b/backend/tests/test_strategy_build_stream.py index 7d1b5dc..24e76ae 100644 --- a/backend/tests/test_strategy_build_stream.py +++ b/backend/tests/test_strategy_build_stream.py @@ -23,7 +23,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] RULES = """ 1. 测试规则一 diff --git a/backend/tests/test_strategy_code_save.py b/backend/tests/test_strategy_code_save.py index 1816220..e27ef61 100644 --- a/backend/tests/test_strategy_code_save.py +++ b/backend/tests/test_strategy_code_save.py @@ -30,7 +30,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] RULES = """ 1. 测试规则一 diff --git a/backend/tests/test_strategy_detail_signals.py b/backend/tests/test_strategy_detail_signals.py index 110846c..c89674f 100644 --- a/backend/tests/test_strategy_detail_signals.py +++ b/backend/tests/test_strategy_detail_signals.py @@ -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"] diff --git a/backend/tests/test_strategy_monitor_events.py b/backend/tests/test_strategy_monitor_events.py index 6c6af6f..4c917d2 100644 --- a/backend/tests/test_strategy_monitor_events.py +++ b/backend/tests/test_strategy_monitor_events.py @@ -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, diff --git a/backend/tests/test_strategy_param_normalize.py b/backend/tests/test_strategy_param_normalize.py index 92a202e..6ac2ed5 100644 --- a/backend/tests/test_strategy_param_normalize.py +++ b/backend/tests/test_strategy_param_normalize.py @@ -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", ) diff --git a/backend/tests/test_strategy_registry.py b/backend/tests/test_strategy_registry.py index 3799aa8..b930b53 100644 --- a/backend/tests/test_strategy_registry.py +++ b/backend/tests/test_strategy_registry.py @@ -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") diff --git a/backend/tests/test_strategy_saved_params.py b/backend/tests/test_strategy_saved_params.py index e2ccf89..ee39ec7 100644 --- a/backend/tests/test_strategy_saved_params.py +++ b/backend/tests/test_strategy_saved_params.py @@ -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) diff --git a/backend/tests/test_strategy_scoring.py b/backend/tests/test_strategy_scoring.py index b565b05..a81fcff 100644 --- a/backend/tests/test_strategy_scoring.py +++ b/backend/tests/test_strategy_scoring.py @@ -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, + }) == {} diff --git a/dev.ps1 b/dev.ps1 index 1f9046c..e32f4ff 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -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' } diff --git a/dev.sh b/dev.sh index 09f8a67..b3874aa 100755 --- a/dev.sh +++ b/dev.sh @@ -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} ")" ) & diff --git a/docs/secondary-development.md b/docs/secondary-development.md new file mode 100644 index 0000000..644ac41 --- /dev/null +++ b/docs/secondary-development.md @@ -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//extension.tsx`,支持静态页面、导航和已开放插槽。 +- 后端源码扩展注册:`backend/app/custom/.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 + +``` + +二开模块通过默认导出的注册清单接入,不修改核心文件: + +```tsx +import type { FrontendExtension } from '@/extensions/types' + +function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string }) { + return collapsed ? null :
二开内容
+} + +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:先解决已经存在的升级冲突,不提前建设完整插件平台,也不阻止开发者在必要时直接修改源码。 diff --git a/frontend/index.html b/frontend/index.html index 94fcdb5..2339bdd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@ - TickFlow Stock Panel · Quant Terminal + Tick Stock Panel · Quant Terminal diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c26ae8f..feca72d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 ( -
+
{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 && ( -
+
+ ) : ( /* Starter+ — 开关 + 跳转设置 */ -
-
- - - 实时行情 · {realtimeProviderName || realtimeModeLabel} - +
+
+ +
+
实时行情
+
+ {realtimeProviderName || realtimeModeLabel} + + {realtimeStatusLabel} +
+
+
+
+
-
)} {/* 状态提示 */} - {realtimeEnabled && (!isNoneTier || realtimeProviderName) && ( -
- {isWatchlistMode && !dismissFreeHint && !realtimeProviderName && ( -
- 监控自选股前 5 只,全市场监控需 Starter+ - -
- )} - {isPaused ? ( -
数据同步运行中,实时行情已临时暂停
- ) : isRunning && isTrading ? ( -
行情运行中
- ) : realtimeEnabled && !isTrading ? ( -
非交易时段,将在交易时间自动开启
- ) : null} -
- )} + {realtimeEnabled + && (!isNoneTier || realtimeProviderName) + && (isPaused || (isWatchlistMode && !dismissFreeHint && !realtimeProviderName)) + && ( +
+ {isWatchlistMode && !dismissFreeHint && !realtimeProviderName && ( +
+ 监控自选股前 5 只,全市场监控需 Starter+ + +
+ )} + {isPaused && ( +
数据同步运行中,实时行情已临时暂停
+ )} +
+ )} {showSidebarQuotes && !isWatchlistMode && (!isNoneTier || !!realtimeProviderName) && ( )} diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index cc4baff..6623e5b 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -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" > {/* 左方括号 */} + directions: Record + onChange: (value: Record, directions: Record) => void + fallbackLabels?: Record +} + +function weightsToPercentages(values: Record) { + 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 + } + + 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 +} + +function normalizePercentages(values: Record) { + 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 +} + +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 ( +
+ {label} + {editing ? ( +
+ {([['high', ArrowUp, '偏好高值'], ['low', ArrowDown, '偏好低值']] as const).map(([value, Icon, title]) => ( + + ))} +
+ ) : ( + + {direction === 'low' ? : } + {direction === 'low' ? '低值' : '高值'} + + )} + {editing ? ( + onWeightChange(Number(event.target.value))} + className="h-1 min-w-0 cursor-pointer accent-amber-400" + aria-label={`${label}权重`} + /> + ) : ( +
+
+
+ )} + {weight}% + {editing ? ( + + ) :
+ ) +} + +export function ScoringEditor({ value, directions, onChange, fallbackLabels = {} }: Props) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState>(() => weightsToPercentages(value)) + const [directionDraft, setDirectionDraft] = useState>(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 = {} + 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 + 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) + } + + 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 ( +
+ {editing && ( +
+ + +
+ )} + + {visibleKeys.length > 0 ? ( +
+ {visibleKeys.map(name => ( + setDraft(current => ({ ...current, [name]: Math.max(0, weight) }))} + onDirectionChange={direction => setDirectionDraft(current => ({ ...current, [name]: direction }))} + onRemove={() => removeFactor(name)} + /> + ))} +
+ ) : ( +
+ {editing ? '请选择评分因子' : '当前策略不使用因子评分'} +
+ )} + +
+
+ 权重 + {editing ? draftTotal : visibleKeys.length > 0 ? 100 : 0}% + +
+
+ {editing && ( + + )} + +
+
+
+ ) +} diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 1504b5b..a640aa2 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -61,6 +61,8 @@ const emptyRule = (preset?: Partial): 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) { })}
+
+
+ 评分范围 +
+
+ + + +
+
+
通知事件 diff --git a/frontend/src/components/screener/StrategyBuilderDialog.tsx b/frontend/src/components/screener/StrategyBuilderDialog.tsx index 68d22fb..8c7e0af 100644 --- a/frontend/src/components/screener/StrategyBuilderDialog.tsx +++ b/frontend/src/components/screener/StrategyBuilderDialog.tsx @@ -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 修改
-

修改指令可调整参数、信号、告警、评分等任意内容。确认无误后点击「保存策略」。

+

修改指令可调整参数、信号、评分等任意内容。确认无误后点击「保存策略」。

)} diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx index 0a01321..6687cd6 100644 --- a/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -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 = {} @@ -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 ( -
- {FIELD_LABEL[col] ?? col} - {editing ? ( - onChange(Number(e.target.value))} - min={0} max={100} step={1} - className="flex-1 h-1 accent-amber-400 cursor-pointer" - /> - ) : ( -
-
-
- )} - {editing ? weight : `${pct}%`} -
- ) -} - export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) { const [detail, setDetail] = useState(null) const [loading, setLoading] = useState(false) @@ -210,6 +186,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif const [basicFilter, setBasicFilter] = useState>({}) const [params, setParams] = useState>({}) const [scoring, setScoring] = useState>({}) + const [scoringDirections, setScoringDirections] = useState>({}) const [stopLoss, setStopLoss] = useState(null) const [maxHoldDays, setMaxHoldDays] = useState(null) const [entrySignals, setEntrySignals] = useState([]) @@ -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:评分 + 交易 */}
- {Object.entries(scoring).length > 0 ? (() => { - const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1 - return ( -
- {Object.entries(scoring).map(([col, w]) => { - const pct = Math.round((w / total) * 100) - return ( - setScoring({ ...scoring, [col]: Math.max(0, v) })} /> - ) - })} -
-
- 总和 - {editingScoring ? total : '100'} - 自动归权计算 -
- -
-
- ) - })() :
未配置
} + { + setScoring(nextScoring) + setScoringDirections(nextDirections) + }} + />
@@ -657,15 +601,6 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif 出入场触发器保存后对回测和监控生效;选股扫描仍按策略本身的筛选规则,不受此影响。
- {detail.alerts.length > 0 && ( -
-
- {detail.alerts.map((a, i) => ( -
{a.message} {a.op ? `${FIELD_LABEL[a.field] ?? a.field} ${a.op} ${a.value}` : FIELD_LABEL[a.field] ?? a.field}
- ))} -
-
- )}
)} diff --git a/frontend/src/custom/README.md b/frontend/src/custom/README.md new file mode 100644 index 0000000..25a1ace --- /dev/null +++ b/frontend/src/custom/README.md @@ -0,0 +1,9 @@ +# 前端二次开发目录 + +在独立子目录中创建 `extension.tsx`,构建时会自动发现,无需修改核心路由和导航文件。 + +```text +frontend/src/custom//extension.tsx +``` + +以 [`_template/extension.tsx.example`](_template/extension.tsx.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与构建。 diff --git a/frontend/src/custom/_template/extension.tsx.example b/frontend/src/custom/_template/extension.tsx.example new file mode 100644 index 0000000..b9596e6 --- /dev/null +++ b/frontend/src/custom/_template/extension.tsx.example @@ -0,0 +1,30 @@ +import { ShieldCheck } from 'lucide-react' +import type { FrontendExtension } from '@/extensions/types' + +function RiskPage() { + return
风险分析
+} + +function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string }) { + return collapsed ? null :
二开内容
+} + +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 diff --git a/frontend/src/extensions/ExtensionBoundary.tsx b/frontend/src/extensions/ExtensionBoundary.tsx new file mode 100644 index 0000000..c53d319 --- /dev/null +++ b/frontend/src/extensions/ExtensionBoundary.tsx @@ -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 { + 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 ( +
+ 扩展 {this.props.extensionId} 暂时不可用 +
+ ) + } +} diff --git a/frontend/src/extensions/ExtensionSlot.tsx b/frontend/src/extensions/ExtensionSlot.tsx new file mode 100644 index 0000000..a02913e --- /dev/null +++ b/frontend/src/extensions/ExtensionSlot.tsx @@ -0,0 +1,26 @@ +import type { FrontendSlotContextMap, FrontendSlotName } from './types' +import { getFrontendSlotRegistrations } from './registry' +import { ExtensionBoundary } from './ExtensionBoundary' + +type Props = { + name: K + context: FrontendSlotContextMap[K] + compact?: boolean +} + +export function ExtensionSlot({ name, context, compact }: Props) { + const registrations = getFrontendSlotRegistrations(name) + if (registrations.length === 0) return null + return registrations.map(registration => { + const SlotComponent = registration.component + return ( + + + + ) + }) +} diff --git a/frontend/src/extensions/bootstrap.ts b/frontend/src/extensions/bootstrap.ts new file mode 100644 index 0000000..3234b2c --- /dev/null +++ b/frontend/src/extensions/bootstrap.ts @@ -0,0 +1,10 @@ +import type { FrontendExtensionModule } from './types' +import { loadFrontendExtensions } from './registry' + +const modules = import.meta.glob( + '../custom/*/extension.tsx', +) + +export async function initializeFrontendExtensions() { + await loadFrontendExtensions(modules) +} diff --git a/frontend/src/extensions/registry.ts b/frontend/src/extensions/registry.ts new file mode 100644 index 0000000..4934904 --- /dev/null +++ b/frontend/src/extensions/registry.ts @@ -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() +const routes = new Map() +const navigation = new Map() +const slots = new Map>() +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() + const localRoutePaths = new Set() + 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() + 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() + 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 Promise>, +) { + 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) { + 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(name: K) { + if (!frozen) throw new Error('读取扩展插槽前必须冻结注册表') + return (slots.get(name) ?? []) as Array & { extensionId: string }> +} + +export function getFrontendExtensionLoadErrors() { + return [...loadErrors] +} diff --git a/frontend/src/extensions/types.ts b/frontend/src/extensions/types.ts new file mode 100644 index 0000000..9ab4962 --- /dev/null +++ b/frontend/src/extensions/types.ts @@ -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 = { + name: K + id: string + order?: number + component: ComponentType +} + +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 +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b9e72d2..8433d58 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -543,6 +543,7 @@ export interface StrategyDetail { params: StrategyParamDef[] params_defaults: Record scoring: Record + scoring_directions: Record 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 @@ -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 + 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 + metrics: Record + 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 + metrics: Record + 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('/api/backtest/factor/batch', { + method: 'POST', + body: JSON.stringify(payload), + }), + + researchCandidates: () => + request<{ items: ResearchCandidate[] }>('/api/backtest/candidates'), + + researchCandidateCreate: (payload: ResearchCandidateCreate) => + request('/api/backtest/candidates', { + method: 'POST', + body: JSON.stringify(payload), + }), + + researchCandidateUpdate: ( + id: string, + payload: { name?: string; status?: ResearchCandidateStatus }, + ) => + request(`/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) => + 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' }), diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 27489ac..073eef0 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 diff --git a/frontend/src/lib/strategyOverrides.ts b/frontend/src/lib/strategyOverrides.ts index 23106fd..87b6492 100644 --- a/frontend/src/lib/strategyOverrides.ts +++ b/frontend/src/lib/strategyOverrides.ts @@ -12,6 +12,8 @@ export function buildDefaultOverrides(detail: StrategyDetail): Record - - - - -) +async function bootstrap() { + await initializeFrontendExtensions() + const { router } = await import('./router') + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , + ) +} + +void bootstrap() diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index cdc69ba..b748c32 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -89,7 +89,7 @@ export function Auth() { {/* Logo */}
-

TickFlow Stock Panel

+

Tick Stock Panel

diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index c12e745..89bf7c3 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -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 = { +const MODES: Record = { 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 = { - factor: BarChart3, - strategy: FlaskConical, - optimizer: SlidersHorizontal, - walkforward: Waypoints, } export function Backtest() { const [activeTab, setActiveTab] = useState('strategy') - - const modeSwitch = ( -
- {(['factor', 'strategy', 'optimizer', 'walkforward'] as const).map(tab => { - const Icon = TAB_ICONS[tab] - const active = activeTab === tab - return ( - - ) - })} -
- ) + const [candidatesOpen, setCandidatesOpen] = useState(false) return ( -
+
{MODES[activeTab].subtitle}} + className="shrink-0 flex-wrap gap-x-4 gap-y-2 bg-base/95 px-3 lg:flex-nowrap lg:px-5" + right={( +
+ +
+ )} /> -
- {activeTab === 'factor' && } +
+ {activeTab === 'factor' && } {activeTab === 'strategy' && } - {activeTab === 'optimizer' && } - {activeTab === 'walkforward' && } + {activeTab === 'robustness' && }
+ + {candidatesOpen && setCandidatesOpen(false)} />}
) } diff --git a/frontend/src/pages/Branding.tsx b/frontend/src/pages/Branding.tsx index 0dc350c..5487584 100644 --- a/frontend/src/pages/Branding.tsx +++ b/frontend/src/pages/Branding.tsx @@ -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() { <>
diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 4ceb114..5af1d56 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -780,6 +780,11 @@ function RulesList({ rulesQuery, onEdit }: {
) : r.type === 'strategy' && r.strategy_id ? (
+ {(r.score_min != null || r.score_max != null) && ( + + 评分 {r.score_min ?? 0}–{r.score_max ?? 100} + + )} {(r.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).map(event => { const option = STRATEGY_NOTIFY_EVENT_OPTIONS.find(item => item.key === event) return option ? ( diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx index fb9da0d..a983622 100644 --- a/frontend/src/pages/Onboarding.tsx +++ b/frontend/src/pages/Onboarding.tsx @@ -108,7 +108,7 @@ export function Onboarding() { className="shrink-0" style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }} /> - TickFlow Stock Panel + Tick Stock Panel
{/* 步骤进度条 —— 胶囊式 */}
@@ -237,7 +237,7 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi

- 欢迎使用 TickFlow Stock Panel + 欢迎使用 Tick Stock Panel

一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。 diff --git a/frontend/src/pages/backtest/FactorBacktest.tsx b/frontend/src/pages/backtest/FactorBacktest.tsx index a92c3a0..b356ce7 100644 --- a/frontend/src/pages/backtest/FactorBacktest.tsx +++ b/frontend/src/pages/backtest/FactorBacktest.tsx @@ -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(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" > @@ -343,6 +364,15 @@ export function FactorBacktest() {

因子预测能力

+ Rank IC · 日度调仓 diff --git a/frontend/src/pages/backtest/FactorDiscovery.tsx b/frontend/src/pages/backtest/FactorDiscovery.tsx new file mode 100644 index 0000000..3f901f3 --- /dev/null +++ b/frontend/src/pages/backtest/FactorDiscovery.tsx @@ -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([]) + 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('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 = { 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 = {} + 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 ( +
+
+
+
+
筛选配置
+
已选 {selected.length} / {allColumns.length}
+
+ +
+ +
+ {Object.entries(factorGroups).map(([group, items]) => { + const groupSelected = items.filter(item => selected.includes(item.id)).length + return ( +
+
+ {group} + +
+
+ {items.map(item => ( + + ))} +
+
+ ) + })} +
+ +
+ +
+ {(['stock', 'etf'] as const).map(value => ( + + ))} +
+
+ setSymbols(event.target.value)} + placeholder="留空使用全市场" + className={`${INPUT_CLS} min-w-0 flex-1 font-mono`} + /> + + + {watchlist.isLoading ? '加载中' : watchlistEntries.length === 0 ? '自选为空' : '从自选加入'} + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ {[3, 6, 12].map(months => ( + + ))} +
+
+ +
+ + + +
+ + +
+ +
+ {run.isPending && ( +
+ + 正在加载共享数据面板并评估 {selected.length} 个因子 +
+ )} + {run.isError && ( +
+ {String((run.error as Error).message)} +
+ )} + {run.data?.error && ( +
{run.data.error}
+ )} + {!run.data && !run.isPending && ( + + )} + {run.data && !run.data.error && ( +
+
+
+
筛选结果
+
+ {run.data.results.length} 个因子 + {run.data.n_symbols} 只标的 + {run.data.n_dates} 个交易日 + {run.data.elapsed_ms.toFixed(0)} ms +
+
+ +
+
+ + + + + + + + + + + + + + + {sortedResults.map((item, index) => ( + + + + + + + + + + + ))} + +
排名因子IC 均值IRIC 胜率多空收益最大回撤操作
{index + 1} +
{item.label}
+
{item.group} · {item.factor_name}
+ {item.error &&
{item.error}
} +
{item.ic_mean == null ? '—' : fmtPct(item.ic_mean)}{item.ir == null ? '—' : item.ir.toFixed(2)}{item.ic_win_rate == null ? '—' : fmtPct(item.ic_win_rate)}{item.long_short_return == null ? '—' : fmtPct(item.long_short_return)}{item.long_short_max_drawdown == null ? '—' : fmtPct(item.long_short_max_drawdown)} +
+ + +
+
+
+
+ )} +
+
+ ) +} + +export function FactorDiscovery() { + const [view, setView] = useState('batch') + const [detailFactor, setDetailFactor] = useState('momentum_20d') + const inspect = (factorName: string) => { + setDetailFactor(factorName) + setView('single') + } + return ( +
+
+
+ {([ + ['batch', '批量筛选', ListFilter], + ['single', '单因子检验', Layers3], + ] as const).map(([value, label, Icon]) => ( + + ))} +
+
+
+ {view === 'batch' + ? + : + } +
+
+ ) +} diff --git a/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx b/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx new file mode 100644 index 0000000..0d8534b --- /dev/null +++ b/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx @@ -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(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 ( + +
+ +
+

候选方案

+
仅保存研究定义、配置与指标摘要
+
+ +
+ +
+ {([['all', '全部'], ['factor', '因子'], ['strategy', '策略']] as const).map(([value, label]) => ( + + ))} + {visible.length} 个 +
+ +
+ {candidates.isLoading && ( +
加载中…
+ )} + {candidates.isError && ( +
+ {String((candidates.error as Error).message)} +
+ )} + {!candidates.isLoading && !candidates.isError && visible.length === 0 && ( +
+ +
暂无候选方案
+
+ )} + {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 ( +
+
+
+
+ + {item.kind === 'factor' ? '因子' : '策略'} + + {item.name} +
+
+ {metricSummary(item)} + {item.data_as_of && 数据截至 {item.data_as_of}} + {item.kind === 'factor' && !factorSupported && !factorColumns.isLoading && ( + 当前因子目录不支持 + )} +
+
+ +
+ {item.kind === 'factor' && ( + + )} + +
+
+ {linking && linkDraft && ( +
+ +
+ 评分方向 +
+ {([['high', ArrowUp, '高值'], ['low', ArrowDown, '低值']] as const).map(([value, Icon, label]) => ( + + ))} +
+
+ +
+ + +
+
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/pages/backtest/RobustnessValidation.tsx b/frontend/src/pages/backtest/RobustnessValidation.tsx new file mode 100644 index 0000000..3972c7c --- /dev/null +++ b/frontend/src/pages/backtest/RobustnessValidation.tsx @@ -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('sensitivity') + return ( +
+
+
+ {([ + ['sensitivity', '参数优化', SlidersHorizontal], + ['walkforward', '步进优化', Waypoints], + ] as const).map(([value, label, Icon]) => ( + + ))} +
+
+
+ {mode === 'sensitivity' ? : } +
+
+ ) +} diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index ae155ca..a475112 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -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 | 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) => { - 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 - return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Math.round((Math.max(0, Number(v) || 0) / total) * 100)])) as Record -} - -const normalizePctWeights = (values: Record) => { - 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 - return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, +(Math.max(0, Number(v) || 0) / total).toFixed(4)])) as Record -} - -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 ( -
- {label} - {editing ? ( - onChange(Number(e.target.value))} - className="h-1 flex-1 cursor-pointer accent-amber-400" - /> - ) : ( -
-
-
- )} - {editing ? weight : `${pct}%`} -
- ) -} - 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(saved?.selectedStrategy ?? null) @@ -963,8 +936,6 @@ export function StrategyBacktest() { const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false) const [quickRanges, setQuickRanges] = useState(loadQuickRanges) const [settingsTab, setSettingsTab] = useState('params') - const [editingScoring, setEditingScoring] = useState(false) - const [scoringDraft, setScoringDraft] = useState>({}) const [strategyParams, setStrategyParams] = useState>(saved?.params ?? {}) const [overrides, setOverrides] = useState>(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, [overrides.scoring]) + const scoringDirections = useMemo( + () => (overrides.scoring_directions ?? {}) as Record, + [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() { ))}
- {simMode === 'full' && ( - maxHoldDaysValue !== '' ? ( -
- 策略最长 {maxHoldDaysValue} 天 -
- ) : ( -
- 兜底上限 -
- {(['1', '5', '10', '20'] as const).map(d => ( - - ))} +
+ {simMode === 'full' && ( + maxHoldDaysValue !== '' ? ( +
+ 策略最长 {maxHoldDaysValue}
-
- ) - )} + ) : ( +
+ 兜底上限 +
+ {(['1', '5', '10', '20'] as const).map(d => ( + + ))} +
+
+ ) + )} + {result && !result.error && ( + + )} +
{/* 市场环境过滤: 只在指定环境的交易日入场(强制 T-1, 用前一日环境判定) */} @@ -2575,53 +2556,19 @@ export function StrategyBacktest() { )} {settingsTab === 'scoring' && ( - - {Object.entries(scoring).length > 0 ? (() => { - const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring) - const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0) - return ( -
-
- {Object.keys(scoring).map(key => ( - setScoringDraft(prev => ({ ...prev, [key]: Math.max(0, value) }))} - /> - ))} -
-
-
- 总和 {editingScoring ? total : 100} - 保存时自动归一化计算 -
-
- {editingScoring && ( - - )} - -
-
-
- ) - })() : ( -
当前策略没有评分权重。
- )} + + setOverrides(previous => ({ + ...previous, + scoring: nextScoring, + scoring_directions: nextDirections, + scoring_replace: true, + }))} + />
评分过滤 diff --git a/frontend/src/pages/backtest/researchCandidates.ts b/frontend/src/pages/backtest/researchCandidates.ts new file mode 100644 index 0000000..288d5a0 --- /dev/null +++ b/frontend/src/pages/backtest/researchCandidates.ts @@ -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, fields: readonly string[]) { + const result: Record = {} + 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, + } +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 05355d8..dcfea77 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,6 +5,12 @@ import { Onboarding } from './pages/Onboarding' import { Auth } from './pages/Auth' import { useSettings } from './lib/useSharedQueries' import { Logo } from './components/Logo' +import { ExtensionBoundary } from './extensions/ExtensionBoundary' +import { + finalizeFrontendExtensions, + getFrontendExtensionLoadErrors, + getFrontendExtensionRoutes, +} from './extensions/registry' // 代码分割: 页面全部 lazy 加载, 避免首屏打包所有页面 (ECharts / lightweight-charts / // framer-motion 等重库) → 大幅减小首屏 bundle。命名导出用 .then 映射为 default。 @@ -28,6 +34,41 @@ const Indices = lazy(() => import('./pages/Indices').then(m => ({ default: m.Ind const Regime = lazy(() => import('./pages/Regime').then(m => ({ default: m.Regime }))) const Dev = lazy(() => import('./pages/Dev').then(m => ({ default: m.Dev }))) +const CORE_ROUTE_PATHS = new Set([ + '/', + '/onboarding', + '/login', + '/overview', + '/analysis', + '/analysis/:menuId', + '/concept-analysis', + '/industry-analysis', + '/stock-analysis', + '/review', + '/watchlist', + '/screener', + '/backtest', + '/financials', + '/data', + '/monitor', + '/limit-ladder', + '/indices', + '/regime', + '/branding', + '/settings', + '/dev', + '/settings/keys', + '/settings/ai', + '/settings/queries', +]) + +finalizeFrontendExtensions(CORE_ROUTE_PATHS) +const frontendExtensionRoutes = getFrontendExtensionRoutes() +const frontendExtensionErrors = getFrontendExtensionLoadErrors() +if (frontendExtensionErrors.length > 0) { + console.error('部分前端扩展加载失败', frontendExtensionErrors) +} + // 首次使用守卫 —— 未完成向导则重定向到 /onboarding // 只挂在根路由上;/onboarding 本身不被守卫,避免循环重定向。 // settings 由 Layout 预取,守卫判定不产生额外请求。 @@ -92,6 +133,17 @@ export const router = createBrowserRouter([ { path: 'settings/keys', element: }, { path: 'settings/ai', element: }, { path: 'settings/queries', element: }, + ...frontendExtensionRoutes.map(route => { + const ExtensionPage = route.component + return { + path: route.path.slice(1), + element: ( + + + + ), + } + }), ], }, ]) diff --git a/packaging/tickflow-logo.svg b/packaging/tickflow-logo.svg index aa13b43..2e98355 100644 --- a/packaging/tickflow-logo.svg +++ b/packaging/tickflow-logo.svg @@ -1,4 +1,4 @@ - + diff --git a/packaging/tickflow.iss b/packaging/tickflow.iss index 6cdbae5..2861cd7 100644 --- a/packaging/tickflow.iss +++ b/packaging/tickflow.iss @@ -1,5 +1,5 @@ ; =========================================================================== -; TickFlow Stock Panel — Inno Setup 安装包脚本 +; Tick Stock Panel — Inno Setup 安装包脚本 ; =========================================================================== ; 用途: 把 PyInstaller 产出的 dist/TickFlowStockPanel/ 文件夹封装成 ; 单个 Setup.exe 安装程序 (双击→安装向导→快捷方式→可卸载)。 @@ -19,7 +19,7 @@ ; =========================================================================== #define MyAppName "TickFlow 股票面板" -#define MyAppNameEN "TickFlow Stock Panel" +#define MyAppNameEN "Tick Stock Panel" #define MyAppExeName "TickFlowStockPanel.exe" #define MyAppPublisher "TickFlow" diff --git a/packaging/tickflow.spec b/packaging/tickflow.spec index 11cb59e..ebec60b 100644 --- a/packaging/tickflow.spec +++ b/packaging/tickflow.spec @@ -205,7 +205,7 @@ if _IS_MACOS: bundle_identifier="com.tickflow.stockpanel", version=APP_VERSION, # → CFBundleShortVersionString / CFBundleVersion info_plist={ - "CFBundleName": "TickFlow Stock Panel", + "CFBundleName": "Tick Stock Panel", "CFBundleDisplayName": "TickFlow 股票面板", "CFBundleVersion": APP_VERSION, "NSHighResolutionCapable": True, diff --git a/scripts/upgrade_check.py b/scripts/upgrade_check.py new file mode 100644 index 0000000..fa68c40 --- /dev/null +++ b/scripts/upgrade_check.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Read-only Git overlap and conflict preview for secondary-development branches.""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *args], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if check and result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise RuntimeError(detail or f"git {' '.join(args)} failed") + return result + + +def _lines(result: subprocess.CompletedProcess[str]) -> set[str]: + return {line.strip() for line in result.stdout.splitlines() if line.strip()} + + +def _resolve_repo(path: Path) -> Path: + result = _git(path, "rev-parse", "--show-toplevel") + return Path(result.stdout.strip()).resolve() + + +def _resolve_commit(root: Path, ref: str) -> str: + return _git(root, "rev-parse", "--verify", f"{ref}^{{commit}}").stdout.strip() + + +def _merge_tree_conflicts(root: Path, base: str, current: str, target: str) -> list[str]: + result = _git(root, "merge-tree", base, current, target) + lines = result.stdout.splitlines() + conflicts: list[str] = [] + current_path: str | None = None + current_conflicted = False + for line in [*lines, ""]: + if line in {"changed in both", "added in both", "removed in both"}: + if current_path and current_conflicted: + conflicts.append(current_path) + current_path = None + current_conflicted = False + continue + parts = line.strip().split() + if current_path is None and len(parts) >= 4 and parts[0] in {"base", "our", "their"}: + current_path = parts[-1] + if line.startswith("+<<<<<<< ") or line.startswith("+=======") or line.startswith("+>>>>>>> "): + current_conflicted = True + if current_path and current_conflicted: + conflicts.append(current_path) + return sorted(set(conflicts)) + + +def _print_paths(title: str, paths: list[str]) -> None: + print(f"\n{title}: {len(paths)}") + for path in paths: + print(f" - {path}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="只读检查当前二开分支升级到目标 Git ref 时的源码重叠与文本冲突", + ) + parser.add_argument("target", help="目标 Tag/分支/commit, 例如 v0.3.1") + parser.add_argument( + "--repo", + type=Path, + default=Path.cwd(), + help="仓库路径, 默认当前目录", + ) + args = parser.parse_args(argv) + + try: + root = _resolve_repo(args.repo.resolve()) + current = _resolve_commit(root, "HEAD") + target = _resolve_commit(root, args.target) + base = _git(root, "merge-base", current, target).stdout.strip() + if not base: + raise RuntimeError("当前分支与目标版本没有共同基线") + + current_paths = _lines(_git(root, "diff", "--name-only", f"{base}..{current}")) + target_paths = _lines(_git(root, "diff", "--name-only", f"{base}..{target}")) + overlap = sorted(current_paths & target_paths) + conflicts = _merge_tree_conflicts(root, base, current, target) + dirty = sorted(_lines(_git(root, "status", "--short"))) + + print("Tick Stock Panel 二开升级预检") + print(f"仓库: {root}") + print(f"当前: {current[:12]}") + print(f"目标: {args.target} ({target[:12]})") + print(f"共同基线: {base[:12]}") + print(f"当前分支修改文件: {len(current_paths)}") + print(f"目标版本修改文件: {len(target_paths)}") + _print_paths("双方修改的同一文件", overlap) + _print_paths("Git 三方预演识别的文本冲突", conflicts) + + if dirty: + print(f"\n未提交工作区条目: {len(dirty)}") + print(" 注意: 未提交内容不会进入 Git 三方预演, 请先提交到临时分支后再复检。") + if conflicts: + print("\n结论: 需要人工解决文本冲突并执行完整回归。") + return 2 + if overlap: + print("\n结论: Git 未发现文本冲突, 但重叠文件需要检查语义兼容和测试。") + return 0 + print("\n结论: 未发现源码重叠; 仍需执行目标版本的验证矩阵。") + return 0 + except (OSError, RuntimeError) as exc: + print(f"升级预检失败: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())