mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: release v0.1.86
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.85"
|
||||
__version__ = "0.1.86"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文件上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 副本。
|
||||
|
||||
|
||||
@@ -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
|
||||
])
|
||||
@@ -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 评估轮。"""
|
||||
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
setDraft(d => {
|
||||
const nonTruthConds = d.conditions.filter(c => c.op !== 'truth')
|
||||
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
|
||||
setDraft(d => ({ ...d, conditions: [...nonTruthConds, ...truthConds] }))
|
||||
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">
|
||||
{/* 监控类型 */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 作用范围 */}
|
||||
<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"
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<option value="">— 请选择 —</option>
|
||||
{(strategies.data?.presets ?? []).map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
<span className="truncate">{category.label}</span>
|
||||
<span className="font-mono text-[9px] opacity-70">{category.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] leading-4 text-muted/70">
|
||||
策略监控自动评估策略的出入场信号。entry=入场信号,exit=出场信号,both=两者都报。作用范围建议用「全市场」。
|
||||
</p>
|
||||
</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>
|
||||
</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,6 +345,7 @@ export function ScreenerTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StockDataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
@@ -398,5 +424,14 @@ 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 => (
|
||||
{builtinOptions.map(option => {
|
||||
const disabled = disabledSignals.includes(option.key) && !signals.includes(option.key)
|
||||
return (
|
||||
<button
|
||||
key={sig}
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => toggle(sig)}
|
||||
className={`${btnCls} ${signals.includes(sig) ? active : idle}`}
|
||||
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`}
|
||||
>
|
||||
{cnSignal(sig)}
|
||||
{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)}
|
||||
{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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user