feat: release v0.1.86

This commit is contained in:
shy3130
2026-07-18 00:49:28 +08:00
parent 34eaba3010
commit a1e8be58db
27 changed files with 1737 additions and 147 deletions
+1 -1
View File
@@ -1 +1 @@
v0.1.84
v0.1.86
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.85"
__version__ = "0.1.86"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+76
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import math
import re
import shutil
import tempfile
from datetime import date, datetime
@@ -127,6 +128,25 @@ def _clean_col_names(df: pl.DataFrame) -> pl.DataFrame:
return df.rename(final)
_DIMENSION_SEPARATOR_CLASS = r"、,;|/\s-"
def _filter_dimension_member_rows(df: pl.DataFrame, field: str, value: str) -> pl.DataFrame:
"""按分隔后的完整标签匹配成员,避免“人工智能”误命中“人工智能体”。"""
if field not in df.columns:
raise HTTPException(400, f"字段 '{field}' 不存在")
normalized = value.strip()
if not normalized:
raise HTTPException(400, "标签值不能为空")
pattern = rf"(^|[{_DIMENSION_SEPARATOR_CLASS}]){re.escape(normalized)}($|[{_DIMENSION_SEPARATOR_CLASS}])"
return df.filter(
pl.col(field)
.cast(pl.String, strict=False)
.fill_null("")
.str.contains(pattern)
)
def _ext_data_dir(config: ExtConfig, data_dir: Path) -> Path:
"""返回扩展数据的数据目录。
@@ -407,6 +427,62 @@ def list_rows(
}
@router.get("/{config_id}/dimension-members")
def dimension_members(
request: Request,
config_id: str,
field: str = Query(..., min_length=1),
value: str = Query(..., min_length=1),
snapshot_date: str | None = Query(None, alias="date"),
limit: int = Query(1000, ge=1, le=10000),
):
"""按扩展字段的完整标签值返回成分股,不绑定具体概念/行业数据源。"""
config = _store(request).get(config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
data_dir = _data_dir(request)
df, active_date = _read_ext_dataframe(config, data_dir, snapshot_date)
df = _with_instrument_name(df, data_dir)
matched = _filter_dimension_member_rows(df, field, value)
total = len(matched)
columns = ["symbol", "code", "name", "股票代码", "股票简称", field]
for mapping in (config.symbol_map, config.code_map):
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
columns.append(str(mapping["col"]))
selected = [column for column in dict.fromkeys(columns) if column in matched.columns]
if selected:
matched = matched.select(selected)
if total > limit:
matched = matched.head(limit)
symbol_columns = ["symbol", "code", "股票代码", "代码"]
name_columns = ["name", "股票简称", "名称"]
for mapping in (config.symbol_map, config.code_map):
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
symbol_columns.append(str(mapping["col"]))
rows = []
for raw in matched.to_dicts():
row = {key: _safe_json_value(item) for key, item in raw.items()}
if not row.get("symbol"):
row["symbol"] = next((str(row[column]) for column in symbol_columns if row.get(column)), "")
if not row.get("name"):
row["name"] = next((str(row[column]) for column in name_columns if row.get(column)), "")
rows.append(row)
return {
"id": config.id,
"label": config.label,
"date": active_date,
"field": field,
"value": value.strip(),
"total": total,
"limit": limit,
"rows": rows,
}
# ---------------------------------------------------------------------------
# 文件上传
# ---------------------------------------------------------------------------
+52
View File
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.strategy import monitor_rules
from app.strategy.intraday_signals import INTRADAY_SIGNAL_LABELS, uses_intraday_signals
router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"])
@@ -63,6 +64,7 @@ class RuleModel(BaseModel):
def get_options(request: Request):
"""返回可选字段、信号列、运算符、枚举,供前端表单使用。"""
from app.indicators.pipeline import ENRICHED_COLUMNS
from app.services.kline_sync import intraday_monitor_support
from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg
# 阈值字段 (带中文标签)
@@ -76,6 +78,10 @@ def get_options(request: Request):
for k, v in ENRICHED_COLUMNS.items()
if k.startswith("signal_")
]
builtin_signals.extend(
{"key": key, "label": label}
for key, label in INTRADAY_SIGNAL_LABELS.items()
)
# 自定义信号列 (csg_)
custom_sigs = []
try:
@@ -118,6 +124,9 @@ def get_options(request: Request):
{"key": "exit", "label": "出场"},
{"key": "both", "label": "出入都报"},
],
"intraday_signal_support": intraday_monitor_support(
getattr(request.app.state, "capabilities", None),
),
}
@@ -125,6 +134,29 @@ def get_options(request: Request):
@router.get("")
def list_rules(request: Request):
rules = monitor_rules.load_all(_data_dir(request))
from app.services.kline_sync import intraday_monitor_support
support = intraday_monitor_support(getattr(request.app.state, "capabilities", None))
intraday_rules = [
rule for rule in rules
if rule.get("enabled", True) and uses_intraday_signals(rule)
]
pooled_symbols = {
str(symbol)
for rule in intraday_rules
for symbol in rule.get("symbols", [])
if symbol
}
runtime_warning = ""
if intraday_rules and not support["available"]:
runtime_warning = str(support["reason"])
elif len(pooled_symbols) > int(support["max_symbols"]):
runtime_warning = (
f"分时监听标的池已超限: {len(pooled_symbols)}/{support['max_symbols']}"
)
if runtime_warning:
for rule in intraday_rules:
rule["runtime_warning"] = runtime_warning
# 按 created_at 倒序
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
return {"rules": rules}
@@ -170,6 +202,26 @@ 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("enabled", True) and uses_intraday_signals(rule):
from app.services.kline_sync import intraday_monitor_support
support = intraday_monitor_support(getattr(request.app.state, "capabilities", None))
if not support["available"]:
raise HTTPException(status_code=403, detail=str(support["reason"]))
symbols = set(str(symbol) for symbol in rule.get("symbols", []) if symbol)
for saved in monitor_rules.load_all(_data_dir(request)):
if (
saved.get("id") != rule.get("id")
and saved.get("enabled", True)
and uses_intraday_signals(saved)
):
symbols.update(str(symbol) for symbol in saved.get("symbols", []) if symbol)
max_symbols = int(support["max_symbols"])
if len(symbols) > max_symbols:
raise HTTPException(
status_code=400,
detail=f"当前分时数据能力最多监听 {max_symbols} 只标的,当前规则合计 {len(symbols)}",
)
monitor_rules.save_one(_data_dir(request), rule)
_sync_engine(request)
return {"ok": True, "rule": rule}
+19 -1
View File
@@ -52,6 +52,22 @@ def _safe(result_dict: dict) -> dict:
return result_dict
def _one_word_limit_expr(status_main: str, columns: list[str]) -> Any:
required = {"open", "high", "low", "close", "status"}
if not required.issubset(columns):
import polars as pl
return pl.lit(False)
import polars as pl
return (
(pl.col("status") == status_main)
& (pl.col("close") > 0)
& (pl.col("open") == pl.col("high"))
& (pl.col("high") == pl.col("low"))
& (pl.col("low") == pl.col("close"))
).fill_null(False)
_EXT_IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$")
@@ -643,6 +659,8 @@ def limit_ladder(
pl.lit(None).alias("sealed_vol"),
)
df = df.with_columns(_one_word_limit_expr(status_main, df.columns).alias("is_one_word"))
# 动态 JOIN 扩展数据
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
ext_col_names: list[str] = []
@@ -680,7 +698,7 @@ def limit_ladder(
pass
# 选择输出列
cols = ["symbol", "name", "close", "change_pct", "boards", "status", consec_col, "sealed_status", "sealed_vol"] + ext_col_names
cols = ["symbol", "name", "close", "change_pct", "boards", "status", consec_col, "sealed_status", "sealed_vol", "is_one_word"] + ext_col_names
df = df.select([c for c in cols if c in df.columns])
# 排序: boards 降序, status 按主状态→炸/翘→断/止
status_order = pl.when(pl.col("status") == status_main).then(0)
+96
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta
import polars as pl
from app.indicators.pipeline import filter_halt_days
from app.market_time import cn_now
from app.services import preferences
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
@@ -641,6 +642,101 @@ def sync_minute_batch(
return pl.concat(out, how="diagonal_relaxed")
def intraday_monitor_support(capset: CapabilitySet | None) -> dict[str, object]:
"""返回分时信号监控可用的数据能力和单轮标的上限。"""
provider_name = preferences.get_minute_data_provider()
if provider_name != "tickflow":
from app.data_providers import custom as custom_sources
if custom_sources.provider_has_dataset(provider_name, "minute"):
return {
"available": True, "source": "custom_minute", "max_symbols": 100,
"reason": "使用已配置的分钟数据插件",
}
if capset is None:
return {
"available": False, "source": None, "max_symbols": 0,
"reason": "需要分钟 K 或日内分时数据权限",
}
for cap, source in (
(Cap.INTRADAY_BATCH, "intraday_batch"),
(Cap.KLINE_MINUTE_BATCH, "minute_batch"),
):
if capset.has(cap):
limits = capset.limits(cap)
return {
"available": True, "source": source,
"max_symbols": max(1, int(limits.batch or 100)) if limits else 100,
"reason": "日内分时数据可用" if cap == Cap.INTRADAY_BATCH else "分钟 K 数据可用",
}
for cap, source in (
(Cap.INTRADAY, "intraday_single"),
(Cap.KLINE_MINUTE_BY_SYMBOL, "minute_single"),
):
if capset.has(cap):
return {
"available": True, "source": source, "max_symbols": 1,
"reason": "当前权限仅支持单标的分时监控",
}
return {
"available": False, "source": None, "max_symbols": 0,
"reason": "需要分钟 K 或日内分时数据权限",
}
def _normalize_intraday_raw(raw, default_symbol: str | None = None) -> list[pl.DataFrame]:
frames: list[pl.DataFrame] = []
if isinstance(raw, dict):
for symbol, sub in raw.items():
if sub is not None and len(sub) > 0:
frames.append(_normalize_minute(sub, default_symbol=str(symbol)))
elif raw is not None and len(raw) > 0:
frames.append(_normalize_minute(raw, default_symbol=default_symbol))
return [frame for frame in frames if not frame.is_empty()]
def fetch_intraday_monitor_batch(
symbols: list[str], capset: CapabilitySet | None, *, now: datetime | None = None,
) -> pl.DataFrame:
"""按当前能力获取分时信号所需的当日分钟数据,不落盘。"""
if not symbols:
return pl.DataFrame()
support = intraday_monitor_support(capset)
if not support["available"] or len(symbols) > int(support["max_symbols"]):
return pl.DataFrame()
now = now or cn_now()
start_time = now.replace(hour=9, minute=25, second=0, microsecond=0)
source = support["source"]
if source in {"custom_minute", "minute_batch"}:
limits = capset.limits(Cap.KLINE_MINUTE_BATCH) if capset and capset.has(Cap.KLINE_MINUTE_BATCH) else None
return sync_minute_batch(
symbols, start_time=start_time, end_time=now,
batch_size=limits.batch if limits else None,
rpm=limits.rpm if limits else None,
)
tf = get_client()
frames: list[pl.DataFrame] = []
try:
if source == "intraday_batch":
limits = capset.limits(Cap.INTRADAY_BATCH) if capset else None
raw = tf.klines.intraday_batch(
symbols, count=300, as_dataframe=True, show_progress=False,
batch_size=limits.batch if limits and limits.batch else 100,
)
frames.extend(_normalize_intraday_raw(raw))
elif source == "intraday_single":
raw = tf.klines.intraday(symbols[0], count=300, as_dataframe=True)
frames.extend(_normalize_intraday_raw(raw, default_symbol=symbols[0]))
elif source == "minute_single":
raw = tf.klines.get(symbols[0], period="1m", count=300, as_dataframe=True)
frames.extend(_normalize_intraday_raw(raw, default_symbol=symbols[0]))
except Exception as e: # noqa: BLE001
logger.warning("intraday monitor fetch failed (%s, %d symbols): %s", source, len(symbols), e)
return pl.DataFrame()
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
"""从 TickFlow 实时拉取单股单日分钟 K(不写入本地)。"""
from datetime import datetime
+53
View File
@@ -34,6 +34,7 @@ import polars as pl
from app.market_time import cn_now, cn_today
from app.parquet import scan_daily_parquet
from app.strategy.intraday_signals import IntradaySignalEvaluator
logger = logging.getLogger(__name__)
@@ -189,6 +190,8 @@ class QuoteService:
self._index_symbol_count: int = 0
self._etf_symbol_count: int = 0
self._index_quotes_cache: pl.DataFrame | None = None
self._intraday_signal_evaluator = IntradaySignalEvaluator()
self._intraday_signal_bucket: dict[str, str] = {}
# 午休/收盘最终同步状态: 到边界后必须成功拉取一版行情, 再进入休盘态。
self._final_sync_done: set[tuple[date, str]] = set()
self._final_sync_failed: dict[tuple[date, str], str] = {}
@@ -1009,6 +1012,7 @@ class QuoteService:
eval_df = enriched_today
if engine.has_rule_type("ladder"):
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
eval_df = self._inject_intraday_signals(eval_df, engine, "stock")
rule_events = engine.evaluate(eval_df, asset_type="stock")
if engine.consume_strategy_result_updates():
self.notify_strategy_results_updated()
@@ -1020,6 +1024,7 @@ class QuoteService:
try:
etf_enriched, _ = self._repo.get_enriched_latest_asset("etf", refresh=False)
if not etf_enriched.is_empty():
etf_enriched = self._inject_intraday_signals(etf_enriched, engine, "etf")
rule_events = rule_events + engine.evaluate(
etf_enriched, asset_type="etf", reset_strategy_results=False,
)
@@ -1111,6 +1116,54 @@ class QuoteService:
except Exception as e: # noqa: BLE001
logger.debug("告警 ext 富化失败 (不影响推送): %s", e)
def _inject_intraday_signals(self, enriched: pl.DataFrame, engine, asset_type: str) -> pl.DataFrame:
"""每分钟为分时信号规则批量获取一次数据并注入临时布尔列。"""
get_symbols = getattr(engine, "intraday_signal_symbols", None)
if not callable(get_symbols):
return enriched
symbols = get_symbols(asset_type)
if not symbols:
return enriched
now = cn_now()
bucket = now.strftime("%Y%m%d%H%M")
if self._intraday_signal_bucket.get(asset_type) == bucket:
return self._intraday_signal_evaluator.inject(enriched, [])
self._intraday_signal_bucket[asset_type] = bucket
from app.services.kline_sync import (
fetch_intraday_monitor_batch,
intraday_monitor_support,
)
capset = getattr(self._app_state, "capabilities", None)
support = intraday_monitor_support(capset)
if not support["available"] or len(symbols) > int(support["max_symbols"]):
return self._intraday_signal_evaluator.inject(enriched, [])
minute_df = fetch_intraday_monitor_batch(sorted(symbols), capset, now=now)
prev_close: dict[str, float] = {}
available_cols = set(enriched.columns)
for row in enriched.filter(pl.col("symbol").is_in(sorted(symbols))).iter_rows(named=True):
symbol = str(row.get("symbol") or "")
reference = row.get("prev_close") if "prev_close" in available_cols else None
if reference is None and "close" in available_cols and "change_pct" in available_cols:
close = row.get("close")
change_pct = row.get("change_pct")
if close is not None and change_pct is not None and float(change_pct) > -1:
reference = float(close) / (1.0 + float(change_pct))
if symbol and reference is not None:
prev_close[symbol] = float(reference)
signals = self._intraday_signal_evaluator.evaluate(
minute_df,
symbols=symbols,
prev_close=prev_close,
asset_type=asset_type,
now=now,
)
return self._intraday_signal_evaluator.inject(enriched, signals)
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
+141
View File
@@ -0,0 +1,141 @@
"""监控中心专用的日内分时穿越信号。"""
from __future__ import annotations
import math
from datetime import datetime
from typing import Any
import polars as pl
from app.market_time import CN_TZ
INTRADAY_SIGNAL_LABELS: dict[str, str] = {
"signal_intraday_avg_cross_up": "分时价格上穿均价",
"signal_intraday_avg_cross_down": "分时价格下穿均价",
"signal_intraday_zero_cross_up": "分时价格上穿0轴",
"signal_intraday_zero_cross_down": "分时价格下穿0轴",
}
INTRADAY_SIGNAL_FIELDS = frozenset(INTRADAY_SIGNAL_LABELS)
def uses_intraday_signals(rule: dict) -> bool:
return any(
c.get("op") == "truth" and c.get("field") in INTRADAY_SIGNAL_FIELDS
for c in rule.get("conditions", [])
if isinstance(c, dict)
)
def _finite(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _naive_datetime(value: Any) -> datetime | None:
if not isinstance(value, datetime):
return None
if value.tzinfo is not None:
return value.astimezone(CN_TZ).replace(tzinfo=None)
return value
class IntradaySignalEvaluator:
"""按已完成的一分钟 K 线生成边沿触发信号。"""
def __init__(self) -> None:
self._last_bar: dict[tuple[str, str], datetime] = {}
def evaluate(
self,
minute_df: pl.DataFrame,
*,
symbols: set[str],
prev_close: dict[str, float],
asset_type: str,
now: datetime,
) -> list[dict[str, Any]]:
active_keys = {(asset_type, symbol) for symbol in symbols}
self._last_bar = {
key: value for key, value in self._last_bar.items()
if key[0] != asset_type or key in active_keys
}
required = {"symbol", "datetime", "close", "volume", "amount"}
if not symbols or minute_df.is_empty() or not required.issubset(minute_df.columns):
return []
cutoff = _naive_datetime(now)
if cutoff is None:
return []
cutoff = cutoff.replace(second=0, microsecond=0)
scoped = minute_df.filter(pl.col("symbol").cast(pl.Utf8).is_in(sorted(symbols)))
if scoped.is_empty():
return []
results: list[dict[str, Any]] = []
for part in scoped.partition_by("symbol", maintain_order=False):
part = part.sort("datetime")
symbol = str(part["symbol"][0])
points: list[tuple[datetime, float, float | None]] = []
cumulative_amount = 0.0
cumulative_volume = 0.0
for row in part.iter_rows(named=True):
bar_time = _naive_datetime(row.get("datetime"))
price = _finite(row.get("close"))
volume = _finite(row.get("volume"))
amount = _finite(row.get("amount"))
if bar_time is None or bar_time.date() != cutoff.date() or bar_time >= cutoff or price is None:
continue
if volume is not None and volume > 0 and amount is not None and amount >= 0:
cumulative_volume += volume
cumulative_amount += amount
average = (
cumulative_amount / (cumulative_volume * 100.0)
if cumulative_volume > 0 and cumulative_amount > 0
else None
)
points.append((bar_time, price, average))
if not points:
continue
current = points[-1]
key = (asset_type, symbol)
last_bar = self._last_bar.get(key)
self._last_bar[key] = current[0]
if last_bar is None or last_bar.date() != current[0].date() or current[0] <= last_bar:
continue
if len(points) < 2:
continue
previous = points[-2]
baseline = _finite(prev_close.get(symbol))
avg_up = previous[2] is not None and current[2] is not None and previous[1] <= previous[2] and current[1] > current[2]
avg_down = previous[2] is not None and current[2] is not None and previous[1] >= previous[2] and current[1] < current[2]
zero_up = baseline is not None and baseline > 0 and previous[1] <= baseline and current[1] > baseline
zero_down = baseline is not None and baseline > 0 and previous[1] >= baseline and current[1] < baseline
if avg_up or avg_down or zero_up or zero_down:
results.append({
"symbol": symbol,
"signal_intraday_avg_cross_up": avg_up,
"signal_intraday_avg_cross_down": avg_down,
"signal_intraday_zero_cross_up": zero_up,
"signal_intraday_zero_cross_down": zero_down,
})
return results
@staticmethod
def inject(df: pl.DataFrame, signals: list[dict[str, Any]]) -> pl.DataFrame:
existing = [field for field in INTRADAY_SIGNAL_FIELDS if field in df.columns]
out = df.drop(existing) if existing else df
if signals:
out = out.join(pl.DataFrame(signals), on="symbol", how="left")
else:
out = out.with_columns([
pl.lit(False).alias(field) for field in INTRADAY_SIGNAL_FIELDS
])
return out.with_columns([
pl.col(field).fill_null(False).cast(pl.Boolean).alias(field)
for field in INTRADAY_SIGNAL_FIELDS
])
+15
View File
@@ -23,6 +23,7 @@ import polars as pl
from app.market_time import cn_today
from app.strategy import config as _strategy_config
from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器
from app.strategy.intraday_signals import INTRADAY_SIGNAL_LABELS, uses_intraday_signals
logger = logging.getLogger(__name__)
@@ -40,6 +41,7 @@ _SIGNAL_CN: dict[str, str] = {
"signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量",
"signal_limit_up": "涨停", "signal_limit_down": "跌停",
"signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板",
**INTRADAY_SIGNAL_LABELS,
# 行情字段
"close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价",
"change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅",
@@ -456,6 +458,19 @@ class MonitorRuleEngine:
for r in list(self._rules.values())
)
def intraday_signal_symbols(self, asset_type: str) -> set[str]:
"""返回启用的分时信号规则所需标的并集。"""
symbols: set[str] = set()
for rule in list(self._rules.values()):
if (
rule.get("enabled", True)
and rule.get("asset_type", "stock") == asset_type
and rule.get("scope") == "symbols"
and uses_intraday_signals(rule)
):
symbols.update(str(symbol) for symbol in rule.get("symbols", []) if symbol)
return symbols
# ── 评估 ───────────────────────────────────────────
def has_asset_rules(self, asset_type: str) -> bool:
"""是否存在指定资产类型的 (已启用) 规则。供 quote_service 判断是否需要 ETF 评估轮。"""
+3
View File
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path
from app.strategy.custom_signals import ALLOWED_FIELDS
from app.strategy.intraday_signals import uses_intraday_signals
logger = logging.getLogger(__name__)
@@ -154,6 +155,8 @@ def validate(rule: dict) -> None:
syms = rule.get("symbols")
if not isinstance(syms, list) or len(syms) == 0:
raise ValueError("scope=symbols 时 symbols 不能为空")
if uses_intraday_signals(rule) and rule.get("scope") != "symbols":
raise ValueError("分时穿越信号仅支持指定股票")
# sector 作用域的板块 JOIN 尚未实现: _apply_scope 目前会退化为「全市场」,
# 一条本意针对某板块的规则会对全市场每只命中都触发(告警风暴)。在板块 JOIN
# 落地前, 拒绝创建 sector 规则(fail-closed), 避免用户建出会刷屏的规则。
@@ -0,0 +1,34 @@
import polars as pl
import pytest
from fastapi import HTTPException
from app.api.ext_data import _filter_dimension_member_rows
def test_filter_dimension_member_rows_matches_complete_tags() -> None:
rows = pl.DataFrame({
"symbol": ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"],
"所属概念": ["人工智能;芯片", "人工智能体;机器人", "芯片 / 人工智能", None],
})
result = _filter_dimension_member_rows(rows, "所属概念", "人工智能")
assert result.get_column("symbol").to_list() == ["000001.SZ", "000003.SZ"]
def test_filter_dimension_member_rows_matches_industry_hierarchy() -> None:
rows = pl.DataFrame({
"symbol": ["000001.SZ", "000002.SZ", "000003.SZ"],
"所属行业": ["金融-银行-股份制银行", "电子-半导体-数字芯片", "电子元件"],
})
result = _filter_dimension_member_rows(rows, "所属行业", "电子")
assert result.get_column("symbol").to_list() == ["000002.SZ"]
def test_filter_dimension_member_rows_rejects_unknown_field() -> None:
rows = pl.DataFrame({"symbol": ["000001.SZ"]})
with pytest.raises(HTTPException, match="字段 '所属行业' 不存在"):
_filter_dimension_member_rows(rows, "所属行业", "银行")
@@ -0,0 +1,189 @@
from __future__ import annotations
from datetime import datetime
import polars as pl
import pytest
from app.market_time import CN_TZ
from app.services.kline_sync import fetch_intraday_monitor_batch, intraday_monitor_support
from app.strategy import monitor_rules
from app.strategy.intraday_signals import IntradaySignalEvaluator
from app.strategy.monitor import MonitorRuleEngine
from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet
def _minute_rows(prices: list[float]) -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["600000.SH"] * len(prices),
"datetime": [datetime(2026, 7, 17, 9, 30 + i) for i in range(len(prices))],
"close": prices,
"volume": [1.0] * len(prices),
"amount": [price * 100.0 for price in prices],
})
def test_intraday_crosses_are_edge_triggered_and_not_replayed():
evaluator = IntradaySignalEvaluator()
kwargs = {
"symbols": {"600000.SH"},
"prev_close": {"600000.SH": 10.0},
"asset_type": "stock",
}
# 首次只建立基线, 不补发当前已有的穿越。
assert evaluator.evaluate(_minute_rows([9.0]), now=datetime(2026, 7, 17, 9, 32), **kwargs) == []
up = evaluator.evaluate(_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33), **kwargs)
assert len(up) == 1
assert up[0]["signal_intraday_avg_cross_up"] is True
assert up[0]["signal_intraday_zero_cross_up"] is True
# 同一根已完成分钟线不得重复触发。
assert evaluator.evaluate(_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33, 30), **kwargs) == []
down = evaluator.evaluate(_minute_rows([9.0, 11.0, 9.0]), now=datetime(2026, 7, 17, 9, 34), **kwargs)
assert len(down) == 1
assert down[0]["signal_intraday_avg_cross_down"] is True
assert down[0]["signal_intraday_zero_cross_down"] is True
def test_intraday_signals_flow_through_monitor_engine():
evaluator = IntradaySignalEvaluator()
kwargs = {
"symbols": {"600000.SH"},
"prev_close": {"600000.SH": 10.0},
"asset_type": "stock",
}
evaluator.evaluate(_minute_rows([9.0]), now=datetime(2026, 7, 17, 9, 32), **kwargs)
signals = evaluator.evaluate(_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33), **kwargs)
enriched = pl.DataFrame({
"symbol": ["600000.SH"], "close": [11.0], "change_pct": [0.1],
})
engine = MonitorRuleEngine()
engine.set_rules([{**_intraday_rule(), "cooldown_seconds": 0}])
events = engine.evaluate(evaluator.inject(enriched, signals))
assert len(events) == 1
assert events[0]["rule_id"] == "intraday_rule"
assert events[0]["signals"] == ["signal_intraday_avg_cross_up"]
def test_intraday_signal_state_resets_between_trading_days():
evaluator = IntradaySignalEvaluator()
evaluator.evaluate(
_minute_rows([9.0]), symbols={"600000.SH"},
prev_close={"600000.SH": 10.0}, asset_type="stock",
now=datetime(2026, 7, 17, 9, 32),
)
next_day = pl.DataFrame({
"symbol": ["600000.SH"],
"datetime": [datetime(2026, 7, 18, 9, 30)],
"close": [11.0], "volume": [1.0], "amount": [1100.0],
})
assert evaluator.evaluate(
next_day, symbols={"600000.SH"},
prev_close={"600000.SH": 10.0}, asset_type="stock",
now=datetime(2026, 7, 18, 9, 32),
) == []
def test_intraday_average_does_not_accumulate_previous_day_bars():
evaluator = IntradaySignalEvaluator()
previous_day = pl.DataFrame({
"symbol": ["600000.SH"],
"datetime": [datetime(2026, 7, 16, 15, 0)],
"close": [100.0], "volume": [1000.0], "amount": [10_000_000.0],
})
first = pl.concat([previous_day, _minute_rows([9.0])])
evaluator.evaluate(
first, symbols={"600000.SH"}, prev_close={"600000.SH": 10.0},
asset_type="stock", now=datetime(2026, 7, 17, 9, 32),
)
second = pl.concat([previous_day, _minute_rows([9.0, 11.0])])
signals = evaluator.evaluate(
second, symbols={"600000.SH"}, prev_close={"600000.SH": 10.0},
asset_type="stock", now=datetime(2026, 7, 17, 9, 33),
)
assert signals[0]["signal_intraday_avg_cross_up"] is True
def test_intraday_cutoff_keeps_beijing_time_in_utc_runtime():
evaluator = IntradaySignalEvaluator()
kwargs = {
"symbols": {"600000.SH"},
"prev_close": {"600000.SH": 10.0},
"asset_type": "stock",
}
assert evaluator.evaluate(
_minute_rows([9.0]), symbols={"600000.SH"},
prev_close={"600000.SH": 10.0}, asset_type="stock",
now=datetime(2026, 7, 17, 9, 32, tzinfo=CN_TZ),
) == []
signals = evaluator.evaluate(
_minute_rows([9.0, 11.0]), now=datetime(2026, 7, 17, 9, 33, tzinfo=CN_TZ),
**kwargs,
)
assert signals[0]["signal_intraday_zero_cross_up"] is True
def _intraday_rule(scope: str = "symbols") -> dict:
return {
"id": "intraday_rule", "name": "分时监控", "enabled": True,
"type": "signal", "asset_type": "stock", "scope": scope,
"symbols": ["600000.SH"], "logic": "and",
"conditions": [{"field": "signal_intraday_avg_cross_up", "op": "truth"}],
}
def test_intraday_rule_pool_is_derived_from_enabled_rules():
engine = MonitorRuleEngine()
disabled = {**_intraday_rule(), "id": "disabled", "enabled": False, "symbols": ["000001.SZ"]}
engine.set_rules([_intraday_rule(), disabled])
assert engine.intraday_signal_symbols("stock") == {"600000.SH"}
assert engine.intraday_signal_symbols("etf") == set()
def test_intraday_rule_rejects_non_symbol_scope():
with pytest.raises(ValueError, match="仅支持指定股票"):
monitor_rules.validate(_intraday_rule("all"))
def test_intraday_support_uses_capability_limits(monkeypatch):
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
capset = CapabilitySet({Cap.KLINE_MINUTE_BATCH: CapabilityLimits(batch=25, rpm=30)})
support = intraday_monitor_support(capset)
assert support["available"] is True
assert support["source"] == "minute_batch"
assert support["max_symbols"] == 25
denied = intraday_monitor_support(CapabilitySet())
assert denied["available"] is False
def test_intraday_batch_provider_is_normalized_without_network(monkeypatch):
monkeypatch.setattr("app.services.preferences.get_minute_data_provider", lambda: "tickflow")
class FakeKlines:
def intraday_batch(self, symbols, count, as_dataframe, show_progress, batch_size):
assert symbols == ["600000.SH"]
assert count == 300
assert as_dataframe is True
assert show_progress is False
assert batch_size == 20
return pl.DataFrame({
"symbol": symbols,
"datetime": [datetime(2026, 7, 17, 9, 30)],
"open": [10.0], "high": [10.1], "low": [9.9], "close": [10.0],
"volume": [1.0], "amount": [1000.0],
})
class FakeClient:
klines = FakeKlines()
monkeypatch.setattr("app.services.kline_sync.get_client", lambda: FakeClient())
capset = CapabilitySet({Cap.INTRADAY_BATCH: CapabilityLimits(batch=20, rpm=30)})
result = fetch_intraday_monitor_batch(
["600000.SH"], capset, now=datetime(2026, 7, 17, 10, 0, tzinfo=CN_TZ),
)
assert result.columns == ["symbol", "datetime", "open", "high", "low", "close", "volume", "amount"]
assert result["symbol"].to_list() == ["600000.SH"]
@@ -0,0 +1,35 @@
import polars as pl
from app.api.screener import _one_word_limit_expr
def test_one_word_limit_requires_main_status_and_equal_ohlc() -> None:
rows = pl.DataFrame({
"status": ["limit_up", "limit_up", "broken", "limit_up"],
"open": [11.0, 10.5, 11.0, 0.0],
"high": [11.0, 11.0, 11.0, 0.0],
"low": [11.0, 10.5, 11.0, 0.0],
"close": [11.0, 11.0, 11.0, 0.0],
})
result = rows.with_columns(
_one_word_limit_expr("limit_up", rows.columns).alias("is_one_word")
)
assert result.get_column("is_one_word").to_list() == [True, False, False, False]
def test_one_word_limit_supports_limit_down() -> None:
rows = pl.DataFrame({
"status": ["limit_down", "recovery"],
"open": [9.0, 9.0],
"high": [9.0, 9.0],
"low": [9.0, 9.0],
"close": [9.0, 9.0],
})
result = rows.with_columns(
_one_word_limit_expr("limit_down", rows.columns).alias("is_one_word")
)
assert result.get_column("is_one_word").to_list() == [True, False]
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.84",
"version": "0.1.86",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,6 +14,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.55.0",
"@tanstack/react-virtual": "^3.13.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"echarts": "^5.5.0",
+20
View File
@@ -20,6 +20,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.55.0
version: 5.100.11(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.13.12
version: 3.14.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.1
@@ -515,6 +518,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.14.6':
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.4':
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1388,6 +1400,14 @@ snapshots:
'@tanstack/query-core': 5.100.11
react: 18.3.1
'@tanstack/react-virtual@3.14.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.17.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.17.4': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.3
@@ -0,0 +1,302 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Building2, ChevronRight, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { boardTag } from '@/components/stock-table/primitives'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
export type DimensionKind = 'concept' | 'industry'
export interface DimensionMembersTarget {
kind: DimensionKind
value: string
/** 扩展字段完整标识,例如 ext_gn_ths.所属概念。 */
sourceField: string
date?: string
}
export function dimensionKindForSourceField(sourceField: string): DimensionKind | null {
const separator = sourceField.indexOf('.')
const field = (separator >= 0 ? sourceField.slice(separator + 1) : sourceField).trim().toLowerCase()
if (/(概念|题材)|(?:^|[_\s])(concept|theme)(?:$|[_\s])/i.test(field)) return 'concept'
if (/(行业|申万|中信)|(?:^|[_\s])(industry|sector)(?:$|[_\s])/i.test(field)) return 'industry'
return null
}
interface Props {
target: DimensionMembersTarget | null
onClose: () => void
onStockClick?: (symbol: string, name?: string) => void
}
interface ResolvedSource {
configId: string
field: string
}
type SortMode = 'change_desc' | 'change_asc' | 'amount_desc' | 'name'
function resolveSource(sourceField: string): ResolvedSource | null {
const separator = sourceField.indexOf('.')
if (separator <= 0 || separator === sourceField.length - 1) return null
return {
configId: sourceField.slice(0, separator),
field: sourceField.slice(separator + 1),
}
}
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim().toUpperCase()
if (!raw) return []
return Array.from(new Set([raw, raw.replace(/\.\w+$/, '')]))
}
function finite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function stockName(row: Record<string, any>): string {
return String(row.name ?? row['股票简称'] ?? row['名称'] ?? '')
}
function stockSymbol(row: Record<string, any>): string {
return String(row.symbol ?? row.code ?? row['股票代码'] ?? row['代码'] ?? '')
}
export function DimensionMembersDialog({ target, onClose, onStockClick }: Props) {
if (!target) return null
return (
<DimensionMembersDialogContent
key={`${target.sourceField}:${target.value}:${target.date ?? ''}`}
target={target}
onClose={onClose}
onStockClick={onStockClick}
/>
)
}
function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit<Props, 'target'> & { target: DimensionMembersTarget }) {
const source = useMemo(() => resolveSource(target.sourceField), [target.sourceField])
const [search, setSearch] = useState('')
const [sortMode, setSortMode] = useState<SortMode>('change_desc')
const listRef = useRef<HTMLDivElement>(null)
const membersQuery = useQuery({
queryKey: source ? QK.dimensionMembers(source.configId, source.field, target.value, target.date) : ['dimension-members-invalid'],
queryFn: () => api.dimensionMembers(source!.configId, {
field: source!.field,
value: target.value,
date: target.date,
limit: 10000,
}),
enabled: !!source,
staleTime: 5 * 60_000,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
enabled: (membersQuery.data?.rows.length ?? 0) > 0,
staleTime: 60_000,
})
const marketMap = useMemo(() => {
const map = new Map<string, MarketSnapshotRow>()
for (const row of marketQuery.data?.rows ?? []) {
for (const key of symbolKeys(row.symbol)) map.set(key, row)
}
return map
}, [marketQuery.data?.rows])
const rows = useMemo(() => {
const seen = new Set<string>()
return (membersQuery.data?.rows ?? []).flatMap(member => {
const rawSymbol = stockSymbol(member)
const market = symbolKeys(rawSymbol).map(key => marketMap.get(key)).find(Boolean)
const symbol = String(market?.symbol ?? rawSymbol)
if (!symbol || seen.has(symbol)) return []
seen.add(symbol)
return [{
...member,
...market,
symbol,
name: market?.name ?? stockName(member),
}]
})
}, [marketMap, membersQuery.data?.rows])
const visibleRows = useMemo(() => {
const keyword = search.trim().toLowerCase()
const filtered = keyword
? rows.filter(row => `${row.symbol} ${row.name ?? ''}`.toLowerCase().includes(keyword))
: rows
return [...filtered].sort((a, b) => {
if (sortMode === 'name') return String(a.name ?? a.symbol).localeCompare(String(b.name ?? b.symbol), 'zh-CN')
if (sortMode === 'amount_desc') return (finite(b.amount) ?? -Infinity) - (finite(a.amount) ?? -Infinity)
const av = finite(a.change_pct)
const bv = finite(b.change_pct)
if (sortMode === 'change_asc') return (av ?? Infinity) - (bv ?? Infinity)
return (bv ?? -Infinity) - (av ?? -Infinity)
})
}, [rows, search, sortMode])
const stats = useMemo(() => {
const changes = rows.map(row => finite(row.change_pct)).filter((value): value is number => value != null)
return {
up: changes.filter(value => value > 0).length,
down: changes.filter(value => value < 0).length,
flat: rows.length - changes.filter(value => value !== 0).length,
average: changes.length ? changes.reduce((sum, value) => sum + value, 0) / changes.length : null,
}
}, [rows])
const rowVirtualizer = useVirtualizer({
count: visibleRows.length,
getScrollElement: () => listRef.current,
estimateSize: () => 54,
getItemKey: index => visibleRows[index]?.symbol ?? index,
overscan: 8,
})
useEffect(() => {
listRef.current?.scrollTo({ top: 0 })
}, [search, sortMode])
const accent = target.kind === 'concept'
? { icon: Tags, badge: '概念', iconCls: 'text-orange-700 dark:text-orange-300', badgeCls: 'bg-orange-500/10 text-orange-700 dark:text-orange-300' }
: { icon: Building2, badge: '行业', iconCls: 'text-sky-700 dark:text-sky-300', badgeCls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' }
const AccentIcon = accent.icon
const titleId = 'dimension-members-title'
const total = membersQuery.data?.total ?? 0
return (
<Modal
onClose={onClose}
labelledBy={titleId}
panelClassName="flex h-[86vh] max-h-[760px] w-[94vw] max-w-4xl flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
>
<div className="flex shrink-0 items-center gap-3 border-b border-border px-4 py-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-elevated ${accent.iconCls}`}>
<AccentIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 id={titleId} className="truncate text-sm font-semibold text-foreground">{target.value}</h2>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${accent.badgeCls}`}>{accent.badge}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
<span>{membersQuery.data?.label ?? source?.configId ?? '扩展数据'}</span>
{membersQuery.data?.date && <span>{membersQuery.data.date}</span>}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs text-secondary">
<Users className="h-3.5 w-3.5 text-muted" />
{membersQuery.isLoading ? '—' : total}
</span>
<button onClick={onClose} className="inline-flex h-7 w-7 items-center justify-center rounded text-muted hover:bg-elevated hover:text-foreground" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
{!source ? (
<div className="grid min-h-64 place-items-center px-6 text-sm text-danger"></div>
) : membersQuery.isLoading ? (
<div className="grid min-h-64 place-items-center text-muted"><RefreshCw className="h-5 w-5 animate-spin" /></div>
) : membersQuery.isError ? (
<div className="grid min-h-64 place-items-center px-6 text-center text-sm text-danger">{String((membersQuery.error as Error).message)}</div>
) : (
<>
<div className="grid shrink-0 grid-cols-4 divide-x divide-border border-b border-border bg-surface/50">
<Summary label="上涨" value={stats.up} className="text-bull" />
<Summary label="下跌" value={stats.down} className="text-bear" />
<Summary label="平盘/待更新" value={stats.flat} className="text-secondary" />
<Summary label="平均涨跌" value={fmtPct(stats.average)} className={priceColorClass(stats.average)} />
</div>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={event => setSearch(event.target.value)}
placeholder="搜索代码或名称"
className="h-8 w-full rounded-input border border-border bg-surface pl-8 pr-3 text-xs text-foreground placeholder:text-muted focus:border-accent/60 focus:outline-none"
/>
</div>
<select
value={sortMode}
onChange={event => setSortMode(event.target.value as SortMode)}
className="h-8 rounded-input border border-border bg-surface px-2 text-xs text-secondary focus:border-accent/60 focus:outline-none"
aria-label="排序方式"
>
<option value="change_desc"></option>
<option value="change_asc"></option>
<option value="amount_desc"></option>
<option value="name"></option>
</select>
</div>
<div className="grid shrink-0 grid-cols-[minmax(132px,1fr)_74px_74px_18px] border-b border-border bg-elevated/60 px-4 py-2 text-[10px] font-medium text-muted md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]">
<span></span><span className="text-right"></span><span className="text-right"></span>
<span className="hidden text-right md:block"></span><span className="hidden text-right md:block"></span><span />
</div>
{visibleRows.length === 0 ? (
<div className="grid min-h-56 place-items-center text-sm text-muted">{search ? '没有匹配的股票' : '暂无成分股'}</div>
) : (
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="relative w-full" style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = visibleRows[virtualRow.index]
const board = boardTag(row.symbol)
return (
<button
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
onClick={() => onStockClick?.(row.symbol, row.name)}
disabled={!onStockClick}
className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<span className="flex min-w-0 items-center gap-2">
{board && <span className={`inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border text-[9px] font-bold ${board.color}`}>{board.label}</span>}
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">{row.name || row.symbol}</span>
<span className="block font-mono text-[10px] text-muted">{row.symbol}</span>
</span>
</span>
<span className="text-right tabular-nums text-secondary">{fmtPrice(finite(row.close))}</span>
<span className={`text-right tabular-nums font-medium ${priceColorClass(finite(row.change_pct))}`}>{fmtPct(finite(row.change_pct))}</span>
<span className="hidden text-right tabular-nums text-secondary md:block">{finite(row.turnover_rate) != null ? `${finite(row.turnover_rate)!.toFixed(2)}%` : '—'}</span>
<span className="hidden text-right tabular-nums text-secondary md:block">{fmtBigNum(finite(row.amount))}</span>
<ChevronRight className="h-3.5 w-3.5 text-muted" />
</button>
)
})}
</div>
</div>
)}
{total > rows.length && (
<div className="shrink-0 border-t border-border px-4 py-2 text-center text-[10px] text-muted"> {rows.length} / {total} </div>
)}
</>
)}
</Modal>
)
}
function Summary({ label, value, className }: { label: string; value: string | number; className: string }) {
return (
<div className="flex items-baseline justify-center gap-1.5 px-2 py-2.5">
<span className="text-[10px] text-muted">{label}</span>
<span className={`text-xs font-semibold tabular-nums ${className}`}>{value}</span>
</div>
)
}
+219 -48
View File
@@ -1,10 +1,11 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, X, Plus, Search } from 'lucide-react'
import { Activity, Check, Plus, RadioTower, Save, Search, TrendingUp, Waypoints, X } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
import { usePreferences } from '@/lib/useSharedQueries'
interface Props {
@@ -22,6 +23,19 @@ const TYPE_DEFAULT_NAME: Record<string, string> = {
signal: '个股信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控',
}
const TYPE_ICONS = {
signal: Activity,
price: TrendingUp,
market: RadioTower,
strategy: Waypoints,
}
const STRATEGY_SOURCE_META = {
builtin: { label: '内置', className: 'border-accent/25 bg-accent/10 text-accent' },
custom: { label: '自定义', className: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-400' },
ai: { label: 'AI', className: 'border-amber-400/25 bg-amber-400/10 text-amber-400' },
} as const
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
id: genRuleId(),
name: '',
@@ -66,6 +80,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
})
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
const [strategyQuery, setStrategyQuery] = useState('')
const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai'>('all')
// ETF 规则时标的搜索一并搜出 ETF。
const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : 'stock'
const symbolSearch = useQuery({
@@ -77,6 +93,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const save = useMutation({
mutationFn: () => {
const d = { ...draft }
delete d.runtime_warning
// name 为空时用默认名
if (!d.name.trim()) {
const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则'
@@ -135,12 +152,39 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const thresholdFields = options.data?.threshold_fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field)
const hasIntradaySignal = selectedSignals.some(signal => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(signal))
const intradaySupport = options.data?.intraday_signal_support
const monitorBuiltinSignals = [
...SIGNAL_OPTIONS.map(key => ({ key, label: cnSignal(key) })),
...(options.data?.builtin_signals ?? []).filter(option => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(option.key)),
]
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const strategyPresets = strategies.data?.presets ?? []
const selectedStrategy = strategyPresets.find(strategy => strategy.id === draft.strategy_id)
const normalizedStrategyQuery = strategyQuery.trim().toLowerCase()
const visibleStrategies = strategyPresets.filter(strategy => {
if (strategyCategory !== 'all' && strategy.source !== strategyCategory) return false
if (!normalizedStrategyQuery) return true
return [strategy.name, strategy.id, strategy.description, ...(strategy.tags ?? [])]
.some(value => String(value ?? '').toLowerCase().includes(normalizedStrategyQuery))
})
const strategyCategories = [
{ key: 'all' as const, label: '全部', count: strategyPresets.length },
{ key: 'builtin' as const, label: '内置', count: strategyPresets.filter(strategy => strategy.source === 'builtin').length },
{ key: 'custom' as const, label: '自定义', count: strategyPresets.filter(strategy => strategy.source === 'custom').length },
{ key: 'ai' as const, label: 'AI', count: strategyPresets.filter(strategy => strategy.source === 'ai').length },
]
const onSignalPickerChange = (next: string[]) => {
const nonTruthConds = draft.conditions.filter(c => c.op !== 'truth')
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
setDraft(d => ({ ...d, conditions: [...nonTruthConds, ...truthConds] }))
setDraft(d => {
const nonTruthConds = d.conditions.filter(c => c.op !== 'truth')
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
return {
...d,
scope: next.some(signal => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(signal)) ? 'symbols' : d.scope,
conditions: [...nonTruthConds, ...truthConds],
}
})
}
// ── 极简模式: 只显示信号点选 + 可选描述 ──
@@ -164,7 +208,21 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
<div>
<div className="mb-1.5 text-[11px] text-muted"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
<SignalPicker
signals={selectedSignals}
onChange={onSignalPickerChange}
kind="entry"
builtinSignals={monitorBuiltinSignals}
disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []}
disabledSignalHint={intradaySupport?.reason}
/>
{hasIntradaySignal && (
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
{intradaySupport?.available === false
? intradaySupport.reason
: `按已完成的一分钟判断,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只标的。`}
</div>
)}
</div>
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
@@ -238,7 +296,13 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
<button
key={t}
type="button"
onClick={() => setDraft(d => ({ ...d, asset_type: t, strategy_id: null, symbols: [] }))}
aria-pressed={assetType === t}
onClick={() => {
if (assetType === t) return
setDraft(d => ({ ...d, asset_type: t, strategy_id: null, symbols: [] }))
setStrategyQuery('')
setStrategyCategory('all')
}}
className={`h-full px-4 text-xs font-medium transition-colors cursor-pointer
${assetType === t ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'}`}
>
@@ -249,26 +313,48 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</div>
)}
{/* 描述 (可选) + 类型 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="留空用默认名称" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={draft.type} onChange={e => setDraft(d => ({ ...d, type: e.target.value as MonitorRule['type'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.types ?? []).map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</label>
{/* 监控类型 */}
<div className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{(options.data?.types ?? []).map(t => {
const Icon = TYPE_ICONS[t.key as keyof typeof TYPE_ICONS] ?? Activity
const active = draft.type === t.key
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => setDraft(d => ({
...d,
type: t.key as MonitorRule['type'],
scope: t.key === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope,
}))}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-btn border px-2 text-xs font-medium transition-colors cursor-pointer ${
active
? 'border-accent/40 bg-accent/12 text-accent'
: 'border-border bg-base text-secondary hover:border-accent/25 hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{t.label}</span>
</button>
)
})}
</div>
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="留空用默认名称" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
{/* 作用范围 */}
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-9 w-32 rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key} disabled={hasIntradaySignal && s.key !== 'symbols'}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="flex-1 flex flex-wrap items-center gap-1.5">
@@ -327,7 +413,21 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
<div>
<div className="mb-1.5 text-[10px] text-muted/70"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
<SignalPicker
signals={selectedSignals}
onChange={onSignalPickerChange}
kind="entry"
builtinSignals={monitorBuiltinSignals}
disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []}
disabledSignalHint={intradaySupport?.reason}
/>
{hasIntradaySignal && (
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
{intradaySupport?.available === false
? intradaySupport.reason
: `分时穿越按已完成的一分钟判断,仅支持指定股票,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只。`}
</div>
)}
</div>
) : null}
@@ -364,36 +464,107 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
{/* strategy 类型: 选策略 + 方向 */}
{draft.type === 'strategy' && (
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.strategy_id ?? ''}
onChange={e => setDraft(d => ({ ...d, strategy_id: e.target.value || null }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
<option value=""> </option>
{(strategies.data?.presets ?? []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.direction}
onChange={e => setDraft(d => ({ ...d, direction: e.target.value as MonitorRule['direction'] }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{(options.data?.directions ?? []).map(d => <option key={d.key} value={d.key}>{d.label}</option>)}
</select>
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<label className="min-w-0 flex-1 space-y-1.5">
<span className="text-[11px] text-muted"></span>
<span className="relative block">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted" />
<input
value={strategyQuery}
onChange={e => setStrategyQuery(e.target.value)}
placeholder="搜索名称、标签或策略 ID"
className="h-9 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
/>
</span>
</label>
<div className="grid grid-cols-4 gap-1 rounded-btn border border-border bg-base p-1 sm:w-[19rem]">
{strategyCategories.map(category => (
<button
key={category.key}
type="button"
aria-pressed={strategyCategory === category.key}
onClick={() => setStrategyCategory(category.key)}
className={`flex h-7 min-w-0 items-center justify-center gap-1 rounded px-1 text-[10px] font-medium transition-colors cursor-pointer ${
strategyCategory === category.key
? 'bg-elevated text-foreground'
: 'text-muted hover:text-secondary'
}`}
>
<span className="truncate">{category.label}</span>
<span className="font-mono text-[9px] opacity-70">{category.count}</span>
</button>
))}
</div>
</div>
<div className="grid max-h-56 grid-cols-1 gap-1.5 overflow-y-auto pr-1 sm:grid-cols-2">
{strategies.isLoading ? (
<div className="col-span-full py-8 text-center text-xs text-muted">...</div>
) : visibleStrategies.length === 0 ? (
<div className="col-span-full rounded-btn border border-dashed border-border py-8 text-center text-xs text-muted"></div>
) : visibleStrategies.map(strategy => {
const active = draft.strategy_id === strategy.id
const sourceMeta = STRATEGY_SOURCE_META[strategy.source]
const summary = strategy.tags?.length
? strategy.tags.slice(0, 3).join(' · ')
: (strategy.description || strategy.id)
return (
<button
key={strategy.id}
type="button"
aria-pressed={active}
onClick={() => setDraft(d => ({ ...d, strategy_id: strategy.id }))}
className={`flex min-h-14 min-w-0 items-start gap-2 rounded-btn border px-3 py-2 text-left transition-colors cursor-pointer ${
active
? 'border-accent/45 bg-accent/10'
: 'border-border bg-base hover:border-accent/25 hover:bg-elevated/50'
}`}
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className={`shrink-0 rounded border px-1 py-px text-[9px] font-medium ${sourceMeta.className}`}>{sourceMeta.label}</span>
<span className="truncate text-xs font-medium text-foreground">{strategy.name}</span>
</span>
<span className="mt-1 block truncate text-[10px] text-muted" title={summary}>{summary}</span>
</span>
<span className={`mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border ${
active ? 'border-accent bg-accent text-white' : 'border-border text-transparent'
}`}>
<Check className="h-2.5 w-2.5" />
</span>
</button>
)
})}
</div>
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-[11px] text-muted">
{selectedStrategy ? (
<> <span className="font-medium text-foreground">{selectedStrategy.name}</span></>
) : '尚未选择策略'}
</div>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[11px] text-muted"></span>
<div className="inline-flex rounded-btn border border-border bg-base p-0.5">
{(options.data?.directions ?? []).map(direction => (
<button
key={direction.key}
type="button"
aria-pressed={draft.direction === direction.key}
onClick={() => setDraft(d => ({ ...d, direction: direction.key as MonitorRule['direction'] }))}
className={`h-7 rounded px-2.5 text-[10px] font-medium transition-colors cursor-pointer ${
draft.direction === direction.key
? 'bg-accent/15 text-accent'
: 'text-muted hover:text-secondary'
}`}
>
{direction.label}
</button>
))}
</div>
</div>
</div>
<p className="text-[10px] leading-4 text-muted/70">
entry=,exit=,both=
</p>
</div>
)}
@@ -16,6 +16,11 @@ import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { MiniIntraday } from '@/components/stock-table/MiniIntraday'
import { StockDataTable, type SortState } from '@/components/stock-table/StockDataTable'
import {
DimensionMembersDialog,
dimensionKindForSourceField,
type DimensionMembersTarget,
} from '@/components/DimensionMembersDialog'
interface ScreenerTableProps {
rows: any[]
@@ -55,6 +60,7 @@ function renderTagList(
expanded: boolean,
onToggle: () => void,
tagClassName: string,
onTagClick?: (tag: string) => void,
): ReactNode {
if (tags.length === 0) return <span className="text-muted"></span>
@@ -71,7 +77,16 @@ function renderTagList(
return (
<div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}>
{visibleTags.map((tag, i) => (
{visibleTags.map((tag, i) => onTagClick ? (
<button
key={i}
type="button"
onClick={event => { event.stopPropagation(); onTagClick(tag) }}
className={`${tagClassName} hover:brightness-95`}
>
{tag}
</button>
) : (
<span key={i} className={tagClassName}>{tag}</span>
))}
{!showAll && hiddenCount > 0 && (
@@ -102,6 +117,7 @@ function renderExtValue(
col: ColumnConfig,
expanded: boolean,
onToggle: () => void,
onTagClick?: (tag: string) => void,
): ReactNode {
if (val == null || Number.isNaN(val)) return <span className="text-muted"></span>
if (typeof val === 'number') {
@@ -121,7 +137,7 @@ function renderExtValue(
? str.split(separator).map(s => s.trim()).filter(Boolean)
: str.split(/[、,;\-]/).map(s => s.trim()).filter(Boolean)
return renderTagList(tags, col, expanded, onToggle, EXT_TAG_CLS)
return renderTagList(tags, col, expanded, onToggle, EXT_TAG_CLS, onTagClick)
}
export function ScreenerTable({
@@ -133,6 +149,7 @@ export function ScreenerTable({
sort, onSortToggle,
}: ScreenerTableProps) {
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
// 日k列渲染尺寸(按眼睛开关取开启/收起尺寸)
const candleCol = columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible)
@@ -164,6 +181,8 @@ export function ScreenerTable({
const val = r[`${configId}__${fieldName}`]
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
const sourceField = `${configId}.${fieldName}`
const dimensionKind = dimensionKindForSourceField(sourceField)
const tdClass = val == null || Number.isNaN(val)
? 'px-3 py-2 text-center text-muted'
: typeof val === 'number'
@@ -173,7 +192,13 @@ export function ScreenerTable({
if (col.extDisplay?.maxWidth) style.maxWidth = col.extDisplay.maxWidth
return (
<td key={col.id} className={tdClass} style={style}>
{renderExtValue(val, col, expanded, () => toggleExpand(cellKey))}
{renderExtValue(
val,
col,
expanded,
() => toggleExpand(cellKey),
dimensionKind ? value => setDimensionTarget({ kind: dimensionKind, value, sourceField }) : undefined,
)}
</td>
)
}
@@ -320,20 +345,21 @@ export function ScreenerTable({
}
return (
<StockDataTable
columns={columns}
rows={rows}
renderCell={renderCell}
sort={sort}
onSortToggle={onSortToggle}
minWidth={Math.max(900, columns.filter(c => c.visible).length * 110)}
rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`}
rowClassName={(r: any) => r._expired
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50'
}
// 日k / 分时列表头:标签 + 显示/隐藏的眼睛按钮(与自选页一致)
renderHeaderContent={(col) => {
<>
<StockDataTable
columns={columns}
rows={rows}
renderCell={renderCell}
sort={sort}
onSortToggle={onSortToggle}
minWidth={Math.max(900, columns.filter(c => c.visible).length * 110)}
rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`}
rowClassName={(r: any) => r._expired
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50'
}
// 日k / 分时列表头:标签 + 显示/隐藏的眼睛按钮(与自选页一致)
renderHeaderContent={(col) => {
if (col.source.type !== 'builtin') return undefined
const key = col.source.key
// 日k 蜡烛图开关
@@ -396,7 +422,16 @@ export function ScreenerTable({
)
}
return undefined
}}
/>
}}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
setDimensionTarget(null)
onPreview(symbol, name ?? '')
}}
/>
</>
)
}
@@ -13,6 +13,9 @@ interface Props {
kind: 'entry' | 'exit'
/** 渲染尺寸: dialog = 选股弹窗紧凑样式; panel = 回测页设置抽屉样式 */
variant?: 'dialog' | 'panel'
builtinSignals?: { key: string; label: string }[]
disabledSignals?: string[]
disabledSignalHint?: string
}
/**
@@ -22,7 +25,7 @@ interface Props {
* - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both)
* - entry 蓝色主题, exit 橙色主题
*/
export function SignalPicker({ signals, onChange, kind, variant = 'panel' }: Props) {
export function SignalPicker({ signals, onChange, kind, variant = 'panel', builtinSignals, disabledSignals = [], disabledSignalHint }: Props) {
const customSignalsQuery = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const customOptions = useMemo(() => {
@@ -54,19 +57,25 @@ export function SignalPicker({ signals, onChange, kind, variant = 'panel' }: Pro
const btnCls = variant === 'dialog'
? 'rounded px-1.5 py-0.5 text-[10px] font-medium border transition-colors cursor-pointer'
: 'rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors cursor-pointer'
const builtinOptions = builtinSignals ?? SIGNAL_OPTIONS.map(key => ({ key, label: cnSignal(key) }))
return (
<div className="flex flex-wrap gap-1.5">
{SIGNAL_OPTIONS.map(sig => (
<button
key={sig}
type="button"
onClick={() => toggle(sig)}
className={`${btnCls} ${signals.includes(sig) ? active : idle}`}
>
{cnSignal(sig)}
</button>
))}
{builtinOptions.map(option => {
const disabled = disabledSignals.includes(option.key) && !signals.includes(option.key)
return (
<button
key={option.key}
type="button"
disabled={disabled}
title={disabled ? disabledSignalHint : undefined}
onClick={() => toggle(option.key)}
className={`${btnCls} ${signals.includes(option.key) ? active : idle} disabled:cursor-not-allowed disabled:opacity-40`}
>
{option.label}
</button>
)
})}
{customOptions.list.map(cs => {
const id = `csg_${cs.id}`
return (
@@ -5,9 +5,11 @@
* 不内置任何业务逻辑:单元格内容(含 symbol 列交互、操作列、ext 列)由调用方通过
* renderCell / renderExtraCol 注入。这样两个页面的特有交互得以保留,同时表头能力一致。
*/
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
import { cloneElement, isValidElement, useRef, type ReactElement, type ReactNode } from 'react'
import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import type { ColumnConfig } from '@/lib/list-columns'
import { UNSORTABLE_KEYS } from '@/lib/stock-table'
import { VIRTUAL_LIST_THRESHOLD, useParentScroll } from '@/components/virtual-list/useParentScroll'
import type { SortState } from './useTableSort'
export type { SortState }
@@ -57,8 +59,28 @@ export function StockDataTable({
renderHeaderContent,
className = 'rounded-card border border-border overflow-x-auto',
}: StockDataTableProps) {
const containerRef = useRef<HTMLDivElement>(null)
const visibleColumns = columns.filter(c => c.visible)
const computedMinWidth = minWidth ?? Math.max(900, visibleColumns.length * 110)
const virtualized = rows.length > VIRTUAL_LIST_THRESHOLD
const { getScrollElement, scrollMargin } = useParentScroll(containerRef, virtualized)
const rowVirtualizer = useVirtualizer({
count: virtualized ? rows.length : 0,
getScrollElement,
estimateSize: () => 56,
getItemKey: index => rowKey(rows[index]),
overscan: 10,
scrollMargin,
})
const virtualRows = virtualized ? rowVirtualizer.getVirtualItems() : []
const totalSize = virtualized ? rowVirtualizer.getTotalSize() : 0
const firstVirtualRow = virtualRows[0]
const lastVirtualRow = virtualRows[virtualRows.length - 1]
const topPadding = firstVirtualRow ? firstVirtualRow.start - scrollMargin : 0
const bottomPadding = lastVirtualRow
? totalSize - (lastVirtualRow.end - scrollMargin)
: totalSize
const columnCount = visibleColumns.length + (renderExtraCol || extraHeader ? 1 : 0)
const isColSortable = (col: ColumnConfig): boolean => {
// 排序能力由调用方是否提供 onSortToggle 决定;sort 是否为 null 只影响当前指示器
@@ -71,8 +93,26 @@ export function StockDataTable({
? 'sticky top-0 z-10 bg-surface after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border'
: 'bg-elevated'
const renderRow = (r: any, virtualRow?: VirtualItem) => (
<tr
key={rowKey(r)}
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
data-index={virtualRow?.index}
className={`transition-colors duration-150 ease-smooth group ${rowClassName(r)}`}
>
{visibleColumns.map(col => {
// renderCell 返回的 <td> 无 key, 这里补上避免 React key 警告
const cell = renderCell(r, col)
return isValidElement(cell)
? cloneElement(cell as ReactElement, { key: col.id })
: cell
})}
{renderExtraCol && renderExtraCol(r)}
</tr>
)
return (
<div className={className}>
<div ref={containerRef} className={className}>
<table className="w-full text-sm" style={{ minWidth: computedMinWidth }}>
<thead className={theadClass}>
<tr className="text-left text-secondary">
@@ -102,23 +142,19 @@ export function StockDataTable({
</tr>
</thead>
<tbody>
{rows.map((r: any) => {
return (
<tr
key={rowKey(r)}
className={`transition-colors duration-150 ease-smooth group ${rowClassName(r)}`}
>
{visibleColumns.map(col => {
// renderCell 返回的 <td> 无 key, 这里补上避免 React key 警告
const cell = renderCell(r, col)
return isValidElement(cell)
? cloneElement(cell as ReactElement, { key: col.id })
: cell
})}
{renderExtraCol && renderExtraCol(r)}
</tr>
)
})}
{virtualized && topPadding > 0 && (
<tr aria-hidden="true">
<td colSpan={columnCount} className="p-0 border-0" style={{ height: topPadding }} />
</tr>
)}
{virtualized
? virtualRows.map(virtualRow => renderRow(rows[virtualRow.index], virtualRow))
: rows.map((r: any) => renderRow(r))}
{virtualized && bottomPadding > 0 && (
<tr aria-hidden="true">
<td colSpan={columnCount} className="p-0 border-0" style={{ height: bottomPadding }} />
</tr>
)}
</tbody>
</table>
</div>
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useLayoutEffect, useState, type RefObject } from 'react'
export const VIRTUAL_LIST_THRESHOLD = 100
function findVerticalScrollParent(element: HTMLElement | null): HTMLElement | null {
let current = element?.parentElement ?? null
while (current) {
const overflowY = window.getComputedStyle(current).overflowY
if (/(auto|scroll|overlay)/.test(overflowY) && current.scrollHeight > current.clientHeight) {
return current
}
current = current.parentElement
}
return null
}
export function useParentScroll(
containerRef: RefObject<HTMLElement>,
enabled: boolean,
) {
const [scrollMargin, setScrollMargin] = useState(0)
const getScrollElement = useCallback(
() => enabled ? findVerticalScrollParent(containerRef.current) : null,
[containerRef, enabled],
)
const updateScrollMargin = useCallback(() => {
if (!enabled || !containerRef.current) return
const scrollElement = getScrollElement()
if (!scrollElement) return
const containerRect = containerRef.current.getBoundingClientRect()
const scrollRect = scrollElement.getBoundingClientRect()
const next = containerRect.top - scrollRect.top + scrollElement.scrollTop
setScrollMargin(current => Math.abs(current - next) < 0.5 ? current : next)
}, [containerRef, enabled, getScrollElement])
useLayoutEffect(updateScrollMargin)
useEffect(() => {
if (!enabled) return
const scrollElement = getScrollElement()
if (!scrollElement || !containerRef.current) return
const observer = new ResizeObserver(updateScrollMargin)
observer.observe(containerRef.current)
observer.observe(scrollElement)
window.addEventListener('resize', updateScrollMargin)
return () => {
observer.disconnect()
window.removeEventListener('resize', updateScrollMargin)
}
}, [containerRef, enabled, getScrollElement, updateScrollMargin])
return { getScrollElement, scrollMargin }
}
+27
View File
@@ -493,6 +493,7 @@ export interface MonitorRule {
webhook_enabled?: boolean // 兼容老规则, 已由 webhook_channels 取代
webhook_channels?: string[] // 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')
created_at?: string
runtime_warning?: string
// ladder 专属: 封单监控
metric?: 'sealed_vol' | 'sealed_amount' // 量(手) / 额(元)
threshold?: number // 封单 <= 此值时报警
@@ -508,6 +509,12 @@ export interface MonitorRuleOptions {
logics: { key: string; label: string }[]
severities: { key: string; label: string }[]
directions: { key: string; label: string }[]
intraday_signal_support: {
available: boolean
source: string | null
max_symbols: number
reason: string
}
}
export interface AlertEvent {
@@ -550,6 +557,8 @@ export interface LimitLadderStock {
sealed_status?: 'real' | 'fake' | 'pending' | null
/** 封单量(买一/卖一量), 仅真封板有值 */
sealed_vol?: number | null
/** 最终状态为涨跌停且当天开高低收四价相同 */
is_one_word?: boolean
}
export interface LimitLadderTier {
@@ -1519,6 +1528,13 @@ export const api = {
return request<ExtDataRowsResult>(`/api/ext-data/${encodeURIComponent(id)}/rows${suffix ? `?${suffix}` : ''}`)
},
dimensionMembers: (id: string, opts: { field: string; value: string; date?: string; limit?: number }) => {
const qs = new URLSearchParams({ field: opts.field, value: opts.value })
if (opts.date) qs.set('date', opts.date)
if (opts.limit) qs.set('limit', String(opts.limit))
return request<DimensionMembersResult>(`/api/ext-data/${encodeURIComponent(id)}/dimension-members?${qs.toString()}`)
},
analysisMenus: () =>
request<{ items: AnalysisMenu[] }>('/api/analysis-menus'),
@@ -2276,6 +2292,17 @@ export interface ExtDataRowsResult {
rows: Record<string, any>[]
}
export interface DimensionMembersResult {
id: string
label: string
date: string | null
field: string
value: string
total: number
limit: number
rows: Record<string, any>[]
}
export interface AnalysisColumn {
field: string
label?: string
+1
View File
@@ -49,6 +49,7 @@ export const QK = {
pipelineJob: (id: string) => ['pipeline-job', id] as const,
extData: ['ext-data'] as const,
extDataRows: (id: string, date?: string, limit?: number, columns?: string) => ['ext-data-rows', id, date, limit, columns] as const,
dimensionMembers: (id: string, field: string, value: string, date?: string) => ['dimension-members', id, field, value, date] as const,
analysisMenus: ['analysis-menus'] as const,
analysisMenu: (id: string) => ['analysis-menu', id] as const,
+10 -1
View File
@@ -159,11 +159,20 @@ export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
},
]
export const MONITOR_INTRADAY_SIGNAL_LABELS: Record<string, string> = {
signal_intraday_avg_cross_up: '分时价格上穿均价',
signal_intraday_avg_cross_down: '分时价格下穿均价',
signal_intraday_zero_cross_up: '分时价格上穿0轴',
signal_intraday_zero_cross_down: '分时价格下穿0轴',
}
export const MONITOR_INTRADAY_SIGNAL_OPTIONS = Object.keys(MONITOR_INTRADAY_SIGNAL_LABELS)
/** 内置原子信号 → 中文标签 */
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
acc[sig.id] = sig.name
return acc
}, {})
}, { ...MONITOR_INTRADAY_SIGNAL_LABELS })
/** 内置信号 ID 列表 */
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
+72 -13
View File
@@ -5,6 +5,7 @@ import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle
import { DatePicker } from '@/components/DatePicker'
import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPct, priceColorClass } from '@/lib/format'
@@ -218,7 +219,7 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe
// ===== 单只股票卡片 =====
const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick }: {
const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick }: {
stock: LimitLadderStock
extFields: ExtFieldConfig
direction: Direction
@@ -228,6 +229,7 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
onMonitorChange: () => void
hasDepth: boolean
onClick: (symbol: string, name?: string) => void
onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void
}) {
const [showMonitorMenu, setShowMonitorMenu] = useState(false)
const [menuAnchor, setMenuAnchor] = useState<DOMRect | null>(null)
@@ -249,9 +251,9 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
const badgeText = typeof style.badgeText === 'function' ? style.badgeText(direction) : style.badgeText
const tagCls = 'text-[9px] leading-none px-1 py-px rounded-sm'
const conceptCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-orange-200/60 bg-orange-400/[0.05]'
const industryCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-sky-300/90 bg-sky-400/10'
const textCls = `${tagCls} text-secondary/60 bg-elevated/60`
const conceptCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-orange-800 bg-orange-100/80 dark:text-orange-200/60 dark:bg-orange-400/[0.05]'
const industryCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-sky-800 bg-sky-100/80 dark:text-sky-300/90 dark:bg-sky-400/10'
const textCls = `${tagCls} text-secondary bg-elevated/60 dark:text-secondary/60`
const hasTags = conceptTags.length > 0 || industryTags.length > 0
@@ -286,8 +288,15 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
onChanged={onMonitorChange}
/>
)}
<button
<div
role="button"
tabIndex={0}
onClick={() => onClick(stock.symbol, stock.name ?? undefined)}
onKeyDown={event => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onClick(stock.symbol, stock.name ?? undefined)
}}
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`}
style={style.cardStyle ? { ...style.cardStyle } : undefined}
onMouseEnter={e => {
@@ -302,6 +311,13 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
{/* 名称行 */}
<div className="flex items-center gap-1.5 w-full min-w-0 pr-4">
<span className={`${style.nameCls} font-medium truncate`}>{stock.name}</span>
{stock.is_one_word && (
<span className={`shrink-0 rounded-sm border px-1 py-px text-[9px] font-medium leading-none ${
direction === 'down'
? 'border-bear/25 bg-bear/10 text-bear'
: 'border-bull/25 bg-bull/10 text-bull'
}`}></span>
)}
{tag && (
<span className={`shrink-0 text-[9px] px-1 py-px rounded-full border leading-none ${tag.cls}`}>{tag.label}</span>
)}
@@ -341,20 +357,34 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
{conceptTags.length > 0 && (
<div className={`flex gap-0.5 ${conceptLayout === 'vertical' ? 'flex-col items-start' : 'flex-wrap'}`}>
{conceptTags.map((t, i) => (
<span key={i} className={isTextConcept ? textCls : conceptCls}>{t}</span>
<button
key={i}
type="button"
onClick={event => { event.stopPropagation(); onDimensionClick('concept', t, extFields.concept?.field) }}
className={`${isTextConcept ? textCls : conceptCls} hover:brightness-95`}
>
{t}
</button>
))}
</div>
)}
{industryTags.length > 0 && (
<div className={`flex gap-0.5 ${industryLayout === 'vertical' ? 'flex-col items-start' : 'flex-wrap'}`}>
{industryTags.map((t, i) => (
<span key={i} className={isTextIndustry ? textCls : industryCls}>{t}</span>
<button
key={i}
type="button"
onClick={event => { event.stopPropagation(); onDimensionClick('industry', t, extFields.industry?.field) }}
className={`${isTextIndustry ? textCls : industryCls} hover:brightness-95`}
>
{t}
</button>
))}
</div>
)}
</div>
)}
</button>
</div>
</div>
)
})
@@ -789,7 +819,7 @@ function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf, direction
// ===== 标签统计面板 =====
function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSelect, direction }: {
function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSelect, onDimensionClick, direction }: {
title: string
tiers: LimitLadderTier[]
extFields: ExtFieldConfig
@@ -798,6 +828,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
color: { text: [number, number, number]; textLight: [number, number, number]; bg: [number, number, number] }
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
onSelect: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void
direction: Direction
}) {
const [expanded, setExpanded] = useState(false)
@@ -854,7 +885,10 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
return (
<button
key={name}
onClick={() => onSelect(isSelected ? null : { fieldKey, tag: name })}
onClick={() => {
onSelect(isSelected ? null : { fieldKey, tag: name })
onDimensionClick(fieldKey, name, extFields[fieldKey]?.field)
}}
className="text-[11px] px-2 py-1 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
// 亮色: 深色阶文字 + 更淡的底; 选中态不用白字 (黄底白字在亮色下不可读)
@@ -888,7 +922,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
// ===== 梯队分组 =====
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: {
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: {
tier: LimitLadderTier
defaultOpen: boolean
extFields: ExtFieldConfig
@@ -897,6 +931,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onStockClick: (symbol: string, name?: string) => void
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
onSelectTag: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void
direction: Direction
sealMode: 'vol' | 'amount'
monitoredSymbols: Set<string>
@@ -991,7 +1026,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
return (
<button
key={name}
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'concept', tag: name })}
onClick={() => {
onSelectTag(isSelected ? null : { fieldKey: 'concept', tag: name })
onDimensionClick('concept', name, extFields.concept?.field)
}}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected
@@ -1018,7 +1056,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
return (
<button
key={name}
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'industry', tag: name })}
onClick={() => {
onSelectTag(isSelected ? null : { fieldKey: 'industry', tag: name })
onDimensionClick('industry', name, extFields.industry?.field)
}}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected
@@ -1085,6 +1126,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onMonitorChange={onMonitorChange}
hasDepth={hasDepth}
onClick={onStockClick}
onDimensionClick={onDimensionClick}
/>
))}
</div>
@@ -1444,6 +1486,7 @@ export function LimitUpLadder() {
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState('')
const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null)
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
const handleSelectTag = useCallback((sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => {
setSelectedTag(prev => prev?.fieldKey === sel?.fieldKey && prev?.tag === sel?.tag ? null : sel)
}, [])
@@ -1475,6 +1518,10 @@ export function LimitUpLadder() {
queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction),
staleTime: 5 * 60_000,
})
const handleOpenDimension = useCallback((kind: DimensionKind, value: string, sourceField?: string) => {
if (!sourceField) return
setDimensionTarget({ kind, value, sourceField, date: (data?.as_of ?? asOf) || undefined })
}, [asOf, data?.as_of])
const rawTiers = data?.tiers ?? []
const tiers = filterTiers(rawTiers, filterKeys, extFields.bf)
@@ -1660,6 +1707,7 @@ export function LimitUpLadder() {
color={{ text: [250, 204, 21], textLight: [161, 98, 7], bg: [234, 179, 8] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
onDimensionClick={handleOpenDimension}
direction={direction}
/>
)}
@@ -1673,6 +1721,7 @@ export function LimitUpLadder() {
color={{ text: [96, 165, 250], textLight: [29, 78, 216], bg: [59, 130, 246] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
onDimensionClick={handleOpenDimension}
direction={direction}
/>
)}
@@ -1690,6 +1739,7 @@ export function LimitUpLadder() {
onStockClick={handleStockClick}
selectedTag={selectedTag}
onSelectTag={handleSelectTag}
onDimensionClick={handleOpenDimension}
direction={direction}
sealMode={sealMode}
monitoredSymbols={monitoredSymbols}
@@ -1700,6 +1750,15 @@ export function LimitUpLadder() {
))}
</div>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
setDimensionTarget(null)
handleStockClick(symbol, name)
}}
/>
{/* 个股K线弹窗 */}
<StockPreviewDialog
symbol={previewSymbol}
+49 -10
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame, Tags } from 'lucide-react'
import { AlertTriangle, RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame, Tags } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { Skeleton } from '@/components/data/Skeleton'
@@ -14,6 +14,7 @@ import { boardTag } from '@/components/stock-table/primitives'
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { usePreferences } from '@/lib/useSharedQueries'
const TYPE_LABEL: Record<string, string> = {
@@ -73,9 +74,10 @@ function getExtTags(ev: Record<string, unknown>, item: MonitorExtFieldItem | nul
}
/** 个股通知的 ext 标签行 (行业/概念), 无数据返回 null */
function AlertExtTags({ ev, fields }: {
function AlertExtTags({ ev, fields, onTagClick }: {
ev: Record<string, unknown>
fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
onTagClick: (kind: DimensionKind, value: string, sourceField?: string) => void
}) {
const conceptTags = getExtTags(ev, fields.concept)
const industryTags = getExtTags(ev, fields.industry)
@@ -83,10 +85,22 @@ function AlertExtTags({ ev, fields }: {
return (
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
{industryTags.map((t, i) => (
<span key={`i${i}`} className="rounded bg-sky-500/10 px-1 py-px text-[9px] text-sky-400 leading-tight">{t}</span>
<button
key={`i${i}`}
onClick={event => { event.stopPropagation(); onTagClick('industry', t, fields.industry?.field) }}
className="rounded bg-sky-500/10 px-1 py-px text-[9px] leading-tight text-sky-700 hover:brightness-95 dark:text-sky-400"
>
{t}
</button>
))}
{conceptTags.map((t, i) => (
<span key={`c${i}`} className="rounded bg-orange-500/10 px-1 py-px text-[9px] text-orange-400 leading-tight">{t}</span>
<button
key={`c${i}`}
onClick={event => { event.stopPropagation(); onTagClick('concept', t, fields.concept?.field) }}
className="rounded bg-orange-500/10 px-1 py-px text-[9px] leading-tight text-orange-700 hover:brightness-95 dark:text-orange-400"
>
{t}
</button>
))}
</div>
)
@@ -281,6 +295,8 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const [confirmTs, setConfirmTs] = useState<number | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null)
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
const clearMut = useMutation({
mutationFn: api.alertsClear,
@@ -479,7 +495,13 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
)}
</>
)}
<AlertExtTags ev={ev} fields={monitorExtFields} />
<AlertExtTags
ev={ev}
fields={monitorExtFields}
onTagClick={(kind, value, sourceField) => {
if (sourceField) setDimensionTarget({ kind, value, sourceField })
}}
/>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-[10px] text-muted/60 font-mono">
@@ -523,8 +545,8 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
/>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
symbol={memberPreview?.symbol ?? previewEv?.symbol ?? null}
name={memberPreview?.name ?? previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
@@ -532,7 +554,16 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
onClose={() => { setPreviewEv(null); setMemberPreview(null) }}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
setDimensionTarget(null)
setMemberPreview({ symbol, name })
}}
/>
</div>
)
@@ -571,7 +602,8 @@ function RulesList({ rulesQuery, onEdit }: {
onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }),
})
const toggleEnabled = (rule: MonitorRule) => {
api.monitorRuleSave({ ...rule, enabled: !rule.enabled }).then(() =>
const { runtime_warning: _runtimeWarning, ...persistedRule } = rule
api.monitorRuleSave({ ...persistedRule, enabled: !rule.enabled }).then(() =>
qc.invalidateQueries({ queryKey: QK.monitorRules }),
)
}
@@ -684,6 +716,13 @@ function RulesList({ rulesQuery, onEdit }: {
</div>
</div>
{r.runtime_warning && (
<div className="mt-1 flex items-center gap-1 text-[9px] text-warning">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span className="truncate" title={r.runtime_warning}>{r.runtime_warning}</span>
</div>
)}
{/* 第二行: 策略类型显示选股池变更监控 */}
{r.type === 'strategy' && r.strategy_id ? (
<div className="mt-0.5 flex items-center gap-2 pl-0.5">
@@ -738,7 +777,7 @@ function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: Monito
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.15 }}
className="mt-12 w-full max-w-2xl"
className="mt-4 w-full max-w-3xl"
onClick={e => e.stopPropagation()}
>
<RuleEditor
+134 -22
View File
@@ -1,5 +1,6 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
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, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow } from '@/lib/api'
@@ -9,9 +10,15 @@ import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import {
DimensionMembersDialog,
dimensionKindForSourceField,
type DimensionMembersTarget,
} from '@/components/DimensionMembersDialog'
import { WatchlistImportDialog } from '@/components/WatchlistImportDialog'
import { ColumnCustomizer } from '@/components/ColumnCustomizer'
import { StockDataTable } from '@/components/stock-table/StockDataTable'
import { VIRTUAL_LIST_THRESHOLD, useParentScroll } from '@/components/virtual-list/useParentScroll'
import { useTableSort } from '@/components/stock-table/useTableSort'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { MiniIntraday } from '@/components/stock-table/MiniIntraday'
@@ -66,6 +73,7 @@ function renderExtValue(
expanded: boolean,
onToggle: () => void,
inline?: boolean,
onTagClick?: (tag: string) => void,
): React.ReactNode {
if (val == null || Number.isNaN(val)) return <span className="text-muted"></span>
if (typeof val === 'number') {
@@ -108,7 +116,16 @@ function renderExtValue(
const tagEls = (
<>
{visibleTags.map((tag, i) => (
{visibleTags.map((tag, i) => onTagClick ? (
<button
key={i}
type="button"
onClick={event => { event.stopPropagation(); onTagClick(tag) }}
className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-yellow-500 bg-yellow-500/10 hover:brightness-95"
>
{tag}
</button>
) : (
<span key={i} className="inline-block px-1.5 py-px rounded text-[10px] font-medium leading-tight text-yellow-500 bg-yellow-500/10">
{tag}
</span>
@@ -146,12 +163,15 @@ function renderExtCell(
col: ColumnConfig,
expandedCells: Set<string>,
onToggleExpand: (key: string) => void,
onDimensionClick: (target: DimensionMembersTarget) => void,
): React.ReactNode {
if (col.source.type !== 'ext') return null
const { configId, fieldName } = col.source
const val = r[`${configId}__${fieldName}`]
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
const sourceField = `${configId}.${fieldName}`
const dimensionKind = dimensionKindForSourceField(sourceField)
const style: React.CSSProperties = {}
if (col.extDisplay?.maxWidth) {
@@ -169,7 +189,14 @@ function renderExtCell(
return (
<td className={tdClass} style={style}>
{renderExtValue(val, col, expanded, () => onToggleExpand(cellKey))}
{renderExtValue(
val,
col,
expanded,
() => onToggleExpand(cellKey),
false,
dimensionKind ? value => onDimensionClick({ kind: dimensionKind, value, sourceField }) : undefined,
)}
</td>
)
}
@@ -327,6 +354,26 @@ function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
// 共享的空 K 线数组常量 — 避免每次渲染传入新的 [] 破坏 StockCard 的 memo
const EMPTY_KLINE: KlineRow[] = []
function cardColumnCount(viewportWidth: number): number {
if (viewportWidth >= 1536) return 6
if (viewportWidth >= 1280) return 5
if (viewportWidth >= 768) return 4
if (viewportWidth >= 640) return 3
return 2
}
function useCardColumnCount(): number {
const [count, setCount] = useState(() => cardColumnCount(window.innerWidth))
useEffect(() => {
const update = () => setCount(cardColumnCount(window.innerWidth))
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}, [])
return count
}
const StockCard = React.memo(function StockCard({
r,
candleRows,
@@ -339,6 +386,7 @@ const StockCard = React.memo(function StockCard({
extCols,
expandedCells,
onToggleExpand,
onDimensionClick,
isMonitored,
}: {
r: any
@@ -352,6 +400,7 @@ const StockCard = React.memo(function StockCard({
extCols: ColumnConfig[]
expandedCells: Set<string>
onToggleExpand: (key: string) => void
onDimensionClick: (target: DimensionMembersTarget) => void
isMonitored?: boolean
}) {
const board = boardTag(r.symbol)
@@ -455,12 +504,21 @@ const StockCard = React.memo(function StockCard({
const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey)
const sourceField = `${configId}.${fieldName}`
const dimensionKind = dimensionKindForSourceField(sourceField)
return (
<span key={col.id} title={col.label}>
<span className="text-secondary">{fieldName}</span>
<span className="font-mono ml-0.5">
{renderExtValue(val, col, expanded, () => onToggleExpand(cellKey), true)}
{renderExtValue(
val,
col,
expanded,
() => onToggleExpand(cellKey),
true,
dimensionKind ? value => onDimensionClick({ kind: dimensionKind, value, sourceField }) : undefined,
)}
</span>
</span>
)
@@ -582,6 +640,7 @@ export function Watchlist() {
}, [])
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
const closePreview = useCallback(() => {
setPreviewSymbol(null)
@@ -825,6 +884,24 @@ export function Watchlist() {
[filteredRows, sortRows, columns],
)
const cardColumns = useCardColumnCount()
const cardGridRef = useRef<HTMLDivElement>(null)
const virtualizeCards = viewMode === 'card' && sortedRows.length > VIRTUAL_LIST_THRESHOLD
const cardRowCount = Math.ceil(sortedRows.length / cardColumns)
const { getScrollElement: getCardScrollElement, scrollMargin: cardScrollMargin } = useParentScroll(
cardGridRef,
virtualizeCards,
)
const cardRowVirtualizer = useVirtualizer({
count: virtualizeCards ? cardRowCount : 0,
getScrollElement: getCardScrollElement,
estimateSize: () => dailyKVisible ? 180 : 140,
getItemKey: index => `${cardColumns}:${(sortedRows[index * cardColumns] as any)?.symbol ?? index}`,
gap: 12,
overscan: 3,
scrollMargin: cardScrollMargin,
})
// 可见的 ext 列(卡片视图使用)
const visibleExtCols = useMemo(
() => visibleColumns.filter(c => c.source.type === 'ext'),
@@ -843,6 +920,25 @@ export function Watchlist() {
// rows.length 是后端实际返回 (含 pending 行), 减去 sortedRows (筛选后) 才是真正的筛选隐藏.
const hiddenCount = Math.max(0, rows.length - sortedRows.length)
const renderStockCard = (r: any) => (
<StockCard
key={r.symbol}
r={r}
candleRows={klineData[r.symbol] ?? EMPTY_KLINE}
showCandle={dailyKVisible}
onPreview={handleCardPreview}
onConfirmRemove={handleCardConfirmRemove}
onCancelRemove={handleCardCancelRemove}
onRequestRemove={handleCardRequestRemove}
isConfirming={confirmRemove === r.symbol}
extCols={visibleExtCols}
expandedCells={expandedCells}
onToggleExpand={handleToggleExpand}
onDimensionClick={setDimensionTarget}
isMonitored={monitoredSymbols.has(r.symbol)}
/>
)
return (
<div className="flex flex-col h-full">
<PageHeader
@@ -1115,7 +1211,7 @@ export function Watchlist() {
renderCell={(r: any, col: ColumnConfig) => {
// ext 列
if (col.source.type === 'ext') {
return renderExtCell(r, col, expandedCells, handleToggleExpand)
return renderExtCell(r, col, expandedCells, handleToggleExpand, setDimensionTarget)
}
const key = col.source.key
const price = r.rt_price ?? r.close
@@ -1256,25 +1352,31 @@ export function Watchlist() {
}}
className="rounded-card overflow-x-auto"
/>
) : (
) : !virtualizeCards ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
{sortedRows.map((r: any) => (
<StockCard
key={r.symbol}
r={r}
candleRows={klineData[r.symbol] ?? EMPTY_KLINE}
showCandle={dailyKVisible}
onPreview={handleCardPreview}
onConfirmRemove={handleCardConfirmRemove}
onCancelRemove={handleCardCancelRemove}
onRequestRemove={handleCardRequestRemove}
isConfirming={confirmRemove === r.symbol}
extCols={visibleExtCols}
expandedCells={expandedCells}
onToggleExpand={handleToggleExpand}
isMonitored={monitoredSymbols.has(r.symbol)}
/>
))}
{sortedRows.map(renderStockCard)}
</div>
) : (
<div
ref={cardGridRef}
className="relative"
style={{ height: cardRowVirtualizer.getTotalSize() }}
>
{cardRowVirtualizer.getVirtualItems().map(virtualRow => {
const start = virtualRow.index * cardColumns
const row = sortedRows.slice(start, start + cardColumns)
return (
<div
key={virtualRow.key}
ref={cardRowVirtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
style={{ transform: `translateY(${virtualRow.start - cardScrollMargin}px)` }}
>
{row.map(renderStockCard)}
</div>
)
})}
</div>
)}
</div>
@@ -1337,6 +1439,16 @@ export function Watchlist() {
onClose={closePreview}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
setDimensionTarget(null)
setPreviewSymbol(symbol)
setPreviewName(name ?? '')
}}
/>
<WatchlistImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)