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() {
) : (
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 && (
当前使用