From 254e1962d08be782695a14fc4647628fb4810587 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sat, 22 Aug 2026 20:26:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(v0.2):=20=E8=87=AA=E9=80=89=E5=88=86?= =?UTF-8?q?=E7=BB=84=E7=9B=91=E6=8E=A7=E4=BD=9C=E7=94=A8=E5=9F=9F=20+=20?= =?UTF-8?q?=E8=87=AA=E9=80=89=E9=A1=B5=E4=BA=A4=E4=BA=92=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=20+=20=E5=BC=B1=E5=8C=96=E9=BB=98=E8=AE=A4=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 监控规则新增 scope=watchlist_group: 规则只存 group_id, 引擎按版本号缓存的 分组成员动态解析, 分组增删标的自动同步监控范围, 无需改规则 - 分组删除 fail-closed (规则暂停+runtime_warning), API 层校验分组存在性 - watchlist 服务增加数据版本号 _REVISION, 读取免锁、写后立即失效引擎缓存 - stock-sdk 插件 display_name 去掉括号合规备注 (说明保留在 description) 前端: - RuleEditor 分组模式交互 (选择分组/成员预览/动态绑定提示), Monitor 列表分组摘要 - 自选搜索: Plus 快速加当前分组 + FolderPlus 展开分组菜单; 多分组圆点叠瓦显示 - 搜索下拉加宽防名称截断, 创/科/ETF 标签紧贴名称 - 自选筛选新增「排除ST」开关 (默认关, 持久化) - 个股详情新增异动信息条: 状态着色/窗口偏离/接近度/计算时间 (无数据不显示) - 引导页: 能力探测按数据源分流 (仅选 TickFlow 才显示 Key/档位), 切换 loading 收进卡片 - 看板空态/首次弹窗按数据源分流文案, 去除 Key/None 档引导注册表述 - 侧边栏实时行情无权限态改为「不可用 + 去配置数据源」 - 设置-数据源页: 新增插件化与配置文档说明块, 内置/插件标识统一改为第三方 测试: test_monitor_group_scope.py 8 例 (动态成员/删除分组/校验/API), 全量 946 passed --- backend/app/api/monitor_rules.py | 28 ++- backend/app/plugins/stocksdk/plugin.yaml | 2 +- backend/app/services/watchlist.py | 12 ++ backend/app/strategy/monitor.py | 73 ++++++- backend/app/strategy/monitor_rules.py | 17 +- backend/tests/test_monitor_group_scope.py | 183 ++++++++++++++++++ frontend/src/components/Layout.tsx | 23 +-- .../src/components/StockPreviewDialog.tsx | 77 +++++++- frontend/src/components/WatchlistGroups.tsx | 13 +- .../src/components/monitor/RuleEditor.tsx | 154 ++++++++++++++- frontend/src/lib/api.ts | 4 +- frontend/src/lib/storage.ts | 3 + frontend/src/pages/Dashboard.tsx | 53 +++-- frontend/src/pages/Monitor.tsx | 49 ++++- frontend/src/pages/Onboarding.tsx | 177 ++++++++++++----- frontend/src/pages/Watchlist.tsx | 104 +++++++--- frontend/src/pages/settings/DataSources.tsx | 25 ++- 17 files changed, 863 insertions(+), 134 deletions(-) create mode 100644 backend/tests/test_monitor_group_scope.py diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 0456ec7..0d9286d 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -79,8 +79,10 @@ class RuleModel(BaseModel): enabled: bool = True type: str # strategy | signal | price | market | sector | abnormal asset_type: str = "stock" # stock | etf (etf: strategy 型走 ETF 历史加载器) - scope: str = "symbols" # symbols | all | sector + scope: str = "symbols" # symbols | all | sector | watchlist_group symbols: list[str] = [] + # watchlist_group 作用域: 绑定的自选分组 id (成员动态解析, 增删自选自动生效) + group_id: str | None = None sector: str | None = None sector_kind: str | None = None # index | concept | industry sector_targets: list[SectorTargetModel] = [] @@ -161,6 +163,7 @@ def get_options(request: Request): ], "scopes": [ {"key": "symbols", "label": "指定标的"}, + {"key": "watchlist_group", "label": "自选分组"}, {"key": "all", "label": "全市场"}, {"key": "sector", "label": "板块"}, ], @@ -227,6 +230,18 @@ def list_rules(request: Request): rule["runtime_warning"] = "部分板块数据已不存在, 请重新选择监控对象" elif unavailable: rule["runtime_warning"] = "所选指数未加入实时指数池, 请先在实时监控设置中启用" + # 分组作用域规则: 绑定的分组被删除 → 标注运行时警告 (引擎侧已 fail-closed 跳过) + group_rules = [rule for rule in rules if rule.get("scope") == "watchlist_group"] + if group_rules: + from app.services import watchlist as watchlist_service + + try: + existing_ids = {g["id"] for g in watchlist_service.list_groups()} + for rule in group_rules: + if rule.get("group_id") not in existing_ids: + rule["runtime_warning"] = "绑定的自选分组已删除, 规则已暂停监控, 编辑可重新选择" + except Exception: # noqa: BLE001 + pass # 按 created_at 倒序 rules.sort(key=lambda r: r.get("created_at", ""), reverse=True) return {"rules": rules} @@ -273,6 +288,17 @@ def save_rule(req: RuleModel, request: Request): monitor_rules.validate(rule) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + if rule.get("scope") == "watchlist_group": + # 绑定的分组必须存在 (strategy 层校验形状, 存在性在本层校验) + from app.services import watchlist as watchlist_service + + group_id = str(rule.get("group_id") or "") + try: + group_ids = {g["id"] for g in watchlist_service.list_groups()} + except Exception as e: # noqa: BLE001 + raise HTTPException(status_code=503, detail=f"自选分组读取失败: {e}") from e + if group_id not in group_ids: + raise HTTPException(status_code=400, detail="自选分组不存在或已被删除, 请重新选择") if rule.get("type") == "sector": sector_service = getattr(request.app.state, "sector_monitor_service", None) if sector_service is None: diff --git a/backend/app/plugins/stocksdk/plugin.yaml b/backend/app/plugins/stocksdk/plugin.yaml index 05308b9..c1a5c49 100644 --- a/backend/app/plugins/stocksdk/plugin.yaml +++ b/backend/app/plugins/stocksdk/plugin.yaml @@ -5,7 +5,7 @@ # 开发模式下需手动安装依赖: cd backend/app/plugins/stocksdk && npm install name: stocksdk -display_name: "stock-sdk(第三方行情·合规风险自负)" +display_name: "stock-sdk" runtime: node entry: app.plugins.stocksdk.provider:StockSDKProvider check: app.plugins.stocksdk.bridge:availability diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 321d665..9ad81e7 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -31,6 +31,14 @@ from app.tickflow.rate_limits import chunked, resolve_limit logger = logging.getLogger(__name__) _LOCK = threading.RLock() +# 数据版本号: 每次写盘 +1 (在 _LOCK 内递增, 读取免锁)。供监控引擎等进程内 +# 消费方做缓存失效判断 —— 版本没变就不必重读文件, 版本一变立即拿到新成员。 +_REVISION = 0 + + +def revision() -> int: + """自选/分组数据版本号, 每次写操作递增。""" + return _REVISION _MAX_GROUP_NAME_LENGTH = 24 DEFAULT_GROUP_COLOR = "sky" GROUP_COLORS = frozenset({ @@ -92,6 +100,7 @@ def _read_entries() -> pl.DataFrame: def _write_entries(df: pl.DataFrame) -> None: + global _REVISION p = _path() # 首次从旧 schema 迁移到 group_ids 前, 备份原文件(一次性) if p.exists(): @@ -103,6 +112,7 @@ def _write_entries(df: pl.DataFrame) -> None: tmp = p.with_suffix(p.suffix + ".tmp") df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp) os.replace(tmp, p) + _REVISION += 1 def _read_groups() -> list[dict]: @@ -129,10 +139,12 @@ def _read_groups() -> list[dict]: def _write_groups(groups: list[dict]) -> None: + global _REVISION p = _groups_path() tmp = p.with_suffix(p.suffix + ".tmp") tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(tmp, p) + _REVISION += 1 def _normalize_group_name(name: str) -> str: diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 84d3bc2..162b324 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -222,6 +222,58 @@ class StrategyMonitorService: _SIGNAL_PREFIXES = ("signal_", "csg_") +# ── 自选分组作用域: group_id → 成员集合解析 (进程内缓存) ──── +# 缓存按 watchlist 数据版本号失效: 版本不变时零磁盘 IO; 自选页任何增删 +# 分组/成员的操作都会 bump 版本号, 下一轮评估立即拿到新成员 (无需等 TTL)。 +_group_cache_lock = threading.Lock() +_group_cache: dict[str, Any] = {} +# 已告警过的「分组已删除」(rule_id, group_id), 防止每轮评估刷日志 +_warned_missing_groups: set[tuple[str, str]] = set() + + +def _watchlist_groups_snapshot() -> dict[str, frozenset[str]]: + """返回 {group_id: 成员symbol集}。读前后版本一致才写缓存, 避免缓存住写竞态下的旧数据。""" + from app.services import watchlist + + rev_before = watchlist.revision() + with _group_cache_lock: + cached = _group_cache.get("groups") + if cached is not None and _group_cache.get("_rev") == rev_before: + return cached + groups: dict[str, set[str]] = {g["id"]: set() for g in watchlist.list_groups()} + for row in watchlist.list_symbols(): + for gid in row.get("group_ids") or []: + members = groups.get(gid) + if members is not None: + members.add(str(row["symbol"])) + frozen = {gid: frozenset(syms) for gid, syms in groups.items()} + if watchlist.revision() == rev_before: + with _group_cache_lock: + _group_cache["_rev"] = rev_before + _group_cache["groups"] = frozen + return frozen + + +def _group_members_or_none(rule: dict) -> frozenset[str] | None: + """解析规则绑定的分组成员; 分组已删除返回 None, 解析异常返回 None 并记日志。""" + group_id = str(rule.get("group_id") or "") + try: + groups = _watchlist_groups_snapshot() + except Exception as exc: # noqa: BLE001 + logger.warning("自选分组数据读取失败, 规则 %s 本轮跳过: %s", rule.get("id"), exc) + return None + members = groups.get(group_id) + if members is None: + key = (str(rule.get("id") or ""), group_id) + if key not in _warned_missing_groups: + _warned_missing_groups.add(key) + logger.warning( + "监控规则 %s 绑定的自选分组 %s 已删除, 本轮跳过 (fail-closed, 恢复分组后自动生效)", + rule.get("id"), group_id, + ) + return members + + def _is_signal_field(field: str) -> bool: return any(field.startswith(p) for p in _SIGNAL_PREFIXES) @@ -821,10 +873,16 @@ class MonitorRuleEngine: threshold = 0.7 direction = rule.get("direction", "both") window_filter = str(rule.get("abnormal_window", "any")) - scope_symbols = ( - {str(s) for s in rule.get("symbols", []) if s} - if rule.get("scope") == "symbols" else None - ) + if rule.get("scope") == "symbols": + scope_symbols = {str(s) for s in rule.get("symbols", []) if s} + elif rule.get("scope") == "watchlist_group": + # 异动规则同样支持动态分组; 分组已删除返回 None → 本轮整体跳过 + members = _group_members_or_none(rule) + if members is None: + return events + scope_symbols = set(members) + else: + scope_symbols = None seen: set[str] = set() for row in rows: @@ -1000,6 +1058,13 @@ class MonitorRuleEngine: if not syms: return df.head(0) return df.filter(pl.col("symbol").is_in(syms)) + if scope == "watchlist_group": + # 动态绑定自选分组: 每轮评估按分组当前成员过滤 (带版本号缓存)。 + # 分组已删除/暂时为空 → fail-closed 返回空, 绝不退化为全市场。 + members = _group_members_or_none(rule) + if not members: + return df.head(0) + return df.filter(pl.col("symbol").is_in(list(members))) if scope == "sector": # sector 过滤需 df 含板块列 (后续接入 ext_data JOIN)。在 JOIN 落地前 # fail-closed 返回空 —— 绝不退化为「全市场」误触发 (旧行为 return df 会让 diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 18fe4f6..35cd0bc 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -29,7 +29,7 @@ logger = logging.getLogger(__name__) # ── 常量 ──────────────────────────────────────────────── ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"} -SCOPES = {"symbols", "all", "sector"} +SCOPES = {"symbols", "all", "sector", "watchlist_group"} LOGICS = {"and", "or"} DIRECTIONS = {"entry", "exit", "both"} STRATEGY_NOTIFY_EVENTS = {"buy_signal", "sell_signal", "pool_entry", "pool_exit"} @@ -224,6 +224,14 @@ def validate(rule: dict) -> None: syms = rule.get("symbols") if not isinstance(syms, list) or len(syms) == 0: raise ValueError("scope=symbols 时 symbols 不能为空") + if rule.get("scope") == "watchlist_group": + # 动态绑定自选分组: 评估时实时解析成员 (分组后续增删自动生效)。 + # 分组存在性由 API 层在保存时校验 (strategy 层不依赖 services)。 + gid = rule.get("group_id") + if not isinstance(gid, str) or not gid.strip(): + raise ValueError("scope=watchlist_group 时必须选择自选分组") + if rule.get("asset_type", "stock") != "stock": + raise ValueError("自选分组作用域仅支持个股") if uses_intraday_signals(rule) and rule.get("scope") != "symbols": raise ValueError("分时穿越信号仅支持指定标的") # sector 作用域的板块 JOIN 尚未实现: _apply_scope 目前会退化为「全市场」, @@ -248,6 +256,12 @@ def normalize(rule: dict) -> dict: # sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的) r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols") r.setdefault("symbols", []) + r.setdefault("group_id", None) + # watchlist_group 作用域: 成员动态来自分组, symbols 不参与; 其他作用域清掉残留 group_id + if r.get("scope") == "watchlist_group": + r["symbols"] = [] + else: + r["group_id"] = None r.setdefault("sector", None) r.setdefault("sector_kind", None) r.setdefault("sector_targets", []) @@ -279,6 +293,7 @@ def normalize(rule: dict) -> dict: if r.get("type") == "sector": r["scope"] = "all" r["symbols"] = [] + r["group_id"] = None # abnormal 专属默认字段 (异动边缘监控) r.setdefault("abnormal_window", "any") r.setdefault("logic", "and") diff --git a/backend/tests/test_monitor_group_scope.py b/backend/tests/test_monitor_group_scope.py new file mode 100644 index 0000000..941b594 --- /dev/null +++ b/backend/tests/test_monitor_group_scope.py @@ -0,0 +1,183 @@ +"""监控规则 scope=watchlist_group — 自选分组动态作用域。 + +覆盖: 规则校验/normalize、引擎按分组当前成员过滤 (分组增删自选后无需改规则, +下一轮评估自动生效)、分组删除 fail-closed、异动规则分组过滤、API 保存时 +分组存在性校验与列表 runtime_warning。 +""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import monitor_rules as monitor_rules_api +from app.config import settings +from app.services import watchlist +from app.strategy import monitor_rules +from app.strategy.monitor import MonitorRuleEngine + + +def _group_rule(rid="r_grp", group_id="g1", **overrides): + rule = { + "id": rid, "name": rid, "type": "signal", "asset_type": "stock", + "scope": "watchlist_group", "group_id": group_id, "symbols": [], + "logic": "or", + "conditions": [{"field": "rsi_14", "op": "<", "value": 100}], + "cooldown_seconds": 0, "enabled": True, + } + rule.update(overrides) + return rule + + +def _stock_df(): + return pl.DataFrame({ + "symbol": ["600000.SH", "000001.SZ", "300750.SZ"], + "name": ["浦发银行", "平安银行", "宁德时代"], + "close": [10.0, 12.0, 200.0], + "change_pct": [1.0, 2.0, 3.0], + "rsi_14": [40.0, 50.0, 60.0], + }) + + +# ── 校验与 normalize ───────────────────────────────────── + +def test_group_scope_validation(): + with pytest.raises(ValueError, match="自选分组"): + monitor_rules.validate(_group_rule(group_id=None)) + with pytest.raises(ValueError, match="自选分组"): + monitor_rules.validate(_group_rule(group_id=" ")) + with pytest.raises(ValueError, match="仅支持个股"): + monitor_rules.validate(_group_rule(asset_type="etf")) + # 分时穿越信号仅支持指定标的 (沿用既有限制) + with pytest.raises(ValueError, match="分时穿越"): + monitor_rules.validate(_group_rule( + conditions=[{"field": "signal_intraday_avg_cross_up", "op": "truth"}], + )) + monitor_rules.validate(_group_rule()) # 合法 + + +def test_normalize_group_scope_fields(): + # 分组作用域: 保留 group_id, 清掉 symbols (成员动态来自分组) + r = monitor_rules.normalize(_group_rule(symbols=["600000.SH"])) + assert r["group_id"] == "g1" + assert r["symbols"] == [] + # 非分组作用域: 清掉残留 group_id + r = monitor_rules.normalize(_group_rule(scope="all")) + assert r["group_id"] is None + + +# ── 引擎: 动态成员过滤 ─────────────────────────────────── + +def test_engine_group_scope_dynamic_members(monkeypatch, tmp_path): + """分组内后续加入的标的, 无需修改规则即自动进入监控范围。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("核心池") + gid = group["id"] + watchlist.add("600000.SH", group_id=gid) + watchlist.add("000001.SZ", group_id=gid) + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule(group_id=gid)]) + df = _stock_df() + + events = eng.evaluate(df) + assert {e["symbol"] for e in events} == {"600000.SH", "000001.SZ"} + + # 分组新增宁德时代 → 同一条规则下一轮自动覆盖 (版本号缓存立即失效) + watchlist.add("300750.SZ", group_id=gid) + events = eng.evaluate(df) + assert "300750.SZ" in {e["symbol"] for e in events} + + # 移出分组 → 自动退出监控范围 + watchlist.remove_from_group("300750.SZ", gid) + events = eng.evaluate(df) + assert "300750.SZ" not in {e["symbol"] for e in events} + + +def test_engine_group_scope_missing_group_fail_closed(monkeypatch, tmp_path): + """分组已删除: 不崩、不触发、绝不退化为全市场。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + watchlist.create_group("核心池") # 让分组文件存在, 但规则绑定的 id 不在其中 + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule(group_id="ghost")]) + assert eng.evaluate(_stock_df()) == [] + + +def test_engine_group_scope_empty_group(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("空组") + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule(group_id=group["id"])]) + assert eng.evaluate(_stock_df()) == [] + + +def test_abnormal_group_scope_filtering(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("异动池") + gid = group["id"] + watchlist.add("600000.SH", group_id=gid) + + def _row(symbol, dev_3d): + return { + "symbol": symbol, "name": symbol, "board": "主板", "st": False, + "close": 10.0, "rt_pct": 1.0, + "windows": {"3d": {"value": dev_3d, "threshold": 0.2, "closeness": abs(dev_3d) / 0.2}}, + } + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule( + rid="r_ab", group_id=gid, type="abnormal", + scope="watchlist_group", threshold_pct=70, direction="both", + conditions=[], symbols=[], + )]) + high_rows = [_row("600000.SH", 0.16), _row("000001.SZ", 0.18), _row("300750.SZ", 0.19)] + low_rows = [_row("600000.SH", 0.10), _row("000001.SZ", 0.05), _row("300750.SZ", 0.05)] + # 边缘触发: 首轮观测不告警, 回落置 False 后再次上穿才触发 + eng.evaluate_abnormal(low_rows) + events = eng.evaluate_abnormal(high_rows) + # 只有分组内的 600000.SH 触发; 组外两只偏离更高也不会告警 + assert [e["symbol"] for e in events] == ["600000.SH"] + + +# ── API: 保存校验 + 列表警告 ──────────────────────────── + +def _fake_request(tmp_path): + repo = MagicMock() + repo.store.data_dir = tmp_path + repo.resolve_asset_type.return_value = "stock" + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo))) + + +def test_api_save_rejects_missing_group(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + watchlist.create_group("真实分组") + + req = _fake_request(tmp_path) + model = monitor_rules_api.RuleModel(**_group_rule(group_id="ghost")) + with pytest.raises(HTTPException) as exc_info: + monitor_rules_api.save_rule(model, req) + assert exc_info.value.status_code == 400 + + +def test_api_save_and_list_group_rule(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("核心池") + + req = _fake_request(tmp_path) + model = monitor_rules_api.RuleModel(**_group_rule(group_id=group["id"])) + resp = monitor_rules_api.save_rule(model, req) + assert resp["ok"] is True + assert resp["rule"]["group_id"] == group["id"] + + listed = monitor_rules_api.list_rules(req) + rule = next(r for r in listed["rules"] if r["id"] == "r_grp") + assert "runtime_warning" not in rule + + # 分组删除后: 列表标注警告 (引擎侧同轮已 fail-closed) + watchlist.delete_group(group["id"]) + listed = monitor_rules_api.list_rules(req) + rule = next(r for r in listed["rules"] if r["id"] == "r_grp") + assert "已删除" in rule["runtime_warning"] diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5257051..8e72a61 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -44,7 +44,6 @@ import { RadioTower, CheckCircle2, BookOpenCheck, - ExternalLink, ChevronRight, ChevronDown, Sun, @@ -67,7 +66,6 @@ import { getFrontendExtensionNavigation } from '@/extensions/registry' // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 const BRAND = '#8B5CF6' -const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA' const CORE_INDEXES = [ { symbol: '000001.SH', name: '上证指数' }, @@ -746,22 +744,19 @@ export function Layout() {
实时行情 - - Free+ + + 不可用
- 免费注册 - navigate('/settings?tab=data-sources')} + className="mx-0.5 text-accent/80 hover:text-accent hover:underline" > - TickFlow - - - 开启个股监控 + 去配置数据源 +
) : ( diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 6aca10e..968d915 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react' +import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { cn } from '@/lib/cn' import { cnSignal } from '@/lib/signals' +import { fmtPct } from '@/lib/format' import { StockPanel, getDefaultRange } from '@/components/StockPanel' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart' @@ -63,6 +64,21 @@ function boardTag(symbol: string): { label: string; color: string } | null { return null } +// ===== 异动边缘 (与异动页同口径) ===== + +const AB_STATUS_META: Record = { + triggered: { label: '已触发', cls: 'bg-danger/20 text-danger font-semibold', bar: 'border-b border-danger/30 bg-danger/[0.08]', icon: 'text-danger' }, + edge: { label: '异动边缘', cls: 'bg-warning/20 text-warning font-semibold', bar: 'border-b border-warning/30 bg-warning/[0.07]', icon: 'text-warning' }, + watch: { label: '观察', cls: 'bg-elevated text-secondary font-semibold', bar: 'border-b border-border bg-surface', icon: 'text-secondary' }, +} + +/** 异动引擎计算时间 (服务端 asof 秒级时间戳 → 月-日 时:分:秒) */ +function fmtAbnormalCalcTime(asofSec: number): string { + const d = new Date(asofSec * 1000) + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) { const [view, setView] = useState('daily') const [intradayDays, setIntradayDays] = useState(loadIntradayDays) @@ -83,6 +99,22 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props queryFn: api.monitorRulesList, enabled: !!symbol, }) + // 异动边缘: 与异动页同 queryKey 共享缓存; 该股处于观察/边缘/触发状态时在图表上方显示信息条 + const abnormal = useQuery({ + queryKey: QK.abnormalOverview(0.5, 300), + queryFn: () => api.abnormalOverview(0.5, 300), + enabled: !!symbol, + }) + const abRow = symbol + ? abnormal.data?.rows.find(r => r.symbol === symbol) + : undefined + // 接近度最高的窗口 (信息条中高亮) + const abDominantWindow = abRow + ? Object.entries(abRow.windows).reduce( + (best, [k, w]) => (!best || w.closeness > best[1].closeness ? [k, w] as const : best), + undefined as undefined | readonly [string, { value: number; threshold: number; closeness: number }], + ) + : undefined const monitorPriceLines = useMemo( () => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [], [monitorRules.data?.rules, symbol], @@ -394,6 +426,49 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props )} + {/* 异动边缘信息条 (与异动页同源; 该股无异动数据时不显示)。整条按状态着色提升辨识度 */} + {abRow && (() => { + const meta = AB_STATUS_META[abRow.status] ?? AB_STATUS_META.watch + return ( +
+ + + 异动 + + {meta.label} + + + {Object.entries(abRow.windows) + .sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)) + .map(([w, info]) => { + const dominant = abDominantWindow?.[0] === w + return ( + + {parseInt(w, 10)}日{' '} + = 0 ? 'text-bull' : 'text-bear'}>{fmtPct(info.value, 1)} + / ±{(info.threshold * 100).toFixed(0)}% + · 接近{(info.closeness * 100).toFixed(0)}% + + ) + })} + + 计算于 {fmtAbnormalCalcTime(abnormal.data?.asof ?? 0)} + +
+ ) + })()} + {/* 图表内容 */}
{view === 'daily' ? ( diff --git a/frontend/src/components/WatchlistGroups.tsx b/frontend/src/components/WatchlistGroups.tsx index 53d6f7a..32ec6f3 100644 --- a/frontend/src/components/WatchlistGroups.tsx +++ b/frontend/src/components/WatchlistGroups.tsx @@ -610,10 +610,17 @@ export function WatchlistGroupPicker({ groups, groupIds, symbol, disabled, onTog {dots.length === 0 ? ( ) : ( - - {dots.map(g => )} + // 叠瓦式圆点: 先加的分组在最上层完整显示, 后加的从其右侧露出半圆, 紧凑不撑宽 + + {dots.map((g, i) => ( + 0 ? '-ml-1' : ''}`} + /> + ))} {memberGroups.length > 3 && ( - +{memberGroups.length - 3} + +{memberGroups.length - 3} )} )} diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 7198148..b6681a7 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -56,6 +56,7 @@ const emptyRule = (preset?: Partial): MonitorRule => ({ asset_type: 'stock', scope: 'symbols', symbols: [], + group_id: null, sector: null, sector_kind: 'index', sector_targets: [], @@ -112,19 +113,34 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { }) const [error, setError] = useState('') const [symbolQuery, setSymbolQuery] = useState('') - // 「自选导入」下拉: 从自选/自选分组批量并入标的 (与自选页共用查询缓存) + const isGroupScope = draft.scope === 'watchlist_group' + // 「自选导入」下拉: 从自选/自选分组批量并入标的 (与自选页共用查询缓存)。 + // 分组作用域模式同样需要分组/成员数据 (选择分组 + 成员预览)。 const [watchMenuOpen, setWatchMenuOpen] = useState(false) const watchMenuRef = useRef(null) const watchlistQ = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, - enabled: watchMenuOpen, + enabled: watchMenuOpen || isGroupScope, }) const watchGroupsQ = useQuery({ queryKey: QK.watchlistGroups, queryFn: api.watchlistGroups, - enabled: watchMenuOpen, + enabled: watchMenuOpen || isGroupScope, }) + // 分组选择下拉 (scope=watchlist_group) + const [groupMenuOpen, setGroupMenuOpen] = useState(false) + const groupMenuRef = useRef(null) + useEffect(() => { + if (!groupMenuOpen) return + const handleClick = (e: MouseEvent) => { + if (groupMenuRef.current && !groupMenuRef.current.contains(e.target as Node)) { + setGroupMenuOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [groupMenuOpen]) useEffect(() => { if (!watchMenuOpen) return const handleClick = (e: MouseEvent) => { @@ -161,6 +177,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { ? `${base} · ${d.sector_targets[0].name}${d.sector_targets.length > 1 ? ` 等${d.sector_targets.length}个` : ''}` : d.type === 'abnormal' ? `${base} · 接近度≥${d.threshold_pct ?? 70}%${d.abnormal_window && d.abnormal_window !== 'any' ? ` (${d.abnormal_window.toUpperCase()})` : ''}` + : d.scope === 'watchlist_group' && selectedGroup + ? `${base} · 分组「${selectedGroup.name}」` : d.scope === 'symbols' && d.symbols.length > 0 ? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? ` 等${d.symbols.length}只` : ''}` : base @@ -204,6 +222,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { } } if (d.type !== 'sector' && d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只标的') + if (d.type !== 'sector' && d.scope === 'watchlist_group' && !d.group_id) throw new Error('请选择一个自选分组') return api.monitorRuleSave(d) }, onSuccess: () => { @@ -292,6 +311,32 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { return options })() + // ── 自选分组作用域 (scope=watchlist_group): 分组选择 + 只读成员预览 ── + const groupList = watchGroupsQ.data?.groups ?? [] + const watchEntries = watchlistQ.data?.symbols ?? [] + const groupCounts = useMemo(() => { + const counts: Record = {} + for (const entry of watchEntries) { + for (const gid of entry.group_ids ?? []) counts[gid] = (counts[gid] ?? 0) + 1 + } + return counts + }, [watchEntries]) + const selectedGroup = groupList.find(g => g.id === draft.group_id) + const selectedGroupSymbols = useMemo( + () => selectedGroup + ? watchEntries.filter(e => e.group_ids?.includes(selectedGroup.id)).map(e => e.symbol) + : [], + [selectedGroup, watchEntries], + ) + // 预览区名称补齐 (分组成员通常不在 draft.symbols 里, 单独批量查询) + const groupNamesQ = useQuery({ + queryKey: ['instrument-names', selectedGroupSymbols.join(',')], + queryFn: () => api.instrumentNames(selectedGroupSymbols), + enabled: isGroupScope && selectedGroupSymbols.length > 0, + staleTime: 5 * 60_000, + }) + const groupNameBySymbol = groupNamesQ.data?.names ?? {} + const selectSectorKind = (kind: SectorKind) => { setDraft(d => ({ ...d, sector_kind: kind, sector_targets: [] })) setSectorQuery('') @@ -341,13 +386,20 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { const pickerSignals = assetType === 'index' ? monitorBuiltinSignals.filter(o => !INDEX_HIDDEN_SIGNALS(o.key)) : monitorBuiltinSignals + // 分时穿越信号: 数据按标的清单订阅且有上限, 自选分组是动态集合 (静默超限风险) → 禁用 + const intradayDisabledSignals = + intradaySupport?.available === false || isGroupScope ? MONITOR_INTRADAY_SIGNAL_OPTIONS : [] + const intradayDisabledHint = isGroupScope + ? '分时穿越信号需逐股订阅, 暂不支持自选分组作用域' + : intradaySupport?.reason // 指数: 监控类型仅 signal/price (无涨跌停/策略/封单语义) const visibleTypes = (options.data?.types ?? []).filter( t => assetType !== 'index' || t.key === 'signal' || t.key === 'price', ) - // 指数: 作用范围仅 symbols (无全市场/板块语义) + // 指数: 作用范围仅 symbols (无全市场/板块语义); ETF: 不支持自选分组 (分组为个股) const visibleScopes = (options.data?.scopes ?? []).filter( - s => assetType !== 'index' || s.key === 'symbols', + s => (assetType !== 'index' || s.key === 'symbols') + && (assetType === 'stock' || s.key !== 'watchlist_group'), ) const sectorKind = draft.sector_kind ?? 'index' const sectorTargets = options.data?.sector_targets?.[sectorKind] ?? [] @@ -504,7 +556,10 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { strategy_id: null, symbols: [], type: t === 'index' && d.type !== 'signal' && d.type !== 'price' ? 'signal' : d.type, - scope: t === 'index' ? 'symbols' : d.scope, + // 指数仅指定标的; ETF 不支持分组作用域 (自选分组为个股) + scope: t === 'index' || (t !== 'stock' && d.scope === 'watchlist_group') + ? 'symbols' + : d.scope, })) setStrategyQuery('') setStrategyCategory('all') @@ -855,7 +910,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
)} + {draft.scope === 'watchlist_group' && ( +
+
+ + {groupMenuOpen && ( +
+ {watchGroupsQ.isLoading ? ( +
正在加载分组...
+ ) : groupList.length === 0 ? ( +
+ 还没有自选分组,去自选页创建 → +
+ ) : groupList.map(g => ( + + ))} +
+ )} +
+ {/* 成员预览 (只读): 让用户明确当前监控哪些标的; 与手动选标的的可编辑标签区分 */} + {selectedGroup && ( +
+ {selectedGroupSymbols.length > 0 ? ( +
+ {selectedGroupSymbols.map(sym => { + const b = boardTag(sym) + return ( + + + {groupNameBySymbol[sym] ?? sym} + + {b && {b.label}} + {sym} + + ) + })} +
+ ) : ( +
+ 该分组当前没有标的, 后续在分组内添加自选会自动纳入监控 +
+ )} +
+ 动态绑定: 分组内增删标的自动同步监控范围, 无需修改本规则 +
+
+ )} +
+ )} {draft.scope === 'all' && 对全市场所有标的生效} {draft.scope === 'sector' && 板块精确过滤(开发中,当前等同全市场)} @@ -995,8 +1131,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { onChange={onSignalPickerChange} kind="entry" builtinSignals={pickerSignals} - disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []} - disabledSignalHint={intradaySupport?.reason} + disabledSignals={intradayDisabledSignals} + disabledSignalHint={intradayDisabledHint} /> {hasIntradaySignal && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 18bf347..83888a1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -780,8 +780,10 @@ export interface MonitorRule { enabled: boolean type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal' asset_type?: 'stock' | 'etf' | 'index' - scope: 'symbols' | 'all' | 'sector' + scope: 'symbols' | 'all' | 'sector' | 'watchlist_group' symbols: string[] + /** scope=watchlist_group 时绑定的自选分组 id (成员动态解析, 增删自选自动生效) */ + group_id?: string | null sector?: string | null sector_kind?: SectorKind | null sector_targets?: SectorMonitorTarget[] diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 191e92d..a340ff5 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -60,6 +60,9 @@ export const storage = { /** 自选列表板块筛选 */ watchlistBoardFilter: kv('watchlist_boardFilter'), + /** 自选列表排除 ST 标的 (默认不排除) */ + watchlistExcludeST: kv('watchlist_excludeST'), + /** 自选分组统计条配置 (metric: 统计指标, sort: 排序方式, card*: 分组卡片显示项) */ watchlistGroupStats: kv<{ metric: string; sort: string; cardTopN?: number; cardColorBar?: boolean; cardRank?: boolean }>('watchlist_groupStats'), diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c55eada..5f5a7a7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,7 +7,7 @@ import { DatePicker } from '@/components/DatePicker' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum, fmtPct } from '@/lib/format' -import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries' +import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { SettingsModal } from '@/components/data/SettingsModal' @@ -555,8 +555,22 @@ export function Dashboard() { const hasDepth = !!caps.data?.capabilities?.['depth5.batch'] const sealedReady = !!data?.limit?.sealed_ready const isSealedDegrade = !hasDepth || !sealedReady - // none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限 - const isNoKey = settings.data?.mode === 'none' + // 空态引导文案按当前数据源分流: TickFlow 源提"免费服务器", 其他源提"当前数据源", + // 弱化与默认 TickFlow 的隐式绑定 (None 档/免费 Key 等 TickFlow 概念仅在其被选中时出现) + const prefs = usePreferences() + const dataSourceList = useQuery({ + queryKey: QK.dataSources, + queryFn: api.dataSources, + staleTime: 60_000, + }) + const activeProvider = prefs.data?.daily_data_provider || 'tickflow' + const isTickflowProvider = activeProvider === 'tickflow' + const providerLabel = [ + ...(dataSourceList.data?.builtin ?? []), + ...(dataSourceList.data?.plugins ?? []), + ...(dataSourceList.data?.custom ?? []), + ].find(s => s.name === activeProvider)?.display_name + ?.replace(/(.*?)|\(.*?\)/g, '').trim() || activeProvider // 无本地数据(enriched/daily 都没有)→ 常驻引导卡片 // 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据 const ds = dataStatus.data @@ -668,14 +682,16 @@ export function Dashboard() { stage={fetchStatus.data?.stage} fetchPct={fetchStatus.data?.progress} onStart={() => startFetch.mutate()} - isNoKey={isNoKey} + isTickflowProvider={isTickflowProvider} + providerLabel={providerLabel} /> )} {/* 首次使用自动弹窗(同会话仅一次) */} {showWelcomeModal && ( setShowWelcomeModal(false)} onStart={() => { startFetch.mutate() @@ -857,7 +873,8 @@ export function Dashboard() { // ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 Key 也可) ===== function FetchDataCard({ - isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, isNoKey, + isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, + isTickflowProvider, providerLabel, }: { isFetching: boolean isStarting: boolean @@ -865,7 +882,8 @@ function FetchDataCard({ stage?: string fetchPct?: number onStart: () => void - isNoKey: boolean + isTickflowProvider: boolean + providerLabel: string }) { const stageText = stage ? (STAGE_LABELS[stage] ?? stage) : '正在同步行情数据…' return ( @@ -877,11 +895,13 @@ function FetchDataCard({
当前暂无数据

- 首次使用需获取行情数据后才能查看看板。系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。 + 首次使用需获取行情数据后才能查看看板。{isTickflowProvider + ? '可通过 TickFlow 免费服务器拉取近 1 年全 A 股日K' + : `将从当前数据源「${providerLabel}」拉取近 1 年全 A 股日K`}(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。

- {isNoKey && ( + {isTickflowProvider && (

- ⓘ 无需 API Key,当前为 None 档即可获取历史日K,可制定策略+回测。配置免费 Key 可解锁实时行情监控能力。 + ⓘ 获取数据后即可进行策略定制、回测验证、选股扫描等本地分析功能。

)} @@ -940,9 +960,10 @@ function FetchDataCard({ // ===== 首次使用自动弹窗: 询问用户后触发盘后管道 ===== function WelcomeFetchModal({ - isNoKey, onClose, onStart, + onClose, onStart, isTickflowProvider, providerLabel, }: { - isNoKey: boolean + isTickflowProvider: boolean + providerLabel: string onClose: () => void onStart: () => void }) { @@ -959,12 +980,14 @@ function WelcomeFetchModal({

首次使用,需先获取行情数据

- 系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟。 + {isTickflowProvider + ? '可通过 TickFlow 免费服务器拉取近 1 年全 A 股日K' + : `将从当前数据源「${providerLabel}」拉取近 1 年全 A 股日K`}(约 5500 只),预计 1-3 分钟。 同步期间可继续浏览其他页面,完成后看板自动刷新。

- {isNoKey && ( + {isTickflowProvider && (
- ⓘ 当前无需 API Key,None 档即可获取历史日K数据。 + ⓘ 获取数据后即可进行策略定制、回测验证等本地分析功能。
)}
diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 270cfe5..8a4d838 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useMemo } from 'react' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { AlertTriangle, RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame, Tags } from 'lucide-react' @@ -14,6 +14,7 @@ import { cn } from '@/lib/cn' import { cnSignal } from '@/lib/signals' import { LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS, strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents' import { boardTag } from '@/components/stock-table/primitives' +import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge' import { RuleEditor } from '@/components/monitor/RuleEditor' import { StockPreviewDialog } from '@/components/StockPreviewDialog' @@ -632,6 +633,31 @@ function RulesList({ rulesQuery, onEdit }: { const rules: MonitorRule[] = (rulesQuery.data as any)?.rules ?? [] + // 分组作用域规则: 拉取分组定义与成员, 展示分组名/成员数 chip (点击跳转自选页对应分组) + const hasGroupRules = rules.some(r => r.scope === 'watchlist_group') + const groupsQ = useQuery({ + queryKey: QK.watchlistGroups, + queryFn: api.watchlistGroups, + enabled: hasGroupRules, + }) + const watchlistQ = useQuery({ + queryKey: QK.watchlist, + queryFn: api.watchlistList, + enabled: hasGroupRules, + }) + const groupMeta = useMemo(() => { + const meta: Record = {} + for (const g of groupsQ.data?.groups ?? []) { + meta[g.id] = { name: g.name, color: g.color, count: 0 } + } + for (const entry of watchlistQ.data?.symbols ?? []) { + for (const gid of entry.group_ids ?? []) { + if (meta[gid]) meta[gid].count += 1 + } + } + return meta + }, [groupsQ.data, watchlistQ.data]) + // 收集所有规则的股票代码, 批量查名称 const allSymbols = useMemo(() => { const set = new Set() @@ -716,7 +742,7 @@ function RulesList({ rulesQuery, onEdit }: { {r.asset_type === 'index' && ( 指数 )} - {/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */} + {/* 个股类型: 直接显示可点击的代码+名称; 分组类型: 分组chip跳自选页; 其他类型显示规则名 */} {r.scope === 'symbols' && r.symbols.length > 0 ? ( + ) : r.scope === 'watchlist_group' && r.group_id ? ( + (() => { + const meta = groupMeta[r.group_id] + if (!meta) { + return 分组已删除 + } + return ( + + + {meta.name} + {meta.count}只 + · 分组作用域 + + ) + })() ) : (

{displayName}

)} diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx index 3ba6cda..dbd5f27 100644 --- a/frontend/src/pages/Onboarding.tsx +++ b/frontend/src/pages/Onboarding.tsx @@ -371,6 +371,8 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () => const isSelected = selected === item.name const unavailable = item.kind === 'plugin' && !item.available const plugin = item.kind === 'plugin' ? plugins.find(p => p.name === item.name) : undefined + // 切换中的目标卡片: 圆点位置显示转圈, 其余卡片压暗 + const switchingToThis = switchProvider.isPending && switchProvider.variables === item.name return (
- {/* 切换反馈 */} - {switchProvider.isPending && ( -
- - 正在切换数据源… -
- )} {switchProvider.isError && (
@@ -511,10 +510,27 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () => } // ===== Step 3: 能力探测结果 ===== +// 按当前实际选中的数据源分流: +// - TickFlow: 提示第三方源性质 + 可配 Key、按 Key 匹配档位, 展示档位与能力探测 +// - 其他源: 弱化 TickFlow (不展示其档位/能力), 只汇总所选源的数据集覆盖与回落规则 function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void }) { const settings = useSettings() const caps = useCapabilities() + const prefs = usePreferences() + const sources = useQuery({ + queryKey: QK.dataSources, + queryFn: api.dataSources, + staleTime: 60_000, + }) + + const activeName = prefs.data?.daily_data_provider || 'tickflow' + const isTickflow = activeName === 'tickflow' + const sourceItem = [ + ...(sources.data?.builtin ?? []), + ...(sources.data?.plugins ?? []), + ...(sources.data?.custom ?? []), + ].find(s => s.name === activeName) // 是否配置成功 —— 免费档(free)或付费档(api_key)都算;None 档算未配置 const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key' @@ -529,57 +545,112 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void

能力探测结果

- {hasKey ? ( + {prefs.isLoading ? ( +
+ + 正在读取当前数据源… +
+ ) : isTickflow ? ( <> -

- Key 已生效,以下是你当前可用的全部能力。后续可在 - 设置 → 账户 - 中重新检测或更换 Key。 -

- -
-
- 订阅档位 - - {caps.data?.label ?? settings.data?.tier_label ?? '—'} - + {/* TickFlow 源说明: 点明第三方性质 + Key/档位关系 */} +
+ +
+ 当前选择了 TickFlow 第三方数据源 + 。实时行情、监控等能力与订阅档位由 TickFlow Key 决定:可在 + 设置 → 账户 + 配置 Key,系统会根据 Key 自动匹配档位;未配置 Key 时按 None 档运行,仅保留内置历史数据能力。
+
- {caps.isLoading ? ( -
- - 正在探测能力… -
- ) : capList.length > 0 ? ( -
- {capList.slice(0, 8).map(([cap]) => { - const meta = CAP_LABELS[cap] - return ( -
- - {meta?.name ?? cap} -
- ) - })} - {capList.length > 8 && ( -
…等共 {capList.length} 项
+ {hasKey ? ( + <> +

+ Key 已生效,以下是你当前可用的全部能力。后续可在 + 设置 → 账户 + 中重新检测或更换 Key。 +

+ +
+
+ 订阅档位 + + {caps.data?.label ?? settings.data?.tier_label ?? '—'} + +
+ + {caps.isLoading ? ( +
+ + 正在探测能力… +
+ ) : capList.length > 0 ? ( +
+ {capList.slice(0, 8).map(([cap]) => { + const meta = CAP_LABELS[cap] + return ( +
+ + {meta?.name ?? cap} +
+ ) + })} + {capList.length > 8 && ( +
…等共 {capList.length} 项
+ )} +
+ ) : ( +
暂未探测到能力
)}
- ) : ( -
暂未探测到能力
- )} -
+ + ) : ( +
+
+ +
+
将以 None 档继续
+

+ 当前未配置有效 Key,仍可使用看板、选股、回测等功能 —— 进入看板后可直接获取近 1 年历史日K数据。配置 Key 后可解锁实时行情监控等能力,随时在 + 设置 → 账户 填写。 +

+
+ )} ) : ( -
-
- + /* 其他数据源: 不展示 TickFlow 档位/能力探测, 汇总所选源的数据集覆盖 */ +
+
+ 当前数据源 + + + {(sourceItem?.display_name ?? activeName).replace(/(.*?)|\(.*?\)/g, '').trim() || sourceItem?.display_name || activeName} + + + {sources.data?.custom?.some(s => s.name === activeName) ? '自有' : '第三方'} + +
-
将以 None 档继续
-

- 当前未配置有效 Key,仍可使用看板、选股、回测等功能 —— 进入看板后可直接获取近 1 年历史日K数据。配置 Key 后可解锁实时行情监控等能力,随时在 - 设置 → 账户 填写。 + +

+ {(sourceItem?.datasets?.length ?? 0) > 0 ? ( + sourceItem!.datasets.map(ds => ( + + {DATASET_LABELS[ds] ?? ds} + + )) + ) : ( + 未声明数据集 + )} +
+

+ 行情能力由所选数据源决定:以上数据集由该源提供,未覆盖的数据集自动回落内置源,无需额外配置。

+ +
+ TickFlow 的 Key 与档位探测仅在选择 TickFlow 作为数据源时展示;如需切换,前往 + 设置 → 数据源 。 +
)} diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index d3a5f72..e721dd8 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' import { motion, AnimatePresence } from 'framer-motion' -import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus } from 'lucide-react' +import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react' import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' @@ -314,7 +314,7 @@ function StockSearchBox({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }} - className="absolute right-0 top-full mt-1 z-50 w-64 max-h-[320px] overflow-y-auto rounded-card border border-border bg-base shadow-xl" + className="absolute right-0 top-full mt-1 z-50 w-72 max-h-[320px] overflow-y-auto rounded-card border border-border bg-base shadow-xl" > {results.map((r, i) => { const entryGids = existingBySymbol.get(r.symbol) @@ -332,19 +332,22 @@ function StockSearchBox({ className="flex items-center gap-2.5 flex-1 min-w-0 text-left" > {r.symbol} - {r.name} - {r.asset_type === 'etf' && ( - ETF - )} - {r.asset_type === 'index' && ( - 指数 - )} - {(() => { - const b = boardTag(r.symbol) - return b && ( - {b.label} - ) - })()} + {/* 名称+标签组: 标签紧贴名称文字, 而不是被 flex-1 推到行尾 */} + + {r.name} + {r.asset_type === 'etf' && ( + ETF + )} + {r.asset_type === 'index' && ( + 指数 + )} + {(() => { + const b = boardTag(r.symbol) + return b && ( + {b.label} + ) + })()} + {inWatchlist ? ( // 已加自选: 对勾标识 + 分组勾选面板, 可继续加入/移出其他分组 @@ -366,14 +369,33 @@ function StockSearchBox({ /> ) : ( - onAdd(r.symbol, groupId)} - preferredGroupId={preferredGroupId} - disabled={addPending} - triggerClassName="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50" - > - - + // 未加自选: + 一键加入当前分组页签 (全部/未分组页签下加为未分组); + // 文件夹图标展开分组菜单, 显式选择目标分组 + + + onAdd(r.symbol, groupId)} + preferredGroupId={preferredGroupId} + disabled={addPending} + triggerClassName="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50" + title="展开分组, 选择要加入的自选分组" + > + + + )}
) @@ -1066,6 +1088,15 @@ export function Watchlist() { }) }, [persistBoardFilter]) + // 排除 ST (含 *ST/S*ST 等变体, 按简称含 "ST" 判定), 默认关闭并持久化 + const [excludeST, setExcludeST] = useState(() => storage.watchlistExcludeST.get(false)) + const toggleExcludeST = useCallback(() => { + setExcludeST(prev => { + storage.watchlistExcludeST.set(!prev) + return !prev + }) + }, []) + const updateFilter = useCallback((colId: string, patch: { min?: string; max?: string; text?: string }) => { setFilters(prev => { const next = { ...prev } @@ -1083,6 +1114,8 @@ export function Watchlist() { const resetAllFilters = useCallback(() => { setFilters({}) persistBoardFilter(new Set(BOARDS)) + setExcludeST(false) + storage.watchlistExcludeST.set(false) }, [persistBoardFilter]) // 可筛选的内置列 @@ -1116,6 +1149,10 @@ export function Watchlist() { return board != null && boardFilter.has(board) }) } + // 排除 ST: 按简称判定 (ST/*ST/S*ST 均含 "ST"); 非股票名称不含该标记, 天然不受影响 + if (excludeST) { + result = result.filter(r => !((r.rt_name ?? r.name ?? '').toUpperCase().includes('ST'))) + } // 数值/文本筛选 const activeFilters = Object.entries(filters).filter(([, v]) => v.min || v.max || v.text) if (activeFilters.length > 0) { @@ -1136,11 +1173,11 @@ export function Watchlist() { }) } return result - }, [rowsInSelectedGroup, filters, columns, boardFilter]) + }, [rowsInSelectedGroup, filters, columns, boardFilter, excludeST]) const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARDS.length - const hasActiveFilters = activeFilterCount > 0 || hasBoardFilter + const hasActiveFilters = activeFilterCount > 0 || hasBoardFilter || excludeST // 排序(复用共享三态排序 hook)。分时列按「最新分钟收盘 vs 昨收」排序(分时图最后一点同口径), // 其余列走共享取值;眼睛关闭时不拉分钟数据,取值为 null → 保持原序。 @@ -1430,6 +1467,23 @@ export function Watchlist() { })}
+ {/* 排除 ST */} +
+
风险警示
+
+ +
+
{COLUMN_GROUPS.map(cat => { const items = colsByCategory[cat.label]?.filter(i => i.col) if (!items?.length) return null diff --git a/frontend/src/pages/settings/DataSources.tsx b/frontend/src/pages/settings/DataSources.tsx index 59d40b6..05b147c 100644 --- a/frontend/src/pages/settings/DataSources.tsx +++ b/frontend/src/pages/settings/DataSources.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { Check, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react' +import { Check, Database, Plus, RefreshCw, Zap, FileWarning, Puzzle } from 'lucide-react' import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { usePreferences } from '@/lib/useSharedQueries' @@ -199,10 +199,10 @@ export function SettingsDataSourcesPanel() { {item.display_name} {item.name === 'tickflow' && ( - 内置 + 第三方 )} {pluginNames.has(item.name) && ( - 插件 + 第三方 )} {/* 右侧操作区: 插件未安装→安装按钮; 已激活→使用中; 否则→使用/卸载 */} {pluginUnavailable ? ( @@ -311,6 +311,23 @@ export function SettingsDataSourcesPanel() { · 未启用的数据集自动回退 TickFlow
+ + {/* 插件化说明 + 第三方数据源配置文档指引 (与引导页同口径) */} +
+ +
+ 数据源已插件化 + ,接入自有行情有两条路径:用 YAML 描述自有 HTTP 接口,放入 + data/data_sources/*.yaml + (也可用「新增数据源」表单配置);或开发插件源,放入 + backend/app/plugins/ + 目录。接入方法与字段映射详见 + docs/custom-data-source.md + 与 + docs/plugin-development.md + 。 +
+
{/* ===== 下方: 编辑区 ===== */} @@ -441,7 +458,7 @@ function TickFlowDetail({ active, onSwitch, switching }: { active: boolean; onSw

TickFlow

- 内置默认 + 第三方 {active && ( 当前使用