diff --git a/VERSION b/VERSION index a783aed..1ed86cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.84 +v0.1.86 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 6e634d1..dd4a72e 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.85" +__version__ = "0.1.86" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py index 2c3d977..eae8cbd 100644 --- a/backend/app/api/ext_data.py +++ b/backend/app/api/ext_data.py @@ -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, + } + + # --------------------------------------------------------------------------- # 文件上传 # --------------------------------------------------------------------------- diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 67e15ee..26e614d 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -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} diff --git a/backend/app/api/screener.py b/backend/app/api/screener.py index 49e349b..e299ae3 100644 --- a/backend/app/api/screener.py +++ b/backend/app/api/screener.py @@ -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) diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index f13dfd1..530e088 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -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 diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 1b2aad7..b3deccb 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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 副本。 diff --git a/backend/app/strategy/intraday_signals.py b/backend/app/strategy/intraday_signals.py new file mode 100644 index 0000000..c2ff6a1 --- /dev/null +++ b/backend/app/strategy/intraday_signals.py @@ -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 + ]) diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 6cecfdf..61ce1c6 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -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 评估轮。""" diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 3405bb4..6b3a359 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -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), 避免用户建出会刷屏的规则。 diff --git a/backend/tests/test_ext_data_dimension_members.py b/backend/tests/test_ext_data_dimension_members.py new file mode 100644 index 0000000..9c0d18a --- /dev/null +++ b/backend/tests/test_ext_data_dimension_members.py @@ -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, "所属行业", "银行") diff --git a/backend/tests/test_intraday_monitor_signals.py b/backend/tests/test_intraday_monitor_signals.py new file mode 100644 index 0000000..5473227 --- /dev/null +++ b/backend/tests/test_intraday_monitor_signals.py @@ -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"] diff --git a/backend/tests/test_limit_ladder_one_word.py b/backend/tests/test_limit_ladder_one_word.py new file mode 100644 index 0000000..4234300 --- /dev/null +++ b/backend/tests/test_limit_ladder_one_word.py @@ -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] diff --git a/frontend/package.json b/frontend/package.json index d105af6..4226999 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ed7d8c7..2aab12a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/components/DimensionMembersDialog.tsx b/frontend/src/components/DimensionMembersDialog.tsx new file mode 100644 index 0000000..8f72e58 --- /dev/null +++ b/frontend/src/components/DimensionMembersDialog.tsx @@ -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 { + return String(row.name ?? row['股票简称'] ?? row['名称'] ?? '') +} + +function stockSymbol(row: Record): string { + return String(row.symbol ?? row.code ?? row['股票代码'] ?? row['代码'] ?? '') +} + +export function DimensionMembersDialog({ target, onClose, onStockClick }: Props) { + if (!target) return null + return ( + + ) +} + +function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit & { target: DimensionMembersTarget }) { + const source = useMemo(() => resolveSource(target.sourceField), [target.sourceField]) + const [search, setSearch] = useState('') + const [sortMode, setSortMode] = useState('change_desc') + const listRef = useRef(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() + 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() + 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 ( + +
+
+ +
+
+
+

{target.value}

+ {accent.badge} +
+
+ {membersQuery.data?.label ?? source?.configId ?? '扩展数据'} + {membersQuery.data?.date && {membersQuery.data.date}} +
+
+
+ + + {membersQuery.isLoading ? '—' : total} + + +
+
+ + {!source ? ( +
扩展字段格式无效
+ ) : membersQuery.isLoading ? ( +
+ ) : membersQuery.isError ? ( +
{String((membersQuery.error as Error).message)}
+ ) : ( + <> +
+ + + + +
+ +
+
+ + 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" + /> +
+ +
+ +
+ 股票现价涨跌幅 + 换手率成交额 +
+ + {visibleRows.length === 0 ? ( +
{search ? '没有匹配的股票' : '暂无成分股'}
+ ) : ( +
+
+ {rowVirtualizer.getVirtualItems().map(virtualRow => { + const row = visibleRows[virtualRow.index] + const board = boardTag(row.symbol) + return ( + + ) + })} +
+
+ )} + + {total > rows.length && ( +
显示前 {rows.length} / {total} 只
+ )} + + )} +
+ ) +} + +function Summary({ label, value, className }: { label: string; value: string | number; className: string }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 2dc951d..045af41 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -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 = { 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 => ({ 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) {
选择触发信号 (任一命中即报警)
- + + {hasIntradaySignal && ( +
+ {intradaySupport?.available === false + ? intradaySupport.reason + : `按已完成的一分钟判断,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只标的。`} +
+ )}
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */} @@ -238,7 +296,13 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { + ) + })} + + + {/* 作用范围 */}
作用范围
{draft.scope === 'symbols' && (
@@ -327,7 +413,21 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { {selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
信号条件 (点选)
- + + {hasIntradaySignal && ( +
+ {intradaySupport?.available === false + ? intradaySupport.reason + : `分时穿越按已完成的一分钟判断,仅支持指定股票,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只。`} +
+ )}
) : null} @@ -364,36 +464,107 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { {/* strategy 类型: 选策略 + 方向 */} {draft.type === 'strategy' && ( -
- 策略与方向 -
- -
) }) @@ -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 (
@@ -1444,6 +1486,7 @@ export function LimitUpLadder() { const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null) + const [dimensionTarget, setDimensionTarget] = useState(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() { ))}
+ setDimensionTarget(null)} + onStockClick={(symbol, name) => { + setDimensionTarget(null) + handleStockClick(symbol, name) + }} + /> + {/* 个股K线弹窗 */} = { @@ -73,9 +74,10 @@ function getExtTags(ev: Record, item: MonitorExtFieldItem | nul } /** 个股通知的 ext 标签行 (行业/概念), 无数据返回 null */ -function AlertExtTags({ ev, fields }: { +function AlertExtTags({ ev, fields, onTagClick }: { ev: Record 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 (
{industryTags.map((t, i) => ( - {t} + ))} {conceptTags.map((t, i) => ( - {t} + ))}
) @@ -281,6 +295,8 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const [confirmTs, setConfirmTs] = useState(null) const resetTimer = useRef | null>(null) const [previewEv, setPreviewEv] = useState(null) + const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null) + const [dimensionTarget, setDimensionTarget] = useState(null) const clearMut = useMutation({ mutationFn: api.alertsClear, @@ -479,7 +495,13 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs )} )} - + { + if (sourceField) setDimensionTarget({ kind, value, sourceField }) + }} + />
@@ -523,8 +545,8 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs /> setPreviewEv(null)} + onClose={() => { setPreviewEv(null); setMemberPreview(null) }} + /> + + setDimensionTarget(null)} + onStockClick={(symbol, name) => { + setDimensionTarget(null) + setMemberPreview({ symbol, name }) + }} />
) @@ -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 }: {
+ {r.runtime_warning && ( +
+ + {r.runtime_warning} +
+ )} + {/* 第二行: 策略类型显示选股池变更监控 */} {r.type === 'strategy' && r.strategy_id ? (
@@ -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()} > void, inline?: boolean, + onTagClick?: (tag: string) => void, ): React.ReactNode { if (val == null || Number.isNaN(val)) return if (typeof val === 'number') { @@ -108,7 +116,16 @@ function renderExtValue( const tagEls = ( <> - {visibleTags.map((tag, i) => ( + {visibleTags.map((tag, i) => onTagClick ? ( + + ) : ( {tag} @@ -146,12 +163,15 @@ function renderExtCell( col: ColumnConfig, expandedCells: Set, 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 ( - {renderExtValue(val, col, expanded, () => onToggleExpand(cellKey))} + {renderExtValue( + val, + col, + expanded, + () => onToggleExpand(cellKey), + false, + dimensionKind ? value => onDimensionClick({ kind: dimensionKind, value, sourceField }) : undefined, + )} ) } @@ -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 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 ( {fieldName} - {renderExtValue(val, col, expanded, () => onToggleExpand(cellKey), true)} + {renderExtValue( + val, + col, + expanded, + () => onToggleExpand(cellKey), + true, + dimensionKind ? value => onDimensionClick({ kind: dimensionKind, value, sourceField }) : undefined, + )} ) @@ -582,6 +640,7 @@ export function Watchlist() { }, []) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + const [dimensionTarget, setDimensionTarget] = useState(null) const [expandedCells, setExpandedCells] = useState>(new Set()) const closePreview = useCallback(() => { setPreviewSymbol(null) @@ -825,6 +884,24 @@ export function Watchlist() { [filteredRows, sortRows, columns], ) + const cardColumns = useCardColumnCount() + const cardGridRef = useRef(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) => ( + + ) + return (
{ // 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 ? (
- {sortedRows.map((r: any) => ( - - ))} + {sortedRows.map(renderStockCard)} +
+ ) : ( +
+ {cardRowVirtualizer.getVirtualItems().map(virtualRow => { + const start = virtualRow.index * cardColumns + const row = sortedRows.slice(start, start + cardColumns) + return ( +
+ {row.map(renderStockCard)} +
+ ) + })}
)}
@@ -1337,6 +1439,16 @@ export function Watchlist() { onClose={closePreview} /> + setDimensionTarget(null)} + onStockClick={(symbol, name) => { + setDimensionTarget(null) + setPreviewSymbol(symbol) + setPreviewName(name ?? '') + }} + /> + setImportOpen(false)} />
)