From f7b5f139459cc48cc1745d7702c24c448b8017aa Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Tue, 25 Aug 2026 14:06:56 +0800 Subject: [PATCH 01/51] =?UTF-8?q?feat(monitor):=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E6=94=BE=E9=87=8F=E7=9B=91=E6=8E=A7=20=E2=80=94=20=E7=9B=B8?= =?UTF-8?q?=E9=82=BB=E5=BF=AB=E7=85=A7=E6=88=90=E4=BA=A4=E9=87=8F/?= =?UTF-8?q?=E9=A2=9D=E5=B7=AE=E5=80=BC=E9=98=88=E5=80=BC=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新监控类型 volume_delta: 全市场相邻两次行情轮询的成交量增量 >= 阈值(默认 9000 手)即提醒, 镜像 ladder 封单监控的临时列注入模式。开盘保护(9:25 竞价 撮合/午休缺口不误报)、跨天清空、数据源重置防御; metric 支持手数/金额双 口径(金额对不同股价更公平); basic_filter 基础过滤(股价/总市值/流通市值/ 成交额/剔除ST, 与策略 basic_filter 语义对齐); 命中超5只合并批量通知; 冷却期默认300s; 触发记录/SSE/Webhook 全链路复用。16项专项+104回归测试。 --- backend/app/api/monitor_rules.py | 6 + backend/app/services/quote_service.py | 104 ++++++- backend/app/strategy/monitor.py | 140 ++++++++- backend/app/strategy/monitor_rules.py | 59 +++- backend/tests/test_volume_delta_monitor.py | 274 ++++++++++++++++++ .../src/components/monitor/RuleEditor.tsx | 151 +++++++++- frontend/src/lib/api.ts | 20 +- frontend/src/pages/LimitUpLadder.tsx | 4 +- frontend/src/pages/Monitor.tsx | 23 +- 9 files changed, 763 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_volume_delta_monitor.py diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 0d9286d..139296e 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -107,6 +107,11 @@ class RuleModel(BaseModel): # ladder 专属 (连板梯队封单监控) metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元) threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元) + # volume_delta 专属 (轮询放量监控): 相邻两次全市场快照的成交量增量 + threshold_volume: float = 9000 # 单轮增量 >= 此值(手)时报警 + threshold_amount: float = 1e6 # metric=amount 时: 单轮增量 >= 此值(元)时报警 + # 基础过滤 (与策略 basic_filter 语义对齐): 值为 null 表示不过滤 + basic_filter: dict = {} # ── 字段选项 ───────────────────────────────────────────── @@ -160,6 +165,7 @@ def get_options(request: Request): {"key": "strategy", "label": "策略监控"}, {"key": "abnormal", "label": "异动监控"}, {"key": "sector", "label": "板块监控"}, + {"key": "volume_delta", "label": "轮询放量"}, ], "scopes": [ {"key": "symbols", "label": "指定标的"}, diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 9844a5c..aea3b32 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -28,7 +28,7 @@ import threading import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from datetime import date, time as dt_time +from datetime import date, datetime, time as dt_time import polars as pl @@ -218,6 +218,16 @@ class QuoteService: # 午休/收盘最终同步状态: 到边界后必须成功拉取一版行情, 再进入休盘态。 self._final_sync_done: set[tuple[date, str]] = set() self._final_sync_failed: dict[tuple[date, str], str] = {} + # 轮询放量 (volume_delta 规则): 上一轮全市场股票快照的 (累计成交量[手], 累计成交额[元])。 + # 每轮全量快照后更新 (含非连续竞价时段, 保证 13:00 恢复时 prev 是 12:59 + # 而非 11:30); 跨交易日清空; cur < prev (数据源重置) 时丢弃该轮差值。 + self._prev_stock_volume: dict[str, tuple[float, float]] | None = None + self._prev_volume_fetched_at: float | None = None # epoch 毫秒 + self._prev_volume_date: date | None = None + # 最近一轮的有效差值 (vol_delta[手], amt_delta[元]) - 仅连续竞价时段内、 + # prev 不早于本时段开盘时计算 + self._volume_delta: dict[str, tuple[float, float]] = {} + self._volume_delta_span_s: float = 0.0 # ================================================================ # 生命周期 @@ -719,6 +729,9 @@ class QuoteService: _persist_last_fetch(fetched_at) logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms) + # 轮询放量状态更新 (volume_delta 规则的差值来源) + self._update_volume_delta(stock_records, fetched_at) + # ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ---- daily_df = self._build_daily(stock_records) if not daily_df.is_empty() and self._repo: @@ -1117,6 +1130,8 @@ class QuoteService: eval_df = enriched_today if engine.has_rule_type("ladder"): eval_df = self._inject_sealed_vol(enriched_today, enriched_date) + if engine.has_rule_type("volume_delta"): + eval_df = self._inject_volume_delta(eval_df) 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(): @@ -1203,7 +1218,8 @@ class QuoteService: "window_change_pct", "coverage_ratio", "valid_count", "total_count", "up_count", "down_count", "leader", "abnormal_window", "abnormal_value", "abnormal_threshold", - "abnormal_closeness", + "abnormal_closeness", "volume_delta", "volume_delta_span", + "volume_delta_amount", ): if key in ev: alert[key] = ev[key] @@ -1352,6 +1368,88 @@ class QuoteService: ) return self._intraday_signal_evaluator.inject(enriched, signals) + @staticmethod + def _continuous_session_start_ms() -> float: + """当前连续竞价时段的起点 (北京时间 9:30 或 13:00) 的 epoch 毫秒。""" + now = cn_now() + start_time = dt_time(13, 0) if now.time() >= dt_time(13, 0) else dt_time(9, 30) + return datetime.combine(now.date(), start_time, tzinfo=now.tzinfo).timestamp() * 1000.0 + + def _update_volume_delta(self, stock_records: list[dict], fetched_at_ms: float) -> None: + """全市场相邻两次快照的股票累计成交量差值 (手), 供 volume_delta 规则。 + + - prev 每轮都更新 (含非连续竞价时段); 差值只在连续竞价时段内计算 + - 开盘保护: prev 早于本时段起点 (9:30/13:00) 时本轮差值无效 -- 避免 + 9:25 集合竞价撮合量 / 午休缺口被当成"突然放量" + - cur < prev (数据源重置/口径跳变) 的个股丢弃差值; 跨交易日清空 + """ + today = cn_today() + if self._prev_volume_date != today: + self._prev_stock_volume = None + self._prev_volume_fetched_at = None + self._prev_volume_date = today + self._volume_delta = {} + + cur: dict[str, tuple[float, float]] = {} + for r in stock_records: + sym = r.get("symbol") + vol = r.get("volume") + amt = r.get("amount") + if not sym or not isinstance(vol, (int, float)): + continue + cur[str(sym)] = ( + float(vol), + float(amt) if isinstance(amt, (int, float)) else 0.0, + ) + + prev = self._prev_stock_volume + prev_ts = self._prev_volume_fetched_at + if ( + prev is not None + and prev_ts is not None + and self._is_continuous_trading() + and prev_ts >= self._continuous_session_start_ms() + ): + delta = { + sym: (v - prev[sym][0], a - prev[sym][1]) + for sym, (v, a) in cur.items() + if sym in prev and v >= prev[sym][0] and a >= prev[sym][1] and v - prev[sym][0] > 0 + } + self._volume_delta = delta + self._volume_delta_span_s = max((fetched_at_ms - prev_ts) / 1000.0, 0.001) + else: + self._volume_delta = {} + + self._prev_stock_volume = cur + self._prev_volume_fetched_at = fetched_at_ms + + def _inject_volume_delta(self, enriched_today: pl.DataFrame) -> pl.DataFrame: + """把最近一轮快照差值作为临时列注入 enriched 副本。 + + _volume_delta (手) / _volume_delta_amount (元) / _volume_delta_span (秒, 快照间隔)。 + 无有效差值 (首轮/开盘保护/暂停后恢复) 时返回原 df, 规则安全降级不触发。 + """ + try: + delta = self._volume_delta + if not delta: + return enriched_today + span = self._volume_delta_span_s + delta_df = pl.DataFrame({ + "symbol": list(delta.keys()), + "_volume_delta": [v for v, _ in delta.values()], + "_volume_delta_amount": [a for _, a in delta.values()], + "_volume_delta_span": [span] * len(delta), + }) + drop_cols = [ + c for c in ("_volume_delta", "_volume_delta_amount", "_volume_delta_span") + if c in enriched_today.columns + ] + df = enriched_today.drop(drop_cols) if drop_cols else enriched_today + return df.join(delta_df, on="symbol", how="left") + except Exception as e: # noqa: BLE001 + logger.debug("快照差值注入失败 (volume_delta 规则将不触发): %s", e) + return enriched_today + def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame: """从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。 @@ -1414,7 +1512,7 @@ class QuoteService: source_labels = { "strategy": "策略", "signal": "信号", "price": "价格", "market": "异动", "ladder": "连板梯队", - "sector": "板块", + "sector": "板块", "volume_delta": "放量", } rules = engine.rules if engine is not None else {} enqueued = 0 diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 162b324..a6afdfd 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -46,7 +46,8 @@ _SIGNAL_CN: dict[str, str] = { # 行情字段 "close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价", "change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅", - "turnover_rate": "换手率", "volume": "成交量", "amount": "成交额", + "turnover_rate": "换手率", "volume": "成交量", "amount": "成交额", + "_volume_delta": "轮询成交量差值(手)", "_sealed_vol": "封单量(手)", # 均线 "ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60", "ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20", @@ -983,6 +984,9 @@ class MonitorRuleEngine: elif rtype == "ladder": # 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message) return self._evaluate_ladder(scoped, rule, now) + elif rtype == "volume_delta": + # 轮询放量监控: 相邻两次全市场快照的成交量差值, 独立处理走专属 message + return self._evaluate_volume_delta(scoped, rule, now) else: # signal / price / market: 通用条件匹配 for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule): @@ -1364,6 +1368,140 @@ class MonitorRuleEngine: results.append((sym, name, price, pct, hit_sigs)) return results + @staticmethod + def _volume_delta_basic_mask(df: pl.DataFrame, bf: dict, name_map: dict[str, str]) -> pl.Expr | None: + """轮询放量基础过滤掩码 (与策略 basic_filter 语义对齐, 字段缺失时该项跳过)。 + + 支持: price_min/max (收盘价), market_cap_min (总市值=close x total_shares), + float_cap_min/max (流通市值), amount_min (当日累计成交额), exclude_st (名称含 ST)。 + """ + masks: list[pl.Expr] = [] + if bf.get("price_min") is not None: + masks.append(pl.col("close") >= float(bf["price_min"])) + if bf.get("price_max") is not None: + masks.append(pl.col("close") <= float(bf["price_max"])) + if bf.get("amount_min") is not None and "amount" in df.columns: + masks.append(pl.col("amount") >= float(bf["amount_min"])) + if bf.get("market_cap_min") is not None and "total_shares" in df.columns: + masks.append((pl.col("close") * pl.col("total_shares")) >= float(bf["market_cap_min"])) + if bf.get("float_cap_min") is not None and "float_shares" in df.columns: + masks.append((pl.col("close") * pl.col("float_shares")) >= float(bf["float_cap_min"])) + if bf.get("float_cap_max") is not None and "float_shares" in df.columns: + masks.append((pl.col("close") * pl.col("float_shares")) <= float(bf["float_cap_max"])) + if bf.get("exclude_st") and name_map: + st_symbols = [ + sym for sym, name in name_map.items() + if name and "ST" in str(name).upper() + ] + if st_symbols: + masks.append(~pl.col("symbol").is_in(st_symbols)) + if not masks: + return None + return pl.all_horizontal(masks) + + def _evaluate_volume_delta(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]: + """评估轮询放量监控: 相邻两次全市场快照的成交量/成交额差值。 + + 差值列 _volume_delta(手)/_volume_delta_amount(元)/间隔列 _volume_delta_span + 由 quote_service 评估前注入。metric=volume 按手数、amount 按金额比较阈值; + basic_filter 先行过滤 (股价/市值/成交额/ST, 与策略 basic_filter 语义对齐)。 + 命中 >5 只时合并为一条批量事件防刷屏。 + """ + if "_volume_delta" not in scoped.columns: + return [] # 无差值数据 (首轮/开盘保护/非全市场轮询), 安全降级 + + metric = rule.get("metric", "volume") + if metric == "amount" and "_volume_delta_amount" in scoped.columns: + cmp_col, threshold = "_volume_delta_amount", rule.get("threshold_amount", 1e6) + th_text = f"{threshold / 1e4:,.0f} 万元" + else: + cmp_col, threshold = "_volume_delta", rule.get("threshold_volume", 9000) + th_text = f"{threshold:,.0f} 手" + + cooldown = rule.get("cooldown_seconds", 300) + severity = rule.get("severity", "warn") + span_s = 0.0 + if "_volume_delta_span" in scoped.columns and scoped.height > 0: + v = scoped["_volume_delta_span"][0] + span_s = float(v) if v is not None else 0.0 + span_text = f" (间隔 {span_s:.0f}s)" if span_s > 0 else "" + + candidate = scoped + bf = rule.get("basic_filter") or {} + if bf: + mask = self._volume_delta_basic_mask(candidate, bf, self._name_map) + if mask is not None: + candidate = candidate.filter(mask) + + hit = candidate.filter( + pl.col(cmp_col).is_not_null() & (pl.col(cmp_col) >= threshold) + ).sort(cmp_col, descending=True) + if hit.is_empty(): + return [] + hit_rows = list(hit.iter_rows(named=True)) + + def _name_of(row: dict) -> str: + sym = row.get("symbol", "") + return row.get("name") or self._name_map.get(sym) or sym + + def _fmt(v) -> str: + if metric == "amount": + return f"{v / 1e4:,.0f} 万元" + return f"{v:,.0f} 手" + + def _event(symbol: str, name: str, message: str, *, delta=None, price=None, pct=None) -> dict: + ev = { + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "source": "volume_delta", + "type": "轮询放量", + "symbol": symbol, + "name": name, + "message": message, + "price": price, + "change_pct": pct, + "signals": [], + "severity": severity, + "conditions": [], + "logic": "and", + "volume_delta": delta, + "volume_delta_span": round(span_s, 1), + } + if metric == "amount": + ev["volume_delta_amount"] = delta + return ev + + if len(hit_rows) > 5: + top = "、".join(_name_of(r) for r in hit_rows[:8]) + suffix = "等" if len(hit_rows) > 8 else "" + message = ( + f"放量 · 单轮增量 >= {th_text}{span_text} · " + f"共 {len(hit_rows)} 只: {top}{suffix}" + ) + key = (rule["id"], "_volume_delta_batch", "volume_delta") + last = self._last_fire.get(key) + if last is not None and (now - last) < cooldown: + return [] + self._last_fire[key] = now + return [_event("", "", message)] + + events: list[dict] = [] + for row in hit_rows: + sym = row.get("symbol", "") + key = (rule["id"], sym, "volume_delta") + last = self._last_fire.get(key) + if last is not None and (now - last) < cooldown: + continue + self._last_fire[key] = now + delta = row.get(cmp_col) + message = f"放量 · 单轮增量 {_fmt(delta)} >= {th_text}{span_text}" + events.append(_event( + sym, _name_of(row), message, + delta=delta, price=row.get("close"), pct=row.get("change_pct"), + )) + return events + def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]: """评估连板梯队封单监控规则。 diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 35cd0bc..2d6571b 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) # ── 常量 ──────────────────────────────────────────────── ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") -RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"} +RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal", "volume_delta"} SCOPES = {"symbols", "all", "sector", "watchlist_group"} LOGICS = {"and", "or"} DIRECTIONS = {"entry", "exit", "both"} @@ -45,6 +45,19 @@ SECTOR_WINDOWS = {1, 3, 5, 10, 15} # abnormal 规则 (异动边缘): 接近度方向 / 关注窗口 ABNORMAL_DIRECTIONS = {"up", "down", "both"} ABNORMAL_WINDOWS = {"any", "3d", "10d", "30d"} +# volume_delta 规则 (轮询放量): 阈值口径 (手数 / 成交额) +VD_METRICS = {"volume", "amount"} +# volume_delta 基础过滤默认值 (与策略 DEFAULT_BASIC_FILTER 核心子集对齐: +# 价格 3-300 元, 总市值 >=10 亿, 当日成交额 >=2000 万, 剔除 ST) +VD_BASIC_FILTER_DEFAULTS: dict = { + "price_min": 3, + "price_max": 300, + "market_cap_min": 10e8, + "float_cap_min": None, + "float_cap_max": None, + "amount_min": 0.2e8, + "exclude_st": True, +} # 布尔信号列前缀 (op=truth 时 field 取这些) _SIGNAL_PREFIXES = ("signal_", "csg_") @@ -190,6 +203,39 @@ def validate(rule: dict) -> None: threshold_pct = rule.get("threshold_pct") if not isinstance(threshold_pct, (int, float)) or not 1 <= threshold_pct <= 150: raise ValueError("异动接近度阈值必须是 1 到 150 之间的百分比数字") + elif rule.get("type") == "volume_delta": + # 轮询放量监控: 相邻两次全市场快照的成交量/成交额差值, 不用 conditions + if rule.get("asset_type", "stock") != "stock": + raise ValueError("轮询放量监控仅支持个股 (依赖全市场股票快照)") + if rule.get("scope", "all") == "sector": + raise ValueError("轮询放量监控不支持板块作用域") + if rule.get("metric", "volume") not in VD_METRICS: + raise ValueError(f"metric 必须是 {VD_METRICS} 之一 (volume=手数, amount=金额)") + if rule.get("metric", "volume") == "amount": + thr = rule.get("threshold_amount") + if isinstance(thr, bool) or not isinstance(thr, (int, float)) or not math.isfinite(thr) or thr < 1: + raise ValueError("threshold_amount 必须是 >=1 的数字 (单轮成交额增量, 单位元)") + else: + thr = rule.get("threshold_volume") + if isinstance(thr, bool) or not isinstance(thr, (int, float)) or not math.isfinite(thr) or thr < 1: + raise ValueError("threshold_volume 必须是 >=1 的数字 (单轮成交量增量, 单位手)") + bf = rule.get("basic_filter") + if bf is not None: + if not isinstance(bf, dict): + raise ValueError("basic_filter 必须是对象") + for key, value in bf.items(): + if key == "exclude_st": + if not isinstance(value, bool): + raise ValueError("basic_filter.exclude_st 必须是布尔值") + elif key in ("price_min", "price_max", "market_cap_min", "float_cap_min", + "float_cap_max", "amount_min"): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value <= 0 + ): + raise ValueError(f"basic_filter.{key} 必须是正数字或 null") + else: + raise ValueError(f"basic_filter 不支持字段: {key}") else: # 信号/价格/市场类型: 需要 conditions conds = rule.get("conditions") @@ -254,7 +300,7 @@ def normalize(rule: dict) -> dict: r.setdefault("enabled", True) r.setdefault("asset_type", "stock") # sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的) - r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols") + r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal", "volume_delta"} else "symbols") r.setdefault("symbols", []) r.setdefault("group_id", None) # watchlist_group 作用域: 成员动态来自分组, symbols 不参与; 其他作用域清掉残留 group_id @@ -290,6 +336,15 @@ def normalize(rule: dict) -> dict: # ladder 专属默认字段 r.setdefault("metric", "sealed_vol") r.setdefault("threshold", 0) + # volume_delta 专属默认字段 (轮询放量): 冷却期默认 300s 而非 3600s -- + # 持续放量会连续多轮达标, 1 小时只提醒一次太迟钝。 + if r.get("type") == "volume_delta": + if r.get("cooldown_seconds") is None: + r["cooldown_seconds"] = 300 + r["metric"] = r["metric"] if r.get("metric") in VD_METRICS else "volume" + r.setdefault("threshold_volume", 9000) + r.setdefault("threshold_amount", 1e6) + r["basic_filter"] = {**VD_BASIC_FILTER_DEFAULTS, **(r.get("basic_filter") or {})} if r.get("type") == "sector": r["scope"] = "all" r["symbols"] = [] diff --git a/backend/tests/test_volume_delta_monitor.py b/backend/tests/test_volume_delta_monitor.py new file mode 100644 index 0000000..df1a36d --- /dev/null +++ b/backend/tests/test_volume_delta_monitor.py @@ -0,0 +1,274 @@ +"""轮询放量监控 (volume_delta) 测试: 引擎命中/冷却/批量合并 + 基础过滤 + 快照差值边界。""" +from __future__ import annotations + +from datetime import date + +import polars as pl +import pytest + +from app.strategy import monitor_rules +from app.strategy.monitor import MonitorRuleEngine + + +def _df(rows: list[dict]) -> pl.DataFrame: + """rows 每项: symbol/_volume_delta 必填, 其余可选 (close/amount/total_shares/float_shares)。""" + base = { + "symbol": [], "close": [], "change_pct": [], + "_volume_delta": [], "_volume_delta_amount": [], "_volume_delta_span": [], + } + optional = ["amount", "total_shares", "float_shares"] + for r in rows: + base["symbol"].append(r["symbol"]) + base["close"].append(r.get("close", 10.0)) + base["change_pct"].append(0.01) + base["_volume_delta"].append(r["_volume_delta"]) + base["_volume_delta_amount"].append(r.get("_volume_delta_amount", r["_volume_delta"] * 1000.0)) + base["_volume_delta_span"].append(6.0) + data = {k: v for k, v in base.items()} + for col in optional: + vals = [r.get(col) for r in rows] + if any(v is not None for v in vals): + data[col] = [v if v is not None else 0.0 for v in vals] + return pl.DataFrame(data) + + +def _rule(**kw): + r = { + "id": "vd1", "name": "轮询放量", "type": "volume_delta", + "asset_type": "stock", "scope": "all", "enabled": True, + "threshold_volume": 9000, "cooldown_seconds": 300, + "severity": "warn", + } + r.update(kw) + return r + + +def test_volume_delta_hits_above_threshold(): + eng = MonitorRuleEngine() + eng.set_rules([_rule()]) + events = eng.evaluate(_df([ + {"symbol": "S1.SH", "_volume_delta": 9500.0, "close": 10.0}, + {"symbol": "S2.SH", "_volume_delta": 8999.0, "close": 20.0}, + ])) + assert [e["symbol"] for e in events] == ["S1.SH"] + ev = events[0] + assert ev["source"] == "volume_delta" + assert "9,500" in ev["message"] and "9,000" in ev["message"] and "间隔 6s" in ev["message"] + assert ev["volume_delta"] == 9500.0 + + +def test_volume_delta_no_column_degrades_silently(): + eng = MonitorRuleEngine() + eng.set_rules([_rule()]) + plain = pl.DataFrame({"symbol": ["S1.SH"], "close": [10.0]}) + assert eng.evaluate(plain) == [] + + +def test_volume_delta_cooldown_suppresses_repeat(): + eng = MonitorRuleEngine() + eng.set_rules([_rule(cooldown=300)]) + df = _df([{"symbol": "S1.SH", "_volume_delta": 12000.0}]) + assert len(eng.evaluate(df)) == 1 + assert eng.evaluate(df) == [] + + +def test_volume_delta_batch_merge_over_five(): + eng = MonitorRuleEngine() + eng.set_rules([_rule()]) + rows = [{"symbol": f"S{i}.SH", "_volume_delta": 20000.0 + i} for i in range(8)] + events = eng.evaluate(_df(rows)) + assert len(events) == 1 + assert events[0]["symbol"] == "" + assert "共 8 只" in events[0]["message"] + + +def test_volume_delta_scope_filters(): + eng = MonitorRuleEngine() + eng.set_rules([_rule(scope="symbols", symbols=["S2.SH"])]) + events = eng.evaluate(_df([ + {"symbol": "S1.SH", "_volume_delta": 9500.0}, + {"symbol": "S2.SH", "_volume_delta": 9500.0}, + ])) + assert [e["symbol"] for e in events] == ["S2.SH"] + + +def test_volume_delta_metric_amount(): + eng = MonitorRuleEngine() + eng.set_rules([_rule(metric="amount", threshold_amount=5e6)]) + events = eng.evaluate(_df([ + {"symbol": "S1.SH", "_volume_delta": 100.0, "_volume_delta_amount": 6e6}, + {"symbol": "S2.SH", "_volume_delta": 20000.0, "_volume_delta_amount": 4.9e6}, + ])) + assert [e["symbol"] for e in events] == ["S1.SH"] + assert "万元" in events[0]["message"] + + +def test_volume_delta_basic_filter_price_and_amount(): + eng = MonitorRuleEngine() + eng.set_rules([_rule(basic_filter={ + "price_min": 5, "price_max": 100, "amount_min": 1e8, "exclude_st": False, + })]) + events = eng.evaluate(_df([ + # 价低被滤 + {"symbol": "LOW.SH", "_volume_delta": 20000.0, "close": 3.0, "amount": 5e8}, + # 价过高被滤 + {"symbol": "HIGH.SH", "_volume_delta": 20000.0, "close": 200.0, "amount": 5e8}, + # 成交额不足被滤 + {"symbol": "THIN.SH", "_volume_delta": 20000.0, "close": 10.0, "amount": 5e7}, + # 通过 + {"symbol": "OK.SH", "_volume_delta": 20000.0, "close": 10.0, "amount": 5e8}, + ])) + assert [e["symbol"] for e in events] == ["OK.SH"] + + +def test_volume_delta_basic_filter_market_cap(): + eng = MonitorRuleEngine() + eng.set_rules([_rule(basic_filter={ + "market_cap_min": 20e8, "price_min": None, "price_max": None, + "amount_min": None, "exclude_st": False, + })]) + # close × total_shares: BIG 10×3e8=30亿 通过; SMALL 10×1e8=10亿 被滤 + events = eng.evaluate(_df([ + {"symbol": "BIG.SH", "_volume_delta": 20000.0, "total_shares": 3e8}, + {"symbol": "SMALL.SH", "_volume_delta": 20000.0, "total_shares": 1e8}, + ])) + assert [e["symbol"] for e in events] == ["BIG.SH"] + + +def test_volume_delta_basic_filter_exclude_st(): + eng = MonitorRuleEngine() + eng.set_name_map({"STOCK.SH": "平安银行", "STK.SH": "ST 某某"}) + eng.set_rules([_rule(basic_filter={ + "price_min": None, "price_max": None, "amount_min": None, "exclude_st": True, + })]) + events = eng.evaluate(_df([ + {"symbol": "STOCK.SH", "_volume_delta": 20000.0}, + {"symbol": "STK.SH", "_volume_delta": 20000.0}, + ])) + assert [e["symbol"] for e in events] == ["STOCK.SH"] + + +def test_validate_and_normalize_defaults(): + r = monitor_rules.normalize({"id": "vd2", "type": "volume_delta"}) + assert r["threshold_volume"] == 9000 + assert r["scope"] == "all" + assert r["cooldown_seconds"] == 300 + assert r["metric"] == "volume" + assert r["basic_filter"]["price_min"] == 3 + assert r["basic_filter"]["exclude_st"] is True + # 用户字段覆盖默认 + r2 = monitor_rules.normalize({"id": "vd5", "type": "volume_delta", "basic_filter": {"price_min": 1, "exclude_st": False}}) + assert r2["basic_filter"]["price_min"] == 1 + assert r2["basic_filter"]["exclude_st"] is False + assert r2["basic_filter"]["price_max"] == 300 # 未覆盖项保留默认 + monitor_rules.validate({"id": "vd2", "name": "n", "type": "volume_delta", "threshold_volume": 1}) + with pytest.raises(ValueError): + monitor_rules.validate({"id": "vd3", "name": "n", "type": "volume_delta", "threshold_volume": 0}) + with pytest.raises(ValueError): + monitor_rules.validate({"id": "vd4", "name": "n", "type": "volume_delta", "asset_type": "etf"}) + with pytest.raises(ValueError): + monitor_rules.validate({"id": "vd6", "name": "n", "type": "volume_delta", + "metric": "amount", "threshold_amount": 0}) + with pytest.raises(ValueError): + monitor_rules.validate({"id": "vd7", "name": "n", "type": "volume_delta", + "basic_filter": {"price_min": -1}}) + with pytest.raises(ValueError): + monitor_rules.validate({"id": "vd8", "name": "n", "type": "volume_delta", + "basic_filter": {"unknown_field": 1}}) + + +# ── 快照差值状态 (QuoteService) ────────────────────────── + +def _qs(monkeypatch, *, continuous=True): + from app.services.quote_service import QuoteService + qs = QuoteService.__new__(QuoteService) + qs._prev_stock_volume = None + qs._prev_volume_fetched_at = None + qs._prev_volume_date = None + qs._volume_delta = {} + qs._volume_delta_span_s = 0.0 + monkeypatch.setattr(QuoteService, "_is_continuous_trading", lambda self: continuous) + monkeypatch.setattr( + QuoteService, "_continuous_session_start_ms", + staticmethod(lambda: 0.0), + ) + monkeypatch.setattr("app.services.quote_service.cn_today", lambda: date(2026, 8, 25)) + return qs + + +def test_delta_computed_and_prev_updated(monkeypatch): + qs = _qs(monkeypatch) + t0 = 1_000_000.0 + qs._update_volume_delta( + [{"symbol": "S1.SH", "volume": 10000, "amount": 5e6}, + {"symbol": "S2.SH", "volume": 500, "amount": 1e6}], t0, + ) + assert qs._volume_delta == {} # 首轮无 prev + qs._update_volume_delta( + [{"symbol": "S1.SH", "volume": 19500, "amount": 9.5e6}, + {"symbol": "S2.SH", "volume": 400, "amount": 2e6}], t0 + 6000, + ) + # S2 volume cur < prev (重置) → 丢弃; S1 差值 (9500 手, 450 万元) + assert qs._volume_delta == {"S1.SH": (9500.0, 4.5e6)} + assert qs._volume_delta_span_s == 6.0 + + +def test_delta_cross_day_reset(monkeypatch): + import app.services.quote_service as qsm + qs = _qs(monkeypatch) + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 10000}], 1000.0) + assert qs._prev_volume_date == date(2026, 8, 25) + monkeypatch.setattr(qsm, "cn_today", lambda: date(2026, 8, 26)) + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 20000}], 2000.0) + assert qs._volume_delta == {} + assert qs._prev_volume_date == date(2026, 8, 26) + + +def test_delta_open_protection(monkeypatch): + qs = _qs(monkeypatch) + # 9:29 的 prev (早于 9:30 时段起点) → 9:31 本轮不触发 + session_start = 1_000_000.0 + monkeypatch.setattr( + type(qs), "_continuous_session_start_ms", + staticmethod(lambda: session_start), + ) + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 10000}], session_start - 60_000) + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 99999}], session_start + 60_000) + assert qs._volume_delta == {} + # 之后一轮 prev 已在时段内 → 恢复计算 + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 109999}], session_start + 66_000) + assert qs._volume_delta == {"S1.SH": (10000.0, 0.0)} + + +def test_delta_not_continuous_trading(monkeypatch): + qs = _qs(monkeypatch, continuous=False) + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 10000}], 1000.0) + qs._update_volume_delta([{"symbol": "S1.SH", "volume": 99999}], 7000.0) + # 非连续竞价 (如午休) 不产差值, 但 prev 持续更新 + assert qs._volume_delta == {} + assert qs._prev_stock_volume == {"S1.SH": (99999.0, 0.0)} + + +def test_inject_volume_delta_join(): + from app.services.quote_service import QuoteService + qs = QuoteService.__new__(QuoteService) + qs._volume_delta = {"S1.SH": (900.0, 9e5), "S9.SH": (500.0, 5e5)} + qs._volume_delta_span_s = 6.0 + base = pl.DataFrame({"symbol": ["S1.SH", "S2.SH"], "close": [10.0, 20.0]}) + out = qs._inject_volume_delta(base) + assert out.filter(pl.col("symbol") == "S1.SH")["_volume_delta"][0] == 900.0 + assert out.filter(pl.col("symbol") == "S1.SH")["_volume_delta_amount"][0] == 9e5 + # 未命中股票为 null (不触发) + assert out.filter(pl.col("symbol") == "S2.SH")["_volume_delta"][0] is None + # 空差值原样返回 + qs._volume_delta = {} + assert qs._inject_volume_delta(base).columns == ["symbol", "close"] + + +def test_session_start_ms_matches_clock(): + from app.services.quote_service import QuoteService + from datetime import datetime, time as dt_time, timedelta, timezone + + now = QuoteService._continuous_session_start_ms() / 1000.0 + start_dt = datetime.fromtimestamp(now, tz=timezone(timedelta(hours=8))) + assert start_dt.time() in (dt_time(9, 30), dt_time(13, 0)) diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index b6681a7..255b878 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Activity, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Siren, Tags, TrendingUp, Waypoints, X } from 'lucide-react' +import { Activity, BarChart3, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Siren, Tags, TrendingUp, Waypoints, X } from 'lucide-react' import { api, genRuleId, type MonitorRule, type MonitorCondition, type SectorKind, type SectorMonitorTarget, type StrategyNotifyEvent } from '@/lib/api' import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents' import { QK } from '@/lib/queryKeys' @@ -9,7 +9,7 @@ import { boardTag } from '@/components/stock-table/primitives' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { SignalPicker } from '@/components/screener/SignalPicker' import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals' -import { usePreferences } from '@/lib/useSharedQueries' +import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' interface Props { /** 编辑现有规则;null=新建 */ @@ -23,7 +23,7 @@ interface Props { } const TYPE_DEFAULT_NAME: Record = { - signal: '信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', sector: '板块监控', abnormal: '异动监控', + signal: '信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', sector: '板块监控', abnormal: '异动监控', volume_delta: '轮询放量监控', } const TYPE_ICONS = { @@ -33,6 +33,7 @@ const TYPE_ICONS = { strategy: Waypoints, sector: Layers3, abnormal: Siren, + volume_delta: BarChart3, } const SECTOR_KIND_OPTIONS: Array<{ key: SectorKind; label: string; icon: typeof ChartNoAxesCombined }> = [ @@ -73,6 +74,7 @@ const emptyRule = (preset?: Partial): MonitorRule => ({ cooldown_seconds: 3600, severity: 'info', message: '', + threshold_volume: 9000, ...preset, }) @@ -80,6 +82,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { const qc = useQueryClient() const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions }) const { data: prefs } = usePreferences() + const { data: quoteStatus } = useQuoteStatus() + const quoteInterval = quoteStatus?.interval_s const feishuConfigured = !!(prefs?.feishu_webhook_url) const wecomConfigured = !!(prefs?.wecom_webhook_url) const [editing] = useState(!!rule) @@ -211,6 +215,18 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { if ((d.threshold_pct ?? 0) < 1 || (d.threshold_pct ?? 0) > 150) { throw new Error('接近度阈值必须在 1 到 150 之间 (70=边缘, 100=已触发)') } + } else if (d.type === 'volume_delta') { + delete d.score_min + delete d.score_max + d.conditions = [] + delete d.notify_events + if (d.metric === 'amount') { + if (!Number.isFinite(d.threshold_amount) || (d.threshold_amount ?? 0) < 1) { + throw new Error('金额阈值必须是 ≥1 的数字 (万元)') + } + } else if (!Number.isFinite(d.threshold_volume) || (d.threshold_volume ?? 0) < 1) { + throw new Error('单轮放量阈值必须是 ≥1 的手数') + } } else { delete d.score_min delete d.score_max @@ -591,12 +607,24 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { return { ...d, type, + // 轮询放量依赖全市场股票快照, 仅支持个股 + asset_type: type === 'volume_delta' ? 'stock' : d.asset_type, notify_events: type === 'strategy' ? [...(d.notify_events ?? DEFAULT_STRATEGY_NOTIFY_EVENTS)] : undefined, - scope: type === 'sector' || type === 'abnormal' + scope: type === 'sector' || type === 'abnormal' || type === 'volume_delta' ? 'all' : type === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope, + // 轮询放量: 冷却期默认 300s (持续放量会连续多轮达标); 切走时还原 3600 + cooldown_seconds: type === 'volume_delta' && d.type !== 'volume_delta' ? 300 + : type !== 'volume_delta' && d.type === 'volume_delta' ? 3600 + : d.cooldown_seconds, + // 轮询放量: metric / 金额阈值 / 基础过滤默认 (与策略 basic_filter 对齐) + metric: type === 'volume_delta' && d.type !== 'volume_delta' ? 'volume' : d.metric, + threshold_amount: type === 'volume_delta' && d.type !== 'volume_delta' ? 1e6 : d.threshold_amount, + basic_filter: type === 'volume_delta' && d.type !== 'volume_delta' + ? { price_min: 3, price_max: 300, market_cap_min: 10e8, float_cap_min: null, float_cap_max: null, amount_min: 0.2e8, exclude_st: true } + : d.basic_filter, direction: type === 'sector' ? 'up' : type === 'abnormal' ? 'both' : d.type === 'sector' || d.type === 'abnormal' ? 'entry' : d.direction, @@ -895,6 +923,119 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { )} + {draft.type === 'volume_delta' && ( +
+
+
+ 阈值口径 +
+ {([['volume', '按手数'], ['amount', '按金额']] as const).map(([key, label]) => ( + + ))} +
+
+ +
+ +
+ 基础过滤 (与策略选股口径对齐, 留空不过滤) +
+ + + + + + +
+
+ +
+ 捕捉单次轮询间隔内的突发放量 (大单连续扫货)。开盘首轮与暂停恢复后的第一轮不触发, + 防止集合竞价撮合量误报; 冷却期内同一标的不重复提醒, 命中超过 5 只时合并为一条批量通知。 +
+
+ )} + {/* 作用范围 */} {draft.type !== 'sector' &&
作用范围 @@ -1106,7 +1247,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
} {/* 触发条件 (非 strategy) */} - {draft.type !== 'strategy' && draft.type !== 'sector' && draft.type !== 'abnormal' && ( + {draft.type !== 'strategy' && draft.type !== 'sector' && draft.type !== 'abnormal' && draft.type !== 'volume_delta' && (
触发条件 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6ae9cc3..1f604c7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -783,7 +783,7 @@ export interface MonitorRule { id: string name: string enabled: boolean - type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal' + type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal' | 'volume_delta' asset_type?: 'stock' | 'etf' | 'index' scope: 'symbols' | 'all' | 'sector' | 'watchlist_group' symbols: string[] @@ -812,9 +812,23 @@ export interface MonitorRule { webhook_channels?: string[] // 命中时推送的外部渠道 (合法值 'feishu' | 'wecom') created_at?: string runtime_warning?: string - // ladder 专属: 封单监控 - metric?: 'sealed_vol' | 'sealed_amount' // 量(手) / 额(元) + // ladder 专属: 封单监控; volume_delta 复用 metric 表示阈值口径 (volume=手数, amount=金额) + metric?: 'sealed_vol' | 'sealed_amount' | 'volume' | 'amount' threshold?: number // 封单 <= 此值时报警 + // volume_delta 专属 (轮询放量): 相邻两次全市场快照的成交量增量(手) + threshold_volume?: number // 单轮增量 >= 此值时报警 + threshold_amount?: number // metric=amount 时: 单轮增量 >= 此值(元)时报警 + basic_filter?: VDBasicFilter // 基础过滤 (与策略 basic_filter 语义对齐) +} + +export interface VDBasicFilter { + price_min?: number | null // 股价下限 (元) + price_max?: number | null // 股价上限 (元) + market_cap_min?: number | null // 总市值下限 (元) + float_cap_min?: number | null // 流通市值下限 (元) + float_cap_max?: number | null // 流通市值上限 (元) + amount_min?: number | null // 当日成交额下限 (元) + exclude_st?: boolean // 剔除 ST } export interface MonitorRuleOptions { diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index d267741..41de236 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -421,7 +421,9 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD { key: '100000000', label: '亿元', mult: 100000000 }, ] - const [metric, setMetric] = useState<'sealed_vol' | 'sealed_amount'>(existing?.metric ?? (sealMode === 'amount' ? 'sealed_amount' : 'sealed_vol')) + const [metric, setMetric] = useState<'sealed_vol' | 'sealed_amount'>( + existing?.metric === 'sealed_amount' || (!existing && sealMode === 'amount') ? 'sealed_amount' : 'sealed_vol' + ) const units = metric === 'sealed_amount' ? AMT_UNITS : VOL_UNITS // 已有规则: 反算到最大便捷单位 (选能整除的最大倍率); 新建: 额默认亿元, 量默认万手 const initUnit = (() => { diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 8a4d838..f366a84 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -23,7 +23,7 @@ import { usePreferences } from '@/lib/useSharedQueries' const TYPE_LABEL: Record = { signal: '信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控', sector: '板块监控', - abnormal: '异动监控', + abnormal: '异动监控', volume_delta: '轮询放量', } /** 严重级别 → 左侧色条 + 图标 */ @@ -39,6 +39,7 @@ const SOURCE_BADGE_STYLE: Record = { market: 'bg-purple-500/10 text-purple-400 border-purple-500/20', sector: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/20 dark:text-cyan-300', abnormal: 'bg-orange-500/10 text-orange-500 border-orange-500/20 dark:text-orange-400', + volume_delta: 'bg-rose-500/10 text-rose-400 border-rose-500/20 dark:text-rose-300', } /** @@ -132,7 +133,7 @@ export function Monitor() { }, [searchParams, setSearchParams]) // 触发记录: 过滤 + 统计 (提升到主组件, 供 header 行使用) - const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market' | 'sector' | 'abnormal'>('all') + const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market' | 'sector' | 'abnormal' | 'volume_delta'>('all') const [confirmClear, setConfirmClear] = useState(false) const [confirmClearRules, setConfirmClearRules] = useState(false) @@ -193,7 +194,7 @@ export function Monitor() { {/* 过滤标签 */}
- {(['all', 'strategy', 'signal', 'price', 'market', 'sector', 'abnormal'] as const).map(f => ( + {(['all', 'strategy', 'signal', 'price', 'market', 'sector', 'abnormal', 'volume_delta'] as const).map(f => (
+ ) : r.type === 'volume_delta' ? ( +
+ + {r.metric === 'amount' + ? `单轮增量 ≥ ${Math.round((r.threshold_amount ?? 1e6) / 1e4).toLocaleString()} 万元` + : `单轮增量 ≥ ${(r.threshold_volume ?? 9000).toLocaleString()} 手`} + + + 冷却 {Math.round((r.cooldown_seconds ?? 300) / 60)} 分钟 + + {r.basic_filter && Object.values(r.basic_filter).some(v => v !== null && v !== false) && ( + + 基础过滤{r.basic_filter.exclude_st ? ' · 剔除ST' : ''} + + )} +
) : r.type === 'strategy' && r.strategy_id ? (
{(r.score_min != null || r.score_max != null) && ( From 5e2358ce486a1f402be57bfd99a3933e89c074f4 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:05 +0800 Subject: [PATCH 02/51] =?UTF-8?q?feat(minute):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=89=A7=E8=A1=8C=E5=90=8E=E7=AB=AF=20+=20?= =?UTF-8?q?=E5=88=86=E9=92=9F=E7=BA=A27=E7=AD=96=E7=95=A5=20+=20=E7=9B=98?= =?UTF-8?q?=E4=B8=AD=E5=A2=9E=E9=87=8F=E8=90=BD=E7=9B=98=20(Expert)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一期 · 分钟策略执行链路: - 引擎新增 minute_filter 执行后端: 策略声明 filter_minute_history(df, params), timeframes 必须且仅为 ["1m"]; 输入为当日分钟K窗口, 命中行事后联表 enriched 快照补基础过滤列 (name/total_shares/change_pct), close 用最新分钟价 - 内置策略「分钟红7」(minute_red_streak): 最近 N 根(默认7)分钟K至少 5 根 close>open, 且按最高价排序的最高的 2 根全红; 全向量化, 671k 行约 230ms; 参数 bars/min_red/top_red/rank_by_close, 不足 N 根不触发, 同值取更晚K线 - ScreenerService 1m context: 优先读 as_of 当日 kline_minute 分区, 缺失回退 全市场最近分区; 单分区直读与全量 glob 解耦 - run_preset/run_all 分钟周期结果不写日线盘后缓存 (语义隔离) 二期 · 盘中分钟增量落盘 (Expert 专有): - kline_sync.fetch_intraday_full_market_burst: intraday.batch 独立限流池, 全市场 5546/200=28 块线程池一次打出, 轮内不重试 - MinuteRefreshService: 后台线程, 门控链 = 开关→自定义分钟源让位→INTRADAY_BATCH 能力→连续竞价时段(9:30-11:30/13:00-15:00); 固定节奏 next=max(起点+间隔,完成), 不补跑; 每轮单次合并落盘 (_write_minute_partition unique 幂等) - 偏好 minute_refresh_enabled(默认关)/minute_refresh_interval([60,300]s 默认60); GET /api/settings/minute-refresh/status 状态端点 前端: - 策略页日线/分钟周期切换 (1m 下 ETF 置灰、不触发盘后 runAll、prune 仅日线), 策略卡片「分钟」徽章, 策略池对话框按周期拉取 - 数据页分钟K设置弹窗新增盘中增量区块: 开关/间隔(60-300s)/能力缺失置灰/ 服务状态行(运行中·时段暂停·最近一轮) 测试: 26 项新增 (形态7/引擎4/context4/服务11), 更新 3 个 matrix 不变量测试; 全量 1112 passed; 前端 build 通过; 浏览器端到端实测通过 --- backend/app/api/screener.py | 9 +- backend/app/api/settings.py | 21 ++ backend/app/main.py | 13 + backend/app/services/kline_sync.py | 46 +++ backend/app/services/minute_refresh.py | 238 ++++++++++++++ backend/app/services/preferences.py | 19 ++ backend/app/services/screener.py | 38 +++ .../app/strategy/builtin/minute_red_streak.py | 110 +++++++ backend/app/strategy/engine.py | 67 +++- .../tests/backtest/test_matrix_strategy.py | 11 +- backend/tests/test_minute_refresh.py | 198 +++++++++++ backend/tests/test_minute_strategy.py | 308 ++++++++++++++++++ backend/tests/test_screener_etf.py | 25 +- .../src/components/data/MinuteSyncConfig.tsx | 111 ++++++- .../src/components/screener/StrategyCard.tsx | 10 +- .../screener/StrategyPoolDialog.tsx | 6 +- frontend/src/lib/api.ts | 37 ++- frontend/src/pages/Screener.tsx | 66 +++- 18 files changed, 1289 insertions(+), 44 deletions(-) create mode 100644 backend/app/services/minute_refresh.py create mode 100644 backend/app/strategy/builtin/minute_red_streak.py create mode 100644 backend/tests/test_minute_refresh.py create mode 100644 backend/tests/test_minute_strategy.py diff --git a/backend/app/api/screener.py b/backend/app/api/screener.py index 784d105..fe6af72 100644 --- a/backend/app/api/screener.py +++ b/backend/app/api/screener.py @@ -317,7 +317,10 @@ def run_preset(req: PresetRequest, request: Request): raise HTTPException(status_code=status_code, detail=str(e)) from e safe_data = _safe(asdict(result)) - _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data) + # 分钟周期结果不写入盘后缓存 (strategy_cache 是日线语义, as_of/updated_at + # 混入分钟结果会污染页面秒加载路径)。 + if req.timeframe == "1d": + _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data) return _result_with_ext(safe_data, ext_values) @@ -583,8 +586,8 @@ def run_all(request: Request, body: Optional[dict] = None): elapsed = (time.perf_counter() - t_total) * 1000 logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids)) - # 写入策略缓存 (供页面秒加载) - if results: + # 写入策略缓存 (供页面秒加载); 分钟周期结果不落盘 (日线语义缓存) + if results and timeframe == "1d": try: strategy_cache.write_cache(data_dir, str(as_of), results) except Exception: # noqa: BLE001 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c10009e..b124b3b 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -391,6 +391,10 @@ class MinuteSyncPrefs(BaseModel): minute_sync_days: int = 5 # 单段大小(交易日),None 表示不修改现有值。范围 [5, 30],默认 20。 minute_sync_segment_days: int | None = None + # 盘中分钟增量刷新 (Expert 专有)。None 表示不修改现有值。 + minute_refresh_enabled: bool | None = None + # 刷新间隔(秒),范围 [60, 300]。None 表示不修改现有值。 + minute_refresh_interval: int | None = None class DataProvidersIn(BaseModel): @@ -478,6 +482,8 @@ def get_preferences() -> dict: "minute_sync_enabled": preferences.get_minute_sync_enabled(), "minute_sync_days": preferences.get_minute_sync_days(), "minute_sync_segment_days": preferences.get_minute_sync_segment_days(), + "minute_refresh_enabled": preferences.get_minute_refresh_enabled(), + "minute_refresh_interval": preferences.get_minute_refresh_interval(), "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), "minute_data_provider": preferences.get_minute_data_provider(), @@ -833,14 +839,29 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict: } if req.minute_sync_segment_days is not None: updates["minute_sync_segment_days"] = max(5, min(30, req.minute_sync_segment_days)) + if req.minute_refresh_enabled is not None: + updates["minute_refresh_enabled"] = req.minute_refresh_enabled + if req.minute_refresh_interval is not None: + updates["minute_refresh_interval"] = max(60, min(300, req.minute_refresh_interval)) preferences.save(updates) return { "minute_sync_enabled": req.minute_sync_enabled, "minute_sync_days": days, "minute_sync_segment_days": preferences.get_minute_sync_segment_days(), + "minute_refresh_enabled": preferences.get_minute_refresh_enabled(), + "minute_refresh_interval": preferences.get_minute_refresh_interval(), } +@router.get("/minute-refresh/status") +def minute_refresh_status(request: Request) -> dict: + """盘中分钟增量刷新服务状态 (开关/能力门控/最近一轮/下一轮)。""" + svc = getattr(request.app.state, "minute_refresh", None) + if svc is None: + return {"available": False} + return {"available": True, **svc.status()} + + class RealtimeQuotesPrefs(BaseModel): realtime_quotes_enabled: bool diff --git a/backend/app/main.py b/backend/app/main.py index a65e130..801fa96 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -173,6 +173,16 @@ async def _application_lifespan(app: FastAPI): except Exception as e: # noqa: BLE001 logger.warning("depth_service init failed: %s", e) + # 盘中分钟增量刷新 (Expert 专有): 线程常驻, 开关/时段/能力门控在循环内每轮判断 + try: + from app.services.minute_refresh import MinuteRefreshService + minute_refresh = MinuteRefreshService(repo) + minute_refresh.set_app_state(app.state) + app.state.minute_refresh = minute_refresh + minute_refresh.start() + except Exception as e: + logger.warning("minute_refresh init failed: %s", e) + # 停机缺口自检: 延迟后台扫描, 发现最近交易日的盘中快照/缺口时自动创建 # 修复任务 (盘中停机→次日开实时场景, 不修则坏数据被"只刷今天"分支永久留存) try: @@ -356,6 +366,9 @@ async def _application_lifespan(app: FastAPI): wbot = getattr(app.state, "wecom_bot_service", None) if wbot: wbot.stop() + mrs = getattr(app.state, "minute_refresh", None) + if mrs: + mrs.stop() logger.info("shutdown") diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index 46789c1..2350141 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -828,6 +828,52 @@ def fetch_intraday_monitor_batch( return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame() +def fetch_intraday_full_market_burst( + symbols: list[str], + capset: CapabilitySet | None, + *, + count: int = 300, +) -> tuple[pl.DataFrame, int]: + """全市场当日分钟K并发脉冲拉取 (盘中增量刷新专用, 不落盘)。 + + 与 fetch_intraday_monitor_batch 的区别: + - 监控路径每轮只拉少量标的 (≤ batch 上限, 单请求); + 本函数按 batch_size 把全市场切块后用线程池一次全部打出 + (5546/200 = 28 并发), 配合 >=60s 的固定轮节奏, 任何 60s + 滑动窗口至多一个脉冲 (28 < 48 安全 rpm), 轮内失败不重试。 + + 限流口径: 只用 intraday.batch 独立池 (Cap.INTRADAY_BATCH, Expert 专有), + 不与 kline.minute.batch (盘后分钟同步) 共享配额。 + 返回 (当日全市场分钟K, 请求数)。 + """ + if not symbols: + return (pl.DataFrame(), 0) + limits = capset.limits(Cap.INTRADAY_BATCH) if capset and capset.has(Cap.INTRADAY_BATCH) else None + batch_size = max(1, int(limits.batch) if limits and limits.batch else 200) + chunks = list(chunked(symbols, batch_size)) + if not chunks: + return (pl.DataFrame(), 0) + + from concurrent.futures import ThreadPoolExecutor + + tf = get_client() + + def _fetch(chunk: list[str]) -> list[pl.DataFrame]: + raw = tf.klines.intraday_batch( + chunk, count=count, as_dataframe=True, show_progress=False, + batch_size=len(chunk), + ) + return _normalize_intraday_raw(raw) + + frames: list[pl.DataFrame] = [] + with ThreadPoolExecutor(max_workers=min(len(chunks), 32)) as pool: + for result in pool.map(_fetch, chunks): + frames.extend(result) + if not frames: + return (pl.DataFrame(), len(chunks)) + return (pl.concat(frames, how="diagonal_relaxed"), len(chunks)) + + def fetch_minute_single( symbol: str, trade_date: date, diff --git a/backend/app/services/minute_refresh.py b/backend/app/services/minute_refresh.py new file mode 100644 index 0000000..43035de --- /dev/null +++ b/backend/app/services/minute_refresh.py @@ -0,0 +1,238 @@ +"""盘中分钟K增量落盘服务 (Expert 专有)。 + +每轮用 intraday.batch (日内分时批量, 独立限流池) 并发脉冲拉全市场当日分钟K, +单次合并写入当日 kline_minute 分区, 供分钟策略 (minute_filter) 读到新鲜数据。 + +设计约束 (见 feat/minute-strategy 方案): +- Expert 专有: 能力门控 Cap.INTRADAY_BATCH — 该能力仅 Expert 档具备, 天然排他。 +- 并发脉冲: 全市场按 batch_size 分块 (5546/200 = 28 块), ThreadPoolExecutor 一次 + 打出全部块 (≤28 并发)。任何 60s 滑动窗口至多一个脉冲 (28 < 48 安全 rpm)。 +- 固定节奏: 默认 60s 一轮 (clamp [60, 300]), 下一轮 = max(本轮起点+间隔, 上轮完成), + 不补跑 (missed 轮次直接跳过), 轮内失败不重试。 +- 仅连续竞价时段运行 (9:30-11:30 / 13:00-15:00), 午休/收盘自动暂停与恢复。 +- 不与其他分钟能力冲突: 与 盘后分钟同步 (kline.minute.batch) / 分时监控路径 + (fetch_intraday_monitor_batch) 分属不同限流池; 落盘走 _write_minute_partition + 的 unique(symbol,datetime) 合并, 与盘后同步写同一分区安全幂等。 +- 数据源插件化让位: 配置了自定义分钟源 (minute_data_provider != tickflow) 时 + 服务不启动 — 盘中增量交由插件自管, 本服务不抢占。 + +分层: 本模块只做调度/落盘/状态; TickFlow SDK 调用全部在 kline_sync 边界层 +(fetch_intraday_full_market_burst), 保持插件化边界不泄漏。 +""" +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from datetime import time as dt_time +from typing import Any + +import polars as pl + +from app.market_time import cn_now +from app.services import preferences + +# 轮询间隔允许范围 (秒): 下限 60s 保证任何滑动窗口 ≤1 个脉冲, 上限防误配。 +REFRESH_INTERVAL_MIN = 60 +REFRESH_INTERVAL_MAX = 300 +# 等待步长 (秒): 循环小步睡眠, 便于快速停止与偏好热生效。 +_LOOP_STEP_S = 2.0 + + +def _in_continuous_session(now=None) -> bool: + """A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。""" + now = now or cn_now() + t = now.time() + morning = dt_time(9, 30) <= t <= dt_time(11, 30) + afternoon = dt_time(13, 0) <= t <= dt_time(15, 0) + return now.weekday() < 5 and (morning or afternoon) + + +@dataclass +class _RefreshState: + """服务运行状态 (status() 的内存镜像, 循环线程内更新)。""" + + rounds: int = 0 + last_round_at: float | None = None # epoch 秒 + last_round_ms: float | None = None # 单轮耗时 + last_rows: int = 0 # 上轮写入行数 (合并后) + last_symbols: int = 0 # 上轮覆盖标的数 + last_requests: int = 0 # 上轮请求数 (分块数) + last_error: str | None = None + next_round_at: float | None = None # epoch 秒 + extra: dict[str, Any] = field(default_factory=dict) + + +class MinuteRefreshService: + """盘中分钟增量刷新: 单实例挂 app.state.minute_refresh, 后台守护线程。""" + + def __init__(self, repo) -> None: + self._repo = repo + self._app_state: Any | None = None + self._thread: threading.Thread | None = None + self._stop = threading.Event() + self._state = _RefreshState() + self._round_lock = threading.Lock() # 同时只允许一轮 (手动触发与定时轮互斥) + + # ------------------------------------------------------------------ + # 生命周期 + # ------------------------------------------------------------------ + + def set_repo(self, repo) -> None: + self._repo = repo + + def set_app_state(self, app_state: Any) -> None: + self._app_state = app_state + + def start(self) -> bool: + """启动后台线程 (幂等)。开关/时段/能力判断都在循环内每轮做, 热生效。""" + if self._thread is not None and self._thread.is_alive(): + return True + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, name="minute-refresh", daemon=True, + ) + self._thread.start() + return True + + def stop(self) -> None: + self._stop.set() + + # ------------------------------------------------------------------ + # 门控 + # ------------------------------------------------------------------ + + def capability_ok(self) -> bool: + """Cap.INTRADAY_BATCH 存在 (Expert)。能力探测结果缓存在 app.state。""" + capset = getattr(self._app_state, "capabilities", None) if self._app_state else None + if capset is None: + return False + try: + from app.tickflow.capabilities import Cap + + return capset.has(Cap.INTRADAY_BATCH) + except Exception: + return False + + def custom_provider_active(self) -> bool: + """配置了自定义分钟源 → 让位插件, 本服务不启动。""" + try: + return preferences.get_minute_data_provider() != "tickflow" + except Exception: + return False + + def _gate_reason(self) -> str | None: + """返回本轮不执行的原因 (None = 放行)。""" + if not preferences.get_minute_refresh_enabled(): + return "disabled" + if self.custom_provider_active(): + return "custom_minute_provider" + if not self.capability_ok(): + return "capability" + if not _in_continuous_session(): + return "outside_trading_hours" + return None + + # ------------------------------------------------------------------ + # 主循环 + # ------------------------------------------------------------------ + + def _loop(self) -> None: + while not self._stop.is_set(): + try: + reason = self._gate_reason() + if reason is None: + interval = preferences.get_minute_refresh_interval() + started = time.time() + self._run_round() + # 固定节奏: 下一轮 = max(本轮起点+间隔, 本轮完成), 不补跑 + finish = time.time() + self._state.next_round_at = max(started + interval, finish) + # 等到下一轮 (小步睡眠保持可停/偏好热切换) + while not self._stop.is_set(): + now = time.time() + gate = self._gate_reason() + if gate is not None: + self._state.next_round_at = None + break # 门控关闭 → 回外层等待重评估 + if now >= self._state.next_round_at: + break + self._stop.wait(min(_LOOP_STEP_S, max(0.0, self._state.next_round_at - now))) + continue + except Exception as e: + self._state.last_error = f"round failed: {e}" + self._stop.wait(_LOOP_STEP_S) + + # ------------------------------------------------------------------ + # 单轮 + # ------------------------------------------------------------------ + + def _run_round(self) -> None: + from app.services import kline_sync + + t0 = time.perf_counter() + symbols = self._universe() + self._state.last_symbols = len(symbols) + if not symbols: + self._state.last_error = "empty universe (instruments 未加载)" + return + + capset = getattr(self._app_state, "capabilities", None) if self._app_state else None + with self._round_lock: + df, requests = kline_sync.fetch_intraday_full_market_burst(symbols, capset) + self._state.last_requests = requests + if df.is_empty(): + self._state.last_error = "intraday burst returned no data" + return + written = kline_sync._write_minute_partition( + df, self._repo.store.data_dir / "kline_minute", + ) + + self._state.rounds += 1 + self._state.last_round_at = time.time() + self._state.last_round_ms = (time.perf_counter() - t0) * 1000 + self._state.last_rows = written + self._state.last_error = None + + def _universe(self) -> list[str]: + """全市场 A 股标的 (instruments 维表, 与盘后分钟同步同一来源)。""" + inst = self._repo.get_instruments() + if inst.is_empty() or "symbol" not in inst.columns: + return [] + return inst["symbol"].cast(pl.Utf8).unique().sort().to_list() + + # ------------------------------------------------------------------ + # 状态 + # ------------------------------------------------------------------ + + def status(self) -> dict[str, Any]: + import contextlib + + with contextlib.suppress(Exception): + enabled = preferences.get_minute_refresh_enabled() + running = self._thread is not None and self._thread.is_alive() + gate = self._gate_reason() + return { + "enabled": enabled, + "running": running, + "interval_seconds": preferences.get_minute_refresh_interval(), + "capability_ok": self.capability_ok(), + "custom_provider_active": self.custom_provider_active(), + "in_trading_hours": _in_continuous_session(), + "gate_reason": gate if (enabled and running) else (gate or "disabled"), + "rounds": self._state.rounds, + "last_round_at": self._state.last_round_at, + "last_round_ms": self._state.last_round_ms, + "last_rows": self._state.last_rows, + "last_symbols": self._state.last_symbols, + "last_requests": self._state.last_requests, + "next_round_at": self._state.next_round_at, + "last_error": self._state.last_error, + } + + def trigger_manual_round(self) -> dict[str, Any]: + """手动触发一轮 (无视时段门控, 但仍受能力/插件门控); 供状态页「立即刷新」。""" + if self.custom_provider_active() or not self.capability_ok(): + return {"ok": False, "reason": self._gate_reason() or "capability"} + threading.Thread(target=self._run_round, daemon=True, name="minute-refresh-manual").start() + return {"ok": True} diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index f5470ff..184c576 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -214,6 +214,25 @@ def get_minute_sync_segment_days() -> int: """ return max(5, min(30, load().get("minute_sync_segment_days", 20))) +# ===== 盘中分钟增量刷新 (Expert 专有, intraday.batch 独立限流池) ===== + +# 下限 60s: 保证任何 60s 滑动窗口至多一个全市场脉冲 (28 并发 < 48 安全 rpm)。 +_MINUTE_REFRESH_INTERVAL_MIN = 60 +_MINUTE_REFRESH_INTERVAL_MAX = 300 + + +def get_minute_refresh_enabled() -> bool: + """盘中分钟K增量落盘开关。默认关闭; 能力门控 (Expert) 在服务层判断。""" + return bool(load().get("minute_refresh_enabled", False)) + + +def get_minute_refresh_interval() -> int: + """盘中分钟增量刷新间隔(秒)。默认 60,范围 [60, 300]。""" + return max( + _MINUTE_REFRESH_INTERVAL_MIN, + min(_MINUTE_REFRESH_INTERVAL_MAX, int(load().get("minute_refresh_interval", 60))), + ) + # ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) ===== diff --git a/backend/app/services/screener.py b/backend/app/services/screener.py index 1e62e29..134cc90 100644 --- a/backend/app/services/screener.py +++ b/backend/app/services/screener.py @@ -386,6 +386,19 @@ class ScreenerService: if current is None: current = self._load_enriched_for_date(as_of) + if timeframe == "1m": + # 分钟策略数据源是本地当日分钟K分区 (单分区文件直读), 与日线 + # enriched 历史窗口无关, 不走 required_history_bars 日线路径。 + history = self._load_minute_history(as_of, current) + return StrategyDataContext( + asset_type=self.asset_type, + timeframe=timeframe, + as_of=as_of, + current=current, + history=history, + market=None, + cache_key=cache_key, + ) history_bars = engine.required_history_bars( strategy_ids, params_map=params_map, @@ -404,6 +417,31 @@ class ScreenerService: cache_key=cache_key, ) + def _load_minute_history(self, as_of: date, current: pl.DataFrame | None) -> pl.DataFrame: + """分钟策略数据源: 优先 as_of 当日分钟分区, 缺失时回退全市场最近分区。 + + 只按日期直读单个分区文件 (get_minute_by_dates), 与全量 glob 扫描解耦, + 内存只随当日分区大小 (~67万行) 走。标的池限定为 enriched 快照 universe; + 分区与快照的日期差是允许的 (分钟分区可能比 enriched 更新, 行自带时间戳)。 + """ + if self.asset_type != "stock": + raise ValueError("分钟策略当前仅支持 A 股") + symbols: list[str] = [] + if current is not None and not current.is_empty(): + symbols = current["symbol"].cast(pl.Utf8).unique().to_list() + if not symbols: + return pl.DataFrame() + df = self.repo.get_minute_by_dates(symbols, [as_of]) + if df.is_empty(): + fallback = self.repo.latest_minute_date_global() + if fallback is None: + raise ValueError( + "无分钟K数据 — 请先在 数据→分钟K 完成同步, 或开启盘中增量刷新" + ) + if fallback != as_of: + df = self.repo.get_minute_by_dates(symbols, [fallback]) + return df + def latest_date(self) -> date | None: if self.asset_type != "stock": _, d = self.repo.get_enriched_latest_asset(self.asset_type) diff --git a/backend/app/strategy/builtin/minute_red_streak.py b/backend/app/strategy/builtin/minute_red_streak.py new file mode 100644 index 0000000..4de22b6 --- /dev/null +++ b/backend/app/strategy/builtin/minute_red_streak.py @@ -0,0 +1,110 @@ +"""分钟红7 — 最近 N 根分钟K多数收红, 且最高的 top_red 根全红。 + +数据契约: filter_minute_history 接收当日全市场分钟K窗口 +(symbol, datetime, open, high, low, close, volume, amount), +由 ScreenerService.build_strategy_context 的 1m 分支从本地 kline_minute +分区注入; 策略本身不感知数据来源 (本地同步 / 盘中增量刷新对它透明)。 +""" + +import polars as pl + +META = { + "id": "minute_red_streak", + "name": "分钟红7", + "description": "最近7根1分钟K至少5根收红, 且最高的2根(按最高价)都是红K", + "tags": ["分钟", "形态", "短线"], + "asset_types": ["stock"], + "timeframes": ["1m"], + "params": [ + { + "id": "bars", + "label": "检查K线数", + "type": "int", + "default": 7, + "min": 5, + "max": 15, + "step": 1, + }, + { + "id": "min_red", + "label": "最少红K数", + "type": "int", + "default": 5, + "min": 1, + "max": 15, + "step": 1, + }, + { + "id": "top_red", + "label": "最高K需红数", + "type": "int", + "default": 2, + "min": 1, + "max": 3, + "step": 1, + }, + { + "id": "rank_by_close", + "label": "最高K按收盘价排序", + "type": "bool", + "default": False, + }, + ], + "order_by": "red_count", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "minute_filter" +ENTRY_SIGNALS: list[str] = [] +EXIT_SIGNALS: list[str] = [] + + +def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: + """红K形态过滤: 全向量化, 无逐行 Python 循环。 + + - 每标的按时间取最近 bars 根; 不足 bars 根不触发 + - 红 = close > open; 窗口内红K数 >= min_red + - 按 rank_by (high / close) 降序取前 top_red 根, 同值取时间更晚者, 需全红 + """ + bars = int(params.get("bars") or 7) + min_red = min(int(params.get("min_red") or 5), bars) + top_red = min(int(params.get("top_red") or 2), bars) + rank_by = "close" if params.get("rank_by_close") else "high" + if rank_by not in df.columns: + rank_by = "high" + + tailed = ( + df.sort(["symbol", "datetime"]) + .filter(pl.int_range(pl.len()).over("symbol") >= pl.len().over("symbol") - bars) + .with_columns(_red=(pl.col("close") > pl.col("open")).cast(pl.Int32)) + ) + + window = tailed.group_by("symbol").agg( + bars_checked=pl.len(), + red_count=pl.col("_red").sum(), + last_datetime=pl.col("datetime").max(), + # 输出列名用 close: 基础过滤的股价区间直接作用于最新分钟价 + close=pl.col("close").sort_by("datetime").last(), + window_high=pl.col("high").max(), + window_low=pl.col("low").min(), + window_volume=pl.col("volume").sum(), + window_amount=pl.col("amount").sum(), + ) + + top = ( + tailed.sort([rank_by, "datetime"], descending=[True, True]) + .filter(pl.int_range(pl.len()).over("symbol") < top_red) + .group_by("symbol") + .agg(top_red_count=pl.col("_red").sum()) + ) + + return ( + window.join(top, on="symbol", how="inner") + .filter( + (pl.col("bars_checked") >= bars) + & (pl.col("red_count") >= min_red) + & (pl.col("top_red_count") >= top_red) + ) + .drop("bars_checked") + ) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index aa7e581..42dd140 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -199,6 +199,8 @@ class StrategyDef: execution_backend: str = "polars_expr" matrix_strategy: Any | None = None composite: CompositeSpec | None = None # 仅 backend=="composite" 时非空 + # 仅 backend=="minute_filter" 时非空: 输入为当日分钟K窗口, 输出为命中标的行 + filter_minute_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None = None @dataclass @@ -471,6 +473,7 @@ class StrategyEngine: filter_fn = getattr(mod, "filter", None) filter_history_fn = getattr(mod, "filter_history", None) + filter_minute_history_fn = getattr(mod, "filter_minute_history", None) execution_backend = str( getattr( mod, @@ -481,7 +484,7 @@ class StrategyEngine: ), ) ) - valid_backends = {"polars_expr", "matrix_native", "python_history_legacy", "composite"} + valid_backends = {"polars_expr", "matrix_native", "python_history_legacy", "composite", "minute_filter"} if execution_backend not in valid_backends: raise ValueError( f"unsupported execution backend {execution_backend!r}; " @@ -515,6 +518,23 @@ class StrategyEngine: "composite strategy must not declare filter, filter_history or MATRIX_STRATEGY" ) composite_spec = _parse_composite_children(meta.get("children")) + elif execution_backend == "minute_filter": + # 分钟形态策略: 只声明 filter_minute_history; 数据源是本地当日分钟K分区 + # (由 ScreenerService.build_strategy_context 的 1m 分支注入), 因此 timeframes + # 必须且只能是 ["1m"] — 混入 1d 会让日线 context 走错数据路径。 + if ( + filter_minute_history_fn is None + or filter_fn is not None + or filter_history_fn is not None + or matrix_strategy is not None + ): + raise ValueError( + "minute_filter strategy must declare only filter_minute_history" + ) + if meta.get("timeframes") != ["1m"]: + raise ValueError( + "minute_filter strategy must declare timeframes == ['1m']" + ) elif filter_history_fn is None or filter_fn is not None: raise ValueError("python_history_legacy strategy must declare only filter_history") @@ -538,6 +558,7 @@ class StrategyEngine: execution_backend=execution_backend, matrix_strategy=matrix_strategy, composite=composite_spec, + filter_minute_history_fn=filter_minute_history_fn, ) def reload(self) -> None: @@ -885,7 +906,23 @@ class StrategyEngine: exit_signal_hits = self._collect_signal_hits(signal_df, exit_signals) # 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。 - if s.filter_history_fn: + if s.execution_backend == "minute_filter": + # 分钟策略: 读取调用方注入的当日分钟K窗口。无 date 列, 不按 as_of 过滤, + # 每个命中行自带最后K线时间戳 (last_datetime)。 + if history is None: + raise ValueError(f"strategy {strategy_id} requires minute history data") + if history.is_empty(): + return StrategyResult( + as_of=as_of, + strategy_id=strategy_id, + exit_signal_hits=exit_signal_hits, + ) + df = s.filter_minute_history_fn(history, params) + # 基础过滤/展示列 (name/total_shares/change_pct 等) 来自 enriched 快照, + # 在命中结果上事后联表, 避免把 enriched 列铺到全市场分钟行上。 + if current is not None and not current.is_empty(): + df = self._join_basic_columns(df, current) + elif s.filter_history_fn: if history is None: raise ValueError(f"strategy {strategy_id} requires history data") df = history @@ -945,7 +982,9 @@ class StrategyEngine: # Stage 3: 评分 df = self._apply_scoring(df, scoring, scoring_directions) entry_signal_hits = self._collect_signal_hits(df, entry_signals) - if not entry_signals and (s.filter_history_fn or s.filter_fn): + if not entry_signals and ( + s.filter_history_fn or s.filter_fn or s.execution_backend == "minute_filter" + ): entry_signal_hits = [ {"symbol": str(symbol), "signals": []} for symbol in df["symbol"].cast(pl.Utf8).unique().to_list() @@ -1036,7 +1075,8 @@ class StrategyEngine: history_strats = [ (sid, strategy) for sid, strategy in selected - if strategy.filter_history_fn or strategy.execution_backend == "matrix_native" + if strategy.filter_history_fn + or strategy.execution_backend in ("matrix_native", "minute_filter") ] shared_history = context.history if history_strats and shared_history is None: @@ -1466,6 +1506,25 @@ class StrategyEngine: return df.filter(expr) return df + # 分钟策略命中行需要从事后联表补齐的 enriched 列: 基础过滤引用 + 前端展示。 + # close 不在列 — 分钟策略输出的 close 是最后一根分钟K收盘价, 优先于日线快照。 + MINUTE_JOIN_COLUMNS: tuple[str, ...] = ( + "name", "total_shares", "float_shares", "amount", + "turnover_rate", "change_pct", "pre_close", + ) + + @staticmethod + def _join_basic_columns(df: pl.DataFrame, current: pl.DataFrame) -> pl.DataFrame: + """把 enriched 快照列按 symbol 联到分钟策略输出上, 只补 df 缺失的列。""" + cols = [ + c for c in StrategyEngine.MINUTE_JOIN_COLUMNS + if c in current.columns and c not in df.columns + ] + if not cols: + return df + extra = current.select(["symbol", *cols]).unique(subset=["symbol"], keep="last") + return df.join(extra, on="symbol", how="left") + # ================================================================ # 内部: 评分 # ================================================================ diff --git a/backend/tests/backtest/test_matrix_strategy.py b/backend/tests/backtest/test_matrix_strategy.py index c1db050..0f8defd 100644 --- a/backend/tests/backtest/test_matrix_strategy.py +++ b/backend/tests/backtest/test_matrix_strategy.py @@ -319,9 +319,13 @@ def test_builtin_matrix_strategies_use_their_declared_formula_modules(): path for path in strategy_dir.glob("*.py") if path.name != "__init__.py" ) - assert len(strategy_files) == 19 + # 非 matrix 后端的内置策略白名单 (当前仅分钟形态策略) + non_matrix = {"minute_red_streak"} + assert len(strategy_files) == 19 + len(non_matrix) for strategy_path in strategy_files: strategy = StrategyEngine._load_file(strategy_path) + if strategy_path.stem in non_matrix: + continue assert strategy.execution_backend == "matrix_native" assert strategy.matrix_strategy is not None assert strategy.matrix_strategy.__class__.__module__ == strategy_path.stem @@ -784,7 +788,10 @@ def test_registered_builtin_matrix_strategies_share_one_cache_profile(): strategy_dirs=[REPO_ROOT / "backend" / "app" / "strategy" / "builtin"] ) profile = build_matrix_cache_profile(engine, "stock") - strategies = engine.strategy_definitions() + strategies = tuple( + s for s in engine.strategy_definitions() + if s.execution_backend != "minute_filter" + ) assert len(strategies) == 19 assert all(strategy.execution_backend == "matrix_native" for strategy in strategies) diff --git a/backend/tests/test_minute_refresh.py b/backend/tests/test_minute_refresh.py new file mode 100644 index 0000000..21e5cd1 --- /dev/null +++ b/backend/tests/test_minute_refresh.py @@ -0,0 +1,198 @@ +"""盘中分钟增量刷新服务 (minute_refresh) 测试。 + +覆盖: +- 连续竞价时段判定 (含边界) +- 门控链: 开关关闭 / 自定义分钟源让位 / 能力缺失 / 时段外 / 放行 +- 单轮: mock 边界层脉冲 + 落盘, 校验状态字段与 universe 来源 +- 偏好读写: 默认关闭、间隔 clamp [60, 300] +- API: /minute-refresh/status 无服务时 available=false + +不发起真实网络请求: fetch_intraday_full_market_burst 与 _write_minute_partition +均 monkeypatch 替换。 +""" +from __future__ import annotations + +from datetime import datetime + +import polars as pl + +from app.services import minute_refresh, preferences +from app.services.minute_refresh import MinuteRefreshService, _in_continuous_session + + +def _isolated_prefs(tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + preferences._invalidate_cache() + return path + + +class _FakeCapSet: + def __init__(self, has_intraday_batch: bool): + self._has = has_intraday_batch + + def has(self, cap) -> bool: + from app.tickflow.capabilities import Cap + + return self._has and cap == Cap.INTRADAY_BATCH + + +class _FakeAppState: + def __init__(self, has_intraday_batch: bool): + self.capabilities = _FakeCapSet(has_intraday_batch) + + +class _FakeRepo: + def __init__(self, symbols: list[str]): + from pathlib import Path + self._inst = pl.DataFrame({"symbol": symbols}) + self.store = type("S", (), {"data_dir": Path(".")})() + + def get_instruments(self) -> pl.DataFrame: + return self._inst + + +# ── 时段判定 ──────────────────────────────────────────────────────── + + +def test_continuous_session_boundaries(): + wk = datetime(2026, 8, 25, 10, 0) # 周二 + assert _in_continuous_session(wk) + assert not _in_continuous_session(datetime(2026, 8, 25, 9, 29)) + assert not _in_continuous_session(datetime(2026, 8, 25, 11, 31)) # 午休 + assert _in_continuous_session(datetime(2026, 8, 25, 13, 0)) # 午后恢复 + assert _in_continuous_session(datetime(2026, 8, 25, 15, 0)) # 收盘瞬时 + assert not _in_continuous_session(datetime(2026, 8, 25, 15, 1)) + assert not _in_continuous_session(datetime(2026, 8, 22, 10, 0)) # 周六 + + +# ── 门控链 ────────────────────────────────────────────────────────── + + +def _svc(tmp_path, monkeypatch, *, enabled=True, custom_provider=False, capability=True, in_hours=True): + _isolated_prefs(tmp_path, monkeypatch) + preferences.save({"minute_refresh_enabled": enabled}) + if custom_provider: + # 模拟已注册的自定义分钟源 (真实注册表在测试环境未加载) + monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: "a-stock-data") + svc = MinuteRefreshService(_FakeRepo(["600000.SH"])) + svc.set_app_state(_FakeAppState(capability)) + monkeypatch.setattr(minute_refresh, "_in_continuous_session", lambda now=None: in_hours) + return svc + + +def test_gate_disabled(tmp_path, monkeypatch): + assert _svc(tmp_path, monkeypatch, enabled=False)._gate_reason() == "disabled" + + +def test_gate_custom_provider_yields(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch, custom_provider=True) + assert svc._gate_reason() == "custom_minute_provider" + + +def test_gate_capability_missing(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch, capability=False) + assert svc._gate_reason() == "capability" + + +def test_gate_outside_trading_hours(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch, in_hours=False) + assert svc._gate_reason() == "outside_trading_hours" + + +def test_gate_pass(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch) + assert svc._gate_reason() is None + assert svc.capability_ok() and not svc.custom_provider_active() + + +# ── 单轮 ──────────────────────────────────────────────────────────── + + +def test_run_round_writes_partition_and_updates_status(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch) + minute_df = pl.DataFrame({ + "symbol": ["600000.SH"], + "datetime": [datetime(2026, 8, 25, 1, 30)], + "open": [10.0], "high": [10.5], "low": [9.9], "close": [10.2], + "volume": [1000.0], "amount": [10200.0], + }) + calls: dict = {} + + def fake_burst(symbols, capset, *, count=300): + calls["symbols"] = list(symbols) + return (minute_df, 1) + + def fake_write(df, minute_dir): + calls["dir"] = minute_dir + calls["rows"] = df.height + return df.height + + monkeypatch.setattr( + "app.services.kline_sync.fetch_intraday_full_market_burst", fake_burst + ) + monkeypatch.setattr("app.services.kline_sync._write_minute_partition", fake_write) + + svc._run_round() + + assert calls["symbols"] == ["600000.SH"] + assert calls["rows"] == 1 + st = svc.status() + assert st["rounds"] == 1 + assert st["last_rows"] == 1 + assert st["last_symbols"] == 1 + assert st["last_requests"] == 1 + assert st["last_round_at"] is not None + assert st["last_error"] is None + assert st["capability_ok"] is True + + +def test_run_round_records_error_when_burst_empty(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch) + monkeypatch.setattr( + "app.services.kline_sync.fetch_intraday_full_market_burst", + lambda symbols, capset, *, count=300: (pl.DataFrame(), 3), + ) + svc._run_round() + st = svc.status() + assert st["rounds"] == 0 + assert "no data" in st["last_error"] + assert st["last_requests"] == 3 + + +def test_status_reports_gate_reason_when_stopped(tmp_path, monkeypatch): + svc = _svc(tmp_path, monkeypatch, enabled=False) + st = svc.status() + assert st["enabled"] is False + assert st["running"] is False + assert st["gate_reason"] == "disabled" + assert st["interval_seconds"] == 60 + + +# ── 偏好 ──────────────────────────────────────────────────────────── + + +def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch): + _isolated_prefs(tmp_path, monkeypatch) + assert preferences.get_minute_refresh_enabled() is False + assert preferences.get_minute_refresh_interval() == 60 + preferences.save({"minute_refresh_interval": 5}) + assert preferences.get_minute_refresh_interval() == 60 # 下限 + preferences.save({"minute_refresh_interval": 999}) + assert preferences.get_minute_refresh_interval() == 300 # 上限 + preferences.save({"minute_refresh_interval": 90}) + assert preferences.get_minute_refresh_interval() == 90 + + +def test_status_endpoint_without_service(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.settings import router + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + resp = client.get("/api/settings/minute-refresh/status") + assert resp.status_code == 200 + assert resp.json() == {"available": False} diff --git a/backend/tests/test_minute_strategy.py b/backend/tests/test_minute_strategy.py new file mode 100644 index 0000000..ca4e209 --- /dev/null +++ b/backend/tests/test_minute_strategy.py @@ -0,0 +1,308 @@ +"""分钟策略 (minute_filter 后端) 测试。 + +覆盖: +- minute_red_streak 形态: 命中 / 不足根数不触发 / 最高K不红 / rank_by 两口径 / + 乱序输入 / 最高价并列取更晚K线 +- 引擎加载校验: 只能声明 filter_minute_history、timeframes 必须且只能是 ["1m"] +- 引擎 1m 运行: enriched 联表基础过滤 (剔除ST / 股价区间)、entry hits、 + 日线 context 拒绝 +- ScreenerService 1m context: 当日分区优先、缺失回退最近分区、空库报错、 + 非股票资产拒绝 +""" +from __future__ import annotations + +import datetime as _dt +from datetime import date, datetime +from pathlib import Path + +import polars as pl + +from app.services.screener import ScreenerService +from app.strategy.builtin import minute_red_streak +from app.strategy.engine import StrategyDataContext, StrategyEngine + + +def _bars(symbol: str, candles: list[tuple[float, float, float]], start_hour: int = 9) -> pl.DataFrame: + """candles: (open, close, high) 序列, 时间从 start_hour:30 起每分钟一根。""" + n = len(candles) + base = datetime(2026, 8, 25, start_hour, 30) + return pl.DataFrame({ + "symbol": [symbol] * n, + "datetime": [base + _dt.timedelta(minutes=i) for i in range(n)], + "open": [float(c[0]) for c in candles], + "high": [float(c[2]) for c in candles], + "low": [float(min(c[0], c[1])) for c in candles], + "close": [float(c[1]) for c in candles], + "volume": [100.0] * n, + "amount": [10000.0] * n, + }) + + +# ── 形态 ──────────────────────────────────────────────────────────── + + +def test_pattern_hits_five_red_of_seven_with_red_top_two(): + # 7根: 5红2绿, 绿K的最高价都压得比红K低 → 最高的两根(10.9/10.7)都是红 + candles = [ + (10.0, 10.2, 10.30), # 红 + (10.2, 10.1, 10.25), # 绿 (低高点) + (10.1, 10.4, 10.50), # 红 + (10.4, 10.6, 10.70), # 红 (次高) + (10.6, 10.5, 10.65), # 绿 (低高点) + (10.5, 10.7, 10.80), # 红 + (10.7, 10.8, 10.90), # 红 (最高) + ] + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + assert out["symbol"].to_list() == ["600000.SH"] + row = out.row(0, named=True) + assert row["red_count"] == 5 + assert row["top_red_count"] == 2 + assert row["close"] == 10.8 + + +def test_pattern_insufficient_bars_never_triggers(): + out = minute_red_streak.filter_minute_history(_bars("600000.SH", [(10.0, 10.2, 10.3)] * 6), {}) + assert out.is_empty() + + +def test_pattern_green_at_top_blocks_hit(): + # 5红, 但最高的一根是绿 (高开回落) → 最高两根不全红, 不触发 + candles = [ + (10.0, 10.2, 10.30), # 红 + (10.1, 10.4, 10.50), # 红 + (10.3, 10.6, 10.70), # 红 + (10.6, 10.5, 10.65), # 绿 (低高点) + (10.4, 10.5, 10.55), # 红 (低高点) + (11.5, 11.0, 12.00), # 绿 (最高) + (11.0, 11.4, 11.90), # 红 (次高) + ] + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + assert out.is_empty() + + +def test_pattern_rank_by_close_uses_close_not_high(): + # high 口径最高两根是绿K冲高; close 口径最高两根是红K → 仅 close 口径命中 + candles = [ + (10.0, 10.5, 10.60), # 红 + (10.5, 10.9, 11.50), # 绿 (high 最高, 并列) + (10.9, 11.2, 11.40), # 红 + (11.2, 11.3, 11.35), # 红 + (11.3, 11.4, 11.45), # 红 (close 次高) + (11.4, 11.1, 11.50), # 绿 (high 最高, 并列) + (11.1, 11.5, 11.55), # 红 (close 最高) + ] + by_high = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + by_close = minute_red_streak.filter_minute_history( + _bars("600000.SH", candles), {"rank_by_close": True} + ) + assert by_high.is_empty() + assert by_close["symbol"].to_list() == ["600000.SH"] + + +def test_pattern_sorts_unordered_input_by_datetime(): + bars = pl.concat([ + _bars("600000.SH", [(10.0, 10.2, 10.30)]), + _bars("600000.SH", [ + (10.2, 10.1, 10.25), (10.1, 10.4, 10.50), (10.4, 10.6, 10.70), + (10.6, 10.5, 10.65), (10.5, 10.7, 10.80), (10.7, 10.8, 10.90), + ]), + ]).sample(fraction=1.0, shuffle=True, seed=7) + out = minute_red_streak.filter_minute_history(bars, {}) + assert out["symbol"].to_list() == ["600000.SH"] + assert out.row(0, named=True)["close"] == 10.8 # 最后一根(时间最大)的收盘 + + +def test_pattern_three_way_high_tie_prefers_later_bars(): + # 三根 high 并列最高: 更早的绿K应被更晚的两根红K挤出 top2 → 命中 + # (若并列取更早, top2 = {红, 绿} → 不命中; 该测试固定 "同值取更晚" 契约) + candles = [ + (10.0, 10.2, 10.30), # 红 + (10.1, 10.4, 10.50), # 红 + (10.2, 10.1, 10.25), # 绿 (低高点) + (10.3, 10.6, 10.70), # 红 + (10.8, 10.5, 10.90), # 绿 (并列最高, 最早 → 被 top2 排除) + (10.5, 10.6, 10.90), # 红 (并列最高, 中间) + (10.6, 10.8, 10.90), # 红 (并列最高, 最晚) + ] + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + assert out["symbol"].to_list() == ["600000.SH"] + assert out.row(0, named=True)["top_red_count"] == 2 + + +def test_pattern_min_red_threshold_respected(): + # 4红3绿, 最高的两根红 → min_red=5 不命中, min_red=4 命中 + candles = [ + (10.0, 10.2, 10.30), # 红 + (10.2, 10.1, 10.25), # 绿 + (10.1, 10.4, 10.50), # 红 + (10.4, 10.3, 10.45), # 绿 + (10.3, 10.6, 10.70), # 红 + (10.6, 10.5, 10.65), # 绿 + (10.5, 10.8, 10.90), # 红 + ] + bars = _bars("600000.SH", candles) + assert minute_red_streak.filter_minute_history(bars, {"min_red": 5}).is_empty() + assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4}).is_empty() + + +# ── 引擎加载与运行 ────────────────────────────────────────────────── + + +def test_builtin_minute_strategy_loads_with_minute_filter_backend(): + engine = StrategyEngine( + strategy_dirs=[Path(__file__).resolve().parent.parent / "app" / "strategy" / "builtin"] + ) + assert not [e for e in engine.load_errors() if "minute" in e["file"]] + s = engine.get("minute_red_streak") + assert s.execution_backend == "minute_filter" + assert s.filter_minute_history_fn is not None + assert s.meta["timeframes"] == ["1m"] + + +def _minute_code(sid: str, timeframes: str = '["1m"]', extra: str = "") -> str: + return f'''import polars as pl +META = {{"id": "{sid}", "name": "{sid}", "asset_types": ["stock"], "timeframes": {timeframes}}} +EXECUTION_BACKEND = "minute_filter" +{extra} +def filter_minute_history(df, params): + return df.group_by("symbol").agg( + close=pl.col("close").max(), last_datetime=pl.col("datetime").max() + ) +''' + + +def test_minute_filter_backend_validation(tmp_path): + (tmp_path / "ok.py").write_text(_minute_code("m_ok")) + (tmp_path / "bad_filter.py").write_text( + _minute_code("m_bad1", extra="def filter(df, params):\n return pl.lit(True)") + ) + (tmp_path / "bad_tf.py").write_text(_minute_code("m_bad2", timeframes='["1d", "1m"]')) + engine = StrategyEngine(strategy_dirs=[tmp_path]) + ids = {m["id"] for m in engine.list_strategies(include_research=True)} + assert "m_ok" in ids + assert "m_bad1" not in ids + assert "m_bad2" not in ids + assert any("only filter_minute_history" in e["error"] for e in engine.load_errors()) + assert any("timeframes" in e["error"] for e in engine.load_errors()) + + +def test_minute_context_run_applies_enriched_basic_filter(tmp_path): + (tmp_path / "m_basic.py").write_text(_minute_code("m_basic")) + engine = StrategyEngine(strategy_dirs=[tmp_path]) + + hist = pl.concat([ + _bars("600001.SH", [(10.0, 20.0, 25.0)] * 7), # 命中, 收盘 20 + _bars("600002.SH", [(10.0, 20.0, 25.0)] * 7), # 命中但 ST → 剔除 + _bars("600003.SH", [(10.0, 20.0, 25.0)] * 7), # 命中 + _bars("600004.SH", [(100.0, 200.0, 250.0)] * 7), # 命中但收盘 200 → 超上限剔除 + ]) + current = pl.DataFrame({ + "symbol": ["600001.SH", "600002.SH", "600003.SH", "600004.SH"], + "name": ["正常股", "ST垃圾", "正常股2", "高价股"], + "total_shares": [1e8, 1e8, 1e8, 1e8], + "float_shares": [5e7, 5e7, 5e7, 5e7], + "amount": [3e8, 3e8, 3e8, 3e8], + "change_pct": [0.01, 0.01, 0.01, 0.01], + }) + context = StrategyDataContext( + asset_type="stock", + timeframe="1m", + as_of=date(2026, 8, 25), + current=current, + history=hist, + ) + result = engine.run( + "m_basic", context, overrides={"basic_filter": {"price_max": 150.0}} + ) + symbols = {r["symbol"] for r in result.rows} + assert symbols == {"600001.SH", "600003.SH"} + assert all("name" in r for r in result.rows) # enriched 列已联表 + assert {h["symbol"] for h in result.entry_signal_hits} == symbols + + +def test_minute_strategy_rejects_daily_context(tmp_path): + (tmp_path / "m_daily.py").write_text(_minute_code("m_daily")) + engine = StrategyEngine(strategy_dirs=[tmp_path]) + context = StrategyDataContext( + asset_type="stock", + timeframe="1d", + as_of=date(2026, 8, 25), + current=pl.DataFrame({"symbol": ["600001.SH"]}), + ) + try: + engine.run("m_daily", context) + raise AssertionError("expected ValueError") + except ValueError as e: + assert "timeframe" in str(e) + + +# ── ScreenerService 1m context ────────────────────────────────────── + + +class _FakeMinuteRepo: + def __init__(self, partitions: dict[date, pl.DataFrame]): + self.partitions = partitions + + def get_minute_by_dates(self, symbols, dates, asset_type="stock"): + frames = [self.partitions[d] for d in dates if d in self.partitions] + if not frames: + return pl.DataFrame() + return pl.concat(frames).filter(pl.col("symbol").is_in(symbols)) + + def latest_minute_date_global(self): + return max(self.partitions) if self.partitions else None + + +def _svc(partitions: dict[date, pl.DataFrame], asset_type: str = "stock") -> ScreenerService: + return ScreenerService(_FakeMinuteRepo(partitions), asset_type=asset_type) # type: ignore[arg-type] + + +def test_minute_context_prefers_as_of_partition(): + d1, d2 = date(2026, 8, 24), date(2026, 8, 25) + svc = _svc({ + d1: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3), + d2: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 4), + }) + ctx = svc.build_strategy_context( + None, d1, [], timeframe="1m", + current=pl.DataFrame({"symbol": ["600001.SH"], "name": ["x"]}), + ) + assert ctx.history.height == 3 # as_of 当日分区, 不取更新的 d2 + assert ctx.timeframe == "1m" + + +def test_minute_context_falls_back_to_latest_partition(): + d1, d2 = date(2026, 8, 24), date(2026, 8, 25) + svc = _svc({ + d1: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3), + d2: _bars("600001.SH", [(10.0, 10.2, 10.3)] * 4), + }) + ctx = svc.build_strategy_context( + None, date(2026, 8, 20), [], timeframe="1m", + current=pl.DataFrame({"symbol": ["600001.SH"]}), + ) + assert ctx.history.height == 4 # 回退到最近分区 d2 + + +def test_minute_context_empty_store_raises_with_guidance(): + svc = _svc({}) + try: + svc.build_strategy_context( + None, date(2026, 8, 25), [], timeframe="1m", + current=pl.DataFrame({"symbol": ["600001.SH"]}), + ) + raise AssertionError("expected ValueError") + except ValueError as e: + assert "分钟K" in str(e) + + +def test_minute_context_rejects_non_stock_asset(): + svc = _svc({date(2026, 8, 25): _bars("510300.SH", [(10.0, 10.2, 10.3)] * 3)}, asset_type="etf") + try: + svc.build_strategy_context( + None, date(2026, 8, 25), [], timeframe="1m", + current=pl.DataFrame({"symbol": ["510300.SH"]}), + ) + raise AssertionError("expected ValueError") + except ValueError as e: + assert "A 股" in str(e) diff --git a/backend/tests/test_screener_etf.py b/backend/tests/test_screener_etf.py index c201f40..32c4de7 100644 --- a/backend/tests/test_screener_etf.py +++ b/backend/tests/test_screener_etf.py @@ -41,18 +41,23 @@ def test_all_builtin_strategies_declare_asset_types_and_timeframes(): assert engine.load_errors() == [] for meta in engine.list_strategies(): assert meta["asset_types"] - assert meta["timeframes"] == ["1d"] + # 分钟策略 timeframes 为 ["1m"], 日线内置策略为 ["1d"] + assert meta["timeframes"] in (["1d"], ["1m"]) def test_all_builtin_strategies_use_matrix_backend_only(): engine = _engine() assert engine.load_errors() == [] strategies = [engine.get(meta["id"]) for meta in engine.list_strategies()] - assert len(strategies) == 18 - assert all(strategy.execution_backend == "matrix_native" for strategy in strategies) - assert all(strategy.matrix_strategy is not None for strategy in strategies) - assert all(strategy.filter_fn is None for strategy in strategies) - assert all(strategy.filter_history_fn is None for strategy in strategies) + matrix_strategies = [s for s in strategies if s.execution_backend == "matrix_native"] + assert len(matrix_strategies) == 18 + assert all(s.matrix_strategy is not None for s in matrix_strategies) + assert all(s.filter_fn is None for s in matrix_strategies) + assert all(s.filter_history_fn is None for s in matrix_strategies) + # 分钟形态策略 (minute_filter) 不参与日线矩阵不变量 + assert [s.meta["id"] for s in strategies if s.execution_backend == "minute_filter"] == [ + "minute_red_streak" + ] def test_all_builtin_matrix_formulas_accept_base_market_matrix(): @@ -79,10 +84,14 @@ def test_all_builtin_matrix_formulas_accept_base_market_matrix(): from app.backtest.matrix import build_market_data_matrix fields = set() - for strategy in (engine.get(meta["id"]) for meta in engine.list_strategies()): + matrix_metas = [ + m for m in engine.list_strategies() + if engine.get(m["id"]).execution_backend == "matrix_native" + ] + for strategy in (engine.get(meta["id"]) for meta in matrix_metas): fields.update(engine._matrix_field_columns(strategy)) market = build_market_data_matrix(panel, field_columns=fields) - for meta in engine.list_strategies(): + for meta in matrix_metas: strategy = engine.get(meta["id"]) signals = strategy.matrix_strategy.compute_signals(market, {}) assert signals.shape == market.shape, meta["id"] diff --git a/frontend/src/components/data/MinuteSyncConfig.tsx b/frontend/src/components/data/MinuteSyncConfig.tsx index e104866..86ce910 100644 --- a/frontend/src/components/data/MinuteSyncConfig.tsx +++ b/frontend/src/components/data/MinuteSyncConfig.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Loader2, Trash2, Download, Calendar } from 'lucide-react' +import { Loader2, Trash2, Download, Calendar, Zap } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { MissingCapChip } from '@/lib/capability-labels' @@ -12,17 +12,59 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; queryFn: api.preferences, }) const update = useMutation({ - mutationFn: ({ enabled, days, segmentDays }: { enabled: boolean; days: number; segmentDays?: number }) => - api.updateMinuteSync(enabled, days, segmentDays), - onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), + mutationFn: ({ enabled, days, segmentDays, refresh }: { + enabled: boolean; days: number; segmentDays?: number + refresh?: { enabled?: boolean; interval?: number } + }) => + api.updateMinuteSync(enabled, days, segmentDays, refresh), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.preferences }) + qc.invalidateQueries({ queryKey: ['minute-refresh-status'] }) + }, + }) + + // 盘中增量刷新状态 (轮询 15s, 仅弹窗挂载期间) + const refreshStatus = useQuery({ + queryKey: ['minute-refresh-status'], + queryFn: api.minuteRefreshStatus, + refetchInterval: 15000, }) const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch'] const enabled = prefs.data?.minute_sync_enabled ?? false const days = prefs.data?.minute_sync_days ?? 5 const segmentDays = prefs.data?.minute_sync_segment_days ?? 20 + const refreshEnabled = prefs.data?.minute_refresh_enabled ?? false + const refreshInterval = prefs.data?.minute_refresh_interval ?? 60 const [localDays, setLocalDays] = useState(days) const [localSegment, setLocalSegment] = useState(segmentDays) + const [localRefreshInterval, setLocalRefreshInterval] = useState(refreshInterval) + + useEffect(() => { setLocalDays(days) }, [days]) + useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays]) + useEffect(() => { setLocalRefreshInterval(refreshInterval) }, [refreshInterval]) + + // 盘中增量 = intraday.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池 + const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch'] + const rs = refreshStatus.data + const refreshGateText = rs?.custom_provider_active + ? '已配置自定义分钟源, 盘中增量由插件自管' + : rs && rs.available && !rs.capability_ok + ? '需要日内分时批量能力 (Expert)' + : !rs?.in_trading_hours ? '非连续竞价时段, 暂停中' + : rs?.last_error ? `最近错误: ${rs.last_error}` + : null + + const handleRefreshToggle = () => { + if (!hasIntradayBatchCap) return + update.mutate({ enabled, days: localDays, refresh: { enabled: !refreshEnabled } }) + } + + const setRefreshInterval = (v: number) => { + const clamped = Math.max(60, Math.min(300, Math.round(v / 30) * 30)) + setLocalRefreshInterval(clamped) + update.mutate({ enabled, days: localDays, refresh: { interval: clamped } }) + } useEffect(() => { setLocalDays(days) }, [days]) useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays]) @@ -147,6 +189,67 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
+ {/* 区块 A2: 盘中增量刷新 (Expert 专有, intraday.batch 独立限流池) */} +
+
+
+ +
+ + + 盘中增量刷新{refreshEnabled ? '已开启' : '已关闭'} + +
+
+
+
+ +
+ {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60}m` : `${localRefreshInterval}s`} +
+ +
+ {!hasIntradayBatchCap && } +
+
+
+ 交易时段内用日内分时批量 (独立配额) 每 {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60} 分钟` : `${localRefreshInterval} 秒`} 全市场脉冲落盘一次, + 分钟策略读到最新K线; 不占用盘后分钟同步的限流配额。 +
+ {/* 运行状态一行: 门控原因 / 下一轮 / 最近一轮 */} + {rs?.available && ( +
+ + ● {rs.running ? '服务运行中' : '服务未运行'} + + {refreshGateText && {refreshGateText}} + {rs.rounds != null && rs.rounds > 0 && ( + 已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求{rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''} + )} +
+ )} +
+ {/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
diff --git a/frontend/src/components/screener/StrategyCard.tsx b/frontend/src/components/screener/StrategyCard.tsx index f2efbff..7281acb 100644 --- a/frontend/src/components/screener/StrategyCard.tsx +++ b/frontend/src/components/screener/StrategyCard.tsx @@ -92,12 +92,14 @@ interface StrategyCardProps { monitored?: boolean /** 切换策略监控 (点击 RadioTower 图标) */ onToggleMonitor?: () => void + /** 周期徽章 (如 '分钟'); 日线策略不传 */ + timeframeBadge?: string } export function StrategyCard({ name, description, source, active, count, expiredCount, loading, cardSize, - onRun, disabled, onSettings, monitored, onToggleMonitor, + onRun, disabled, onSettings, monitored, onToggleMonitor, timeframeBadge, }: StrategyCardProps) { const cs = CARD_STYLES[cardSize] const activeCls = active @@ -125,6 +127,9 @@ export function StrategyCard({ className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait w-full">
{srcLabel} + {timeframeBadge && ( + {timeframeBadge} + )} {name}
{description && ( @@ -164,6 +169,9 @@ export function StrategyCard({ className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait min-w-0">
{srcLabel} + {timeframeBadge && ( + {timeframeBadge} + )} {name} {count != null && !loading && ( {count} diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index d6dcc21..8f41e09 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -8,6 +8,8 @@ interface Props { pool: string[] onConfirm: (newPool: string[]) => void onClose: () => void + /** 列表周期: 1d 日线 / 1m 分钟, 与策略页当前周期一致 */ + timeframe?: '1d' | '1m' } const SOURCE_CLS: Record = { @@ -42,7 +44,7 @@ function fileStem(name: string): string { return name.replace(/\.py$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') } -export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { +export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' }: Props) { const backdrop = useDialogBackdrop(onClose) // 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交 const [draftPool, setDraftPool] = useState(() => [...pool]) @@ -57,7 +59,7 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { const loadStrategies = useCallback(async () => { setLoading(true) try { - const d = await api.strategyList() + const d = await api.strategyList(undefined, timeframe) setAllStrategies(d.strategies) } catch { setAllStrategies([]) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6ae9cc3..de54b86 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -319,6 +319,8 @@ export interface ScreenerStrategy { name: string description: string source?: string + /** 支持的周期, 如 ['1d'] / ['1m'] (分钟策略) */ + timeframes?: string[] } export interface StrategyLoadError { @@ -1457,6 +1459,8 @@ export interface Preferences { minute_sync_enabled: boolean minute_sync_days: number minute_sync_segment_days: number + minute_refresh_enabled: boolean + minute_refresh_interval: number daily_data_provider?: string adj_factor_provider?: string minute_data_provider?: string @@ -1644,15 +1648,38 @@ export const api = { }), }, ), - updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) => + updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number, refresh?: { enabled?: boolean; interval?: number }) => request('/api/settings/preferences/minute-sync', { method: 'PUT', body: JSON.stringify({ minute_sync_enabled: enabled, minute_sync_days: days, ...(segmentDays != null ? { minute_sync_segment_days: segmentDays } : {}), + ...(refresh?.enabled != null ? { minute_refresh_enabled: refresh.enabled } : {}), + ...(refresh?.interval != null ? { minute_refresh_interval: refresh.interval } : {}), }), }), + + /** 盘中分钟增量刷新服务状态 (Expert 专有) */ + minuteRefreshStatus: () => + request<{ + available: boolean + enabled?: boolean + running?: boolean + interval_seconds?: number + capability_ok?: boolean + custom_provider_active?: boolean + in_trading_hours?: boolean + gate_reason?: string | null + rounds?: number + last_round_at?: number | null + last_round_ms?: number | null + last_rows?: number + last_symbols?: number + last_requests?: number + next_round_at?: number | null + last_error?: string | null + }>('/api/settings/minute-refresh/status'), updatePipelinePullTypes: (cfg: Partial>) => request<{ pipeline_pull_a_share: boolean @@ -2091,16 +2118,16 @@ export const api = { : '/api/watchlist/enriched', ), - screenerStrategies: async (assetType?: 'stock' | 'etf' | 'index') => { + screenerStrategies: async (assetType?: 'stock' | 'etf' | 'index', timeframe: '1d' | '1m' = '1d') => { const data = await request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>( - `/api/strategies?${assetType ? `asset_type=${assetType}&` : ''}timeframe=1d`, + `/api/strategies?${assetType ? `asset_type=${assetType}&` : ''}timeframe=${timeframe}`, ) return { presets: data.strategies, load_errors: data.load_errors } }, - screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string, assetType: 'stock' | 'etf' = 'stock') => + screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string, assetType: 'stock' | 'etf' = 'stock', timeframe: '1d' | '1m' = '1d') => request('/api/screener/run_preset', { method: 'POST', - body: JSON.stringify({ strategy_id, pool, as_of: asOf ?? null, ext_columns: extColumns || null, asset_type: assetType }), + body: JSON.stringify({ strategy_id, pool, as_of: asOf ?? null, ext_columns: extColumns || null, asset_type: assetType, timeframe }), }), screenerRunCustom: (conditions: string[], orderBy?: string, limit = 30, pool?: string[], extColumns?: string, assetType: 'stock' | 'etf' = 'stock') => request('/api/screener/run', { diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index bc2910e..bd1e141 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -40,6 +40,8 @@ const SHOW_STRATEGY_STORE = false export function Screener() { const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock') + // 周期: 日线 (盘后缓存 + runAll) / 分钟 (本地分钟K分区, 单策略实时跑) + const [timeframe, setTimeframe] = useState<'1d' | '1m'>('1d') const [activeStrategy, setActiveStrategy] = useState(null) const [result, setResult] = useState(null) const [asOf, setAsOf] = useState('') @@ -128,27 +130,28 @@ export function Screener() { const screenerAutoRun = prefs?.screener_auto_run ?? true const strategies = useQuery({ - queryKey: QK.screenerStrategies('all'), - queryFn: () => api.screenerStrategies(), + queryKey: [...QK.screenerStrategies('all'), timeframe], + queryFn: () => api.screenerStrategies(undefined, timeframe), }) // 卡片首屏只读取轻量摘要;明细在点击策略或“全部”时按需加载。 const summaryQuery = useQuery({ queryKey: QK.screenerCachedSummary, queryFn: api.screenerCachedSummary, - enabled: assetType === 'stock', + enabled: assetType === 'stock' && timeframe === '1d', }) const fullCachedQuery = useQuery({ queryKey: QK.screenerCached(asOf, extColumnsParam), queryFn: () => api.screenerCached(extColumnsParam || undefined), - enabled: assetType === 'stock' && showAll, + enabled: assetType === 'stock' && timeframe === '1d' && showAll, }) const singleCachedQuery = useQuery({ queryKey: QK.screenerCachedResult(activeStrategy ?? '', asOf, extColumnsParam), queryFn: () => api.screenerCachedResult(activeStrategy!, extColumnsParam || undefined), enabled: assetType === 'stock' + && timeframe === '1d' && !showAll && !!activeStrategy && summaryQuery.data?.results[activeStrategy]?.as_of === asOf, @@ -204,8 +207,10 @@ export function Screener() { if (strategies.isError) return // 拉取失败: 不 prune if (!strategies.isSuccess) return // 加载中: 不 prune if (allStrategyIds.size === 0) return // 空列表: 不 prune + // 分钟模式的列表只含分钟策略, prune 会误删池中的日线策略 → 仅日线模式清理 + if (timeframe !== '1d') return prune(allStrategyIds) - }, [allStrategyIds, prune, strategies.isError, strategies.isSuccess]) + }, [allStrategyIds, prune, strategies.isError, strategies.isSuccess, timeframe]) // 策略文件加载失败时提示用户(避免"策略静默消失"被误判为正常) const loadErrors = strategies.data?.load_errors ?? [] @@ -445,7 +450,8 @@ export function Screener() { // 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll useEffect(() => { // ETF 模式无股票盘后缓存/ runAll, 单策略走实时单跑, 不触发 runAll - if (assetType !== 'stock') return + // 分钟模式走本地分钟K分区, 同样不触发 runAll (盘后缓存是日线语义) + if (assetType !== 'stock' || timeframe !== '1d') return if (!asOf || strategyPresets.length === 0 || !summaryQuery.isSuccess || runAll.isPending || visiblePool.length === 0) return const runKey = `${asOf}|${visiblePool.join(',')}` if (runAllDateRef.current === runKey) return @@ -458,11 +464,11 @@ export function Screener() { if (!screenerAutoRun) return runAllDateRef.current = runKey requestRunAll({ date: asOf, strategyIds: missingStrategyIds }) - }, [asOf, strategyPresets.length, summaryQuery.isSuccess, visiblePool, cacheCoversPool, missingStrategyIds, screenerAutoRun, assetType, runAll.isPending, requestRunAll]) + }, [asOf, strategyPresets.length, summaryQuery.isSuccess, visiblePool, cacheCoversPool, missingStrategyIds, screenerAutoRun, assetType, timeframe, runAll.isPending, requestRunAll]) const run = useMutation({ mutationFn: ({ id, date }: { id: string; date: string }) => - api.screenerRunPreset(id, undefined, date || undefined, extColumnsParam || undefined, assetType), + api.screenerRunPreset(id, undefined, date || undefined, extColumnsParam || undefined, assetType, timeframe), onSuccess: (data, vars) => { setResult(data) // 同步更新卡片上的命中数 @@ -479,7 +485,7 @@ export function Screener() { if (result?.strategy !== s.id || result.as_of !== asOf) setResult(null) // ETF 模式: 无股票盘后缓存, 始终实时单跑。 // 传空日期让后端用 ETF 自己的最新交易日 (asOf 跟随的是股票 enriched, 两者可能不同日)。 - if (assetType !== 'stock') { + if (assetType !== 'stock' || timeframe !== '1d') { run.mutate({ id: s.id, date: '' }) return } @@ -602,19 +608,47 @@ export function Screener() { subtitle="基于本地 enriched 表 · 毫秒级 SQL" right={
- {/* 资产类型切换: 股票 / ETF */} + {/* 资产类型切换: 股票 / ETF (分钟策略仅支持股票, 1m 模式下 ETF 置灰) */}
- {(['stock', 'etf'] as const).map(t => ( + {(['stock', 'etf'] as const).map(t => { + const disabled = t === 'etf' && timeframe === '1m' + return ( + + ) + })} +
+ {/* 周期切换: 日线 (盘后缓存) / 分钟 (本地分钟K分区实时计算) */} +
+ {(['1d', '1m'] as const).map(tf => ( ))}
@@ -746,6 +780,7 @@ export function Screener() { onSettings={() => setSettingsStrategyId(s.id)} monitored={strategyMonitorMap.has(s.id)} onToggleMonitor={() => toggleStrategyMonitor(s.id, s.name)} + timeframeBadge={s.timeframes?.includes('1m') ? '分钟' : undefined} /> ) })} @@ -1004,6 +1039,7 @@ export function Screener() { {showPoolDialog && ( { reorderPool(newPool) }} From 2f8d1e5e60c9d70a3cee4ff1b4c3d8a03b6c6f61 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:06 +0800 Subject: [PATCH 03/51] =?UTF-8?q?refactor(settings):=20=E7=9B=98=E4=B8=AD?= =?UTF-8?q?=E5=88=86=E9=92=9F=E5=A2=9E=E9=87=8F=E5=88=B7=E6=96=B0=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E8=BF=81=E8=87=B3=E5=AE=9E=E6=97=B6=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该功能是交易时段常驻运行的实时服务, 心智模型归实时行情设置更合理; 数据页分钟K弹窗还原为纯盘后批量同步语义。 - 配置端点归属迁移: minute_refresh_enabled/interval 由 PUT /api/settings/preferences/realtime-monitor 拥有 (set_realtime_monitor_config 写入 + getter 返回, clamp [60,300] 不变); minute-sync 端点还原原状 - 前端 Monitoring.tsx 新增「盘中分钟增量」卡片 (开关/间隔滑杆 60-300s/ 运行轮次状态行), 沿用 Card/ToggleRow/滑杆防抖既有模式 - MinuteSyncConfig 移除盘中增量区块; api.ts updateMinuteSync 还原 3 参签名 - 新增归属测试: realtime_monitor_config 拥有 refresh 键且越界 clamp 验证: 28 项分钟相关测试通过; UI 开关双向回路 (curl 置开→UI 显示开, UI 点击→偏好翻转 False 且服务 gate_reason=disabled); 数据页弹窗无残留 --- backend/app/api/settings.py | 13 +-- backend/app/services/preferences.py | 9 ++ backend/tests/test_minute_refresh.py | 14 +++ .../src/components/data/MinuteSyncConfig.tsx | 108 +----------------- frontend/src/lib/api.ts | 4 +- frontend/src/pages/settings/Monitoring.tsx | 77 +++++++++++++ 6 files changed, 108 insertions(+), 117 deletions(-) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index b124b3b..9cf8098 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -391,10 +391,6 @@ class MinuteSyncPrefs(BaseModel): minute_sync_days: int = 5 # 单段大小(交易日),None 表示不修改现有值。范围 [5, 30],默认 20。 minute_sync_segment_days: int | None = None - # 盘中分钟增量刷新 (Expert 专有)。None 表示不修改现有值。 - minute_refresh_enabled: bool | None = None - # 刷新间隔(秒),范围 [60, 300]。None 表示不修改现有值。 - minute_refresh_interval: int | None = None class DataProvidersIn(BaseModel): @@ -839,17 +835,11 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict: } if req.minute_sync_segment_days is not None: updates["minute_sync_segment_days"] = max(5, min(30, req.minute_sync_segment_days)) - if req.minute_refresh_enabled is not None: - updates["minute_refresh_enabled"] = req.minute_refresh_enabled - if req.minute_refresh_interval is not None: - updates["minute_refresh_interval"] = max(60, min(300, req.minute_refresh_interval)) preferences.save(updates) return { "minute_sync_enabled": req.minute_sync_enabled, "minute_sync_days": days, "minute_sync_segment_days": preferences.get_minute_sync_segment_days(), - "minute_refresh_enabled": preferences.get_minute_refresh_enabled(), - "minute_refresh_interval": preferences.get_minute_refresh_interval(), } @@ -1002,6 +992,9 @@ class RealtimeMonitorConfigIn(BaseModel): screener_auto_run: bool | None = None minute_intraday_refresh: bool | None = None minute_intraday_refresh_interval: int | None = None + # 盘中分钟增量落盘 (Expert 专有) — 交易时段常驻服务, 归实时监控配置 + minute_refresh_enabled: bool | None = None + minute_refresh_interval: int | None = None monitor_ext_fields: dict | None = None diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 184c576..906789d 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -940,6 +940,13 @@ def set_realtime_monitor_config(cfg: dict) -> dict: updates["minute_intraday_refresh_interval"] = max( _INTRADAY_REFRESH_INTERVAL_MIN, min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"]))) + if "minute_refresh_enabled" in cfg: + updates["minute_refresh_enabled"] = bool(cfg["minute_refresh_enabled"]) + if "minute_refresh_interval" in cfg: + # clamp 到 [60, 300] (下限保证 60s 窗口至多一个全市场脉冲), 与 getter 一致 + updates["minute_refresh_interval"] = max( + _MINUTE_REFRESH_INTERVAL_MIN, + min(_MINUTE_REFRESH_INTERVAL_MAX, int(cfg["minute_refresh_interval"]))) if "monitor_ext_fields" in cfg: raw = cfg["monitor_ext_fields"] or {} updates["monitor_ext_fields"] = { @@ -961,6 +968,8 @@ def get_realtime_monitor_config() -> dict: "screener_auto_run": get_screener_auto_run(), "minute_intraday_refresh": get_minute_intraday_refresh(), "minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(), + "minute_refresh_enabled": get_minute_refresh_enabled(), + "minute_refresh_interval": get_minute_refresh_interval(), "monitor_ext_fields": get_monitor_ext_fields(), } diff --git a/backend/tests/test_minute_refresh.py b/backend/tests/test_minute_refresh.py index 21e5cd1..8e87d5d 100644 --- a/backend/tests/test_minute_refresh.py +++ b/backend/tests/test_minute_refresh.py @@ -183,6 +183,20 @@ def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch): preferences.save({"minute_refresh_interval": 90}) assert preferences.get_minute_refresh_interval() == 90 +def test_realtime_monitor_config_owns_refresh_keys(tmp_path, monkeypatch): + """盘中增量配置归属实时监控端点 (set_realtime_monitor_config), 并 clamp 到 [60,300]。""" + _isolated_prefs(tmp_path, monkeypatch) + saved = preferences.set_realtime_monitor_config({ + "minute_refresh_enabled": True, + "minute_refresh_interval": 10, # 越界 → clamp 到下限 + }) + assert saved["minute_refresh_enabled"] is True + assert saved["minute_refresh_interval"] == 60 + saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 400}) + assert saved["minute_refresh_interval"] == 300 + saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 120}) + assert saved["minute_refresh_interval"] == 120 + def test_status_endpoint_without_service(): from fastapi import FastAPI diff --git a/frontend/src/components/data/MinuteSyncConfig.tsx b/frontend/src/components/data/MinuteSyncConfig.tsx index 86ce910..6a79a8a 100644 --- a/frontend/src/components/data/MinuteSyncConfig.tsx +++ b/frontend/src/components/data/MinuteSyncConfig.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Loader2, Trash2, Download, Calendar, Zap } from 'lucide-react' +import { Loader2, Trash2, Download, Calendar } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { MissingCapChip } from '@/lib/capability-labels' @@ -12,59 +12,20 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; queryFn: api.preferences, }) const update = useMutation({ - mutationFn: ({ enabled, days, segmentDays, refresh }: { - enabled: boolean; days: number; segmentDays?: number - refresh?: { enabled?: boolean; interval?: number } - }) => - api.updateMinuteSync(enabled, days, segmentDays, refresh), - onSuccess: () => { - qc.invalidateQueries({ queryKey: QK.preferences }) - qc.invalidateQueries({ queryKey: ['minute-refresh-status'] }) - }, - }) - - // 盘中增量刷新状态 (轮询 15s, 仅弹窗挂载期间) - const refreshStatus = useQuery({ - queryKey: ['minute-refresh-status'], - queryFn: api.minuteRefreshStatus, - refetchInterval: 15000, + mutationFn: ({ enabled, days, segmentDays }: { enabled: boolean; days: number; segmentDays?: number }) => + api.updateMinuteSync(enabled, days, segmentDays), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), }) const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch'] const enabled = prefs.data?.minute_sync_enabled ?? false const days = prefs.data?.minute_sync_days ?? 5 const segmentDays = prefs.data?.minute_sync_segment_days ?? 20 - const refreshEnabled = prefs.data?.minute_refresh_enabled ?? false - const refreshInterval = prefs.data?.minute_refresh_interval ?? 60 const [localDays, setLocalDays] = useState(days) const [localSegment, setLocalSegment] = useState(segmentDays) - const [localRefreshInterval, setLocalRefreshInterval] = useState(refreshInterval) useEffect(() => { setLocalDays(days) }, [days]) useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays]) - useEffect(() => { setLocalRefreshInterval(refreshInterval) }, [refreshInterval]) - - // 盘中增量 = intraday.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池 - const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch'] - const rs = refreshStatus.data - const refreshGateText = rs?.custom_provider_active - ? '已配置自定义分钟源, 盘中增量由插件自管' - : rs && rs.available && !rs.capability_ok - ? '需要日内分时批量能力 (Expert)' - : !rs?.in_trading_hours ? '非连续竞价时段, 暂停中' - : rs?.last_error ? `最近错误: ${rs.last_error}` - : null - - const handleRefreshToggle = () => { - if (!hasIntradayBatchCap) return - update.mutate({ enabled, days: localDays, refresh: { enabled: !refreshEnabled } }) - } - - const setRefreshInterval = (v: number) => { - const clamped = Math.max(60, Math.min(300, Math.round(v / 30) * 30)) - setLocalRefreshInterval(clamped) - update.mutate({ enabled, days: localDays, refresh: { interval: clamped } }) - } useEffect(() => { setLocalDays(days) }, [days]) useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays]) @@ -189,67 +150,6 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
- {/* 区块 A2: 盘中增量刷新 (Expert 专有, intraday.batch 独立限流池) */} -
-
-
- -
- - - 盘中增量刷新{refreshEnabled ? '已开启' : '已关闭'} - -
-
-
-
- -
- {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60}m` : `${localRefreshInterval}s`} -
- -
- {!hasIntradayBatchCap && } -
-
-
- 交易时段内用日内分时批量 (独立配额) 每 {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60} 分钟` : `${localRefreshInterval} 秒`} 全市场脉冲落盘一次, - 分钟策略读到最新K线; 不占用盘后分钟同步的限流配额。 -
- {/* 运行状态一行: 门控原因 / 下一轮 / 最近一轮 */} - {rs?.available && ( -
- - ● {rs.running ? '服务运行中' : '服务未运行'} - - {refreshGateText && {refreshGateText}} - {rs.rounds != null && rs.rounds > 0 && ( - 已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求{rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''} - )} -
- )} -
- {/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index de54b86..d38429b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1648,15 +1648,13 @@ export const api = { }), }, ), - updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number, refresh?: { enabled?: boolean; interval?: number }) => + updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) => request('/api/settings/preferences/minute-sync', { method: 'PUT', body: JSON.stringify({ minute_sync_enabled: enabled, minute_sync_days: days, ...(segmentDays != null ? { minute_sync_segment_days: segmentDays } : {}), - ...(refresh?.enabled != null ? { minute_refresh_enabled: refresh.enabled } : {}), - ...(refresh?.interval != null ? { minute_refresh_interval: refresh.interval } : {}), }), }), diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 94607b1..f515151 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -54,9 +54,21 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6 // 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致) const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval) + // 盘中分钟增量 (Expert 专有): 间隔 (秒), 与后端 [60,300] clamp 对齐; 默认 60 + const minuteRefreshInterval = prefs?.minute_refresh_interval ?? 60 + const [minuteRefreshIntervalDraft, setMinuteRefreshIntervalDraft] = useState(minuteRefreshInterval) + // 盘中增量服务状态 (15s 轮询; 无服务时 available=false) + const refreshStatus = useQuery({ + queryKey: ['minute-refresh-status'], + queryFn: api.minuteRefreshStatus, + refetchInterval: 15000, + }) const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false const hasDepth = !!caps?.capabilities?.['depth5.batch'] + // 盘中分钟增量 = intraday.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池 + const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch'] + const rs = refreshStatus.data // 新建监控规则时默认勾选的推送渠道 (全局默认值数组, 单条规则可独立修改) const webhookDefaultChannels = prefs?.webhook_default_channels ?? [] const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol) @@ -262,6 +274,20 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = return () => window.clearTimeout(t) }, [intradayIntervalDraft, intradayInterval, save]) + // 盘中增量间隔: 服务端值变化时同步本地草稿 + useEffect(() => { + setMinuteRefreshIntervalDraft(minuteRefreshInterval) + }, [minuteRefreshInterval]) + + // 盘中增量间隔: 草稿与已保存值不同时, 2s 防抖落库 + useEffect(() => { + if (minuteRefreshIntervalDraft === minuteRefreshInterval) return + const t = window.setTimeout(() => { + save({ minute_refresh_interval: minuteRefreshIntervalDraft }) + }, 2000) + return () => window.clearTimeout(t) + }, [minuteRefreshIntervalDraft, minuteRefreshInterval, save]) + // highlight=depth-fix 时闪烁高亮连板梯队修正卡片 const [flash, setFlash] = useState(false) const flashedRef = useRef(false) @@ -282,6 +308,57 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {/* ========== 左列 ========== */}
{/* 行情状态 — 开关 + 间隔 */} + {/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */} + + save({ minute_refresh_enabled: v })} + disabled={!hasIntradayBatchCap || !!rs?.custom_provider_active} + /> +
+
+
+
刷新间隔
+
+ 交易时段内全市场脉冲落盘一轮的间隔; 下限 60s 保证不超 intraday.batch 配额 +
+
+ + {minuteRefreshIntervalDraft >= 60 && minuteRefreshIntervalDraft % 60 === 0 ? `${minuteRefreshIntervalDraft / 60}m` : `${minuteRefreshIntervalDraft}s`} + +
+
+ setMinuteRefreshIntervalDraft(parseInt(e.target.value, 10))} + className="flex-1 h-1 accent-accent cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" + /> + + {minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '60s — 300s'} + +
+ {rs?.available && rs.rounds != null && rs.rounds > 0 && ( +
+ 已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求 + {rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''} + {rs.last_error ? ` · ${rs.last_error}` : ''} +
+ )} +
+
+ Date: Sun, 30 Aug 2026 19:05:07 +0800 Subject: [PATCH 04/51] =?UTF-8?q?feat(minute):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E7=BA=A27=E6=94=B9=E7=94=A8=E5=BC=80=E7=9B=98=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=20+=20=E7=9B=98=E4=B8=AD=E5=A2=9E=E9=87=8F=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E7=A7=BB=E8=87=B3=E8=AE=BE=E7=BD=AE=E5=8F=B3=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分钟红7: 检查窗口从「最近7根」改为「当日最早7根(开盘7根)」— 捕捉开盘急拉形态, 窗口在早盘即固定, 不随盘中新K线漂移; close 取开盘窗口末根收盘价 (基础过滤口径同步更新)。 - 窗口选择: tail → head (sort 后 int_range < bars), 输出列契约不变 - 新增两个区分性测试: 开盘命中后转绿仍命中且 close 取窗口末根; 开盘不足红K数、尾盘转红不救回 (旧最近窗口口径会命中) - 实盘验证: 252 只命中, 每只窗口末时间均为 9:36 (开盘第7根) - 设置页「盘中分钟增量」卡片从左列顶部移至右列 (连板梯队修正之后) 注: 本地 runtime 覆盖文件 minute_red_streak.json 的 description 快照已同步新文案 (gitignored, 不入库) --- .../app/strategy/builtin/minute_red_streak.py | 18 ++-- backend/tests/test_minute_strategy.py | 41 ++++++- frontend/src/pages/settings/Monitoring.tsx | 102 +++++++++--------- 3 files changed, 100 insertions(+), 61 deletions(-) diff --git a/backend/app/strategy/builtin/minute_red_streak.py b/backend/app/strategy/builtin/minute_red_streak.py index 4de22b6..58bf2d3 100644 --- a/backend/app/strategy/builtin/minute_red_streak.py +++ b/backend/app/strategy/builtin/minute_red_streak.py @@ -1,4 +1,4 @@ -"""分钟红7 — 最近 N 根分钟K多数收红, 且最高的 top_red 根全红。 +"""分钟红7 — 开盘 N 根 (当日最早) 分钟K多数收红, 且最高的 top_red 根全红。 数据契约: filter_minute_history 接收当日全市场分钟K窗口 (symbol, datetime, open, high, low, close, volume, amount), @@ -11,14 +11,14 @@ import polars as pl META = { "id": "minute_red_streak", "name": "分钟红7", - "description": "最近7根1分钟K至少5根收红, 且最高的2根(按最高价)都是红K", + "description": "开盘前7根1分钟K至少5根收红, 且最高的2根(按最高价)都是红K", "tags": ["分钟", "形态", "短线"], "asset_types": ["stock"], "timeframes": ["1m"], "params": [ { "id": "bars", - "label": "检查K线数", + "label": "开盘K线数", "type": "int", "default": 7, "min": 5, @@ -63,7 +63,7 @@ EXIT_SIGNALS: list[str] = [] def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: """红K形态过滤: 全向量化, 无逐行 Python 循环。 - - 每标的按时间取最近 bars 根; 不足 bars 根不触发 + - 每标的按时间取当日最早 bars 根 (开盘窗口); 不足 bars 根不触发 - 红 = close > open; 窗口内红K数 >= min_red - 按 rank_by (high / close) 降序取前 top_red 根, 同值取时间更晚者, 需全红 """ @@ -74,17 +74,17 @@ def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: if rank_by not in df.columns: rank_by = "high" - tailed = ( + windowed = ( df.sort(["symbol", "datetime"]) - .filter(pl.int_range(pl.len()).over("symbol") >= pl.len().over("symbol") - bars) + .filter(pl.int_range(pl.len()).over("symbol") < bars) .with_columns(_red=(pl.col("close") > pl.col("open")).cast(pl.Int32)) ) - window = tailed.group_by("symbol").agg( + window = windowed.group_by("symbol").agg( bars_checked=pl.len(), red_count=pl.col("_red").sum(), last_datetime=pl.col("datetime").max(), - # 输出列名用 close: 基础过滤的股价区间直接作用于最新分钟价 + # 输出列名用 close: 基础过滤的股价区间作用于开盘窗口末根收盘价 close=pl.col("close").sort_by("datetime").last(), window_high=pl.col("high").max(), window_low=pl.col("low").min(), @@ -93,7 +93,7 @@ def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: ) top = ( - tailed.sort([rank_by, "datetime"], descending=[True, True]) + windowed.sort([rank_by, "datetime"], descending=[True, True]) .filter(pl.int_range(pl.len()).over("symbol") < top_red) .group_by("symbol") .agg(top_red_count=pl.col("_red").sum()) diff --git a/backend/tests/test_minute_strategy.py b/backend/tests/test_minute_strategy.py index ca4e209..b20d480 100644 --- a/backend/tests/test_minute_strategy.py +++ b/backend/tests/test_minute_strategy.py @@ -2,7 +2,7 @@ 覆盖: - minute_red_streak 形态: 命中 / 不足根数不触发 / 最高K不红 / rank_by 两口径 / - 乱序输入 / 最高价并列取更晚K线 + 乱序输入 / 最高价并列取更晚K线 / 开盘窗口(当日最早N根, 与最近N根区分) - 引擎加载校验: 只能声明 filter_minute_history、timeframes 必须且只能是 ["1m"] - 引擎 1m 运行: enriched 联表基础过滤 (剔除ST / 股价区间)、entry hits、 日线 context 拒绝 @@ -145,6 +145,45 @@ def test_pattern_min_red_threshold_respected(): assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4}).is_empty() +def test_pattern_uses_opening_bars_even_if_day_turns_green(): + # 开盘7根 = 5红2绿命中; 第8/9根大绿回落 → 开盘窗口语义下仍命中, + # 且 close 取窗口末根 (10.8) 而非全天最新价 + candles = [ + (10.0, 10.2, 10.30), # 红 + (10.2, 10.1, 10.25), # 绿 (低高点) + (10.1, 10.4, 10.50), # 红 + (10.4, 10.6, 10.70), # 红 (次高) + (10.6, 10.5, 10.65), # 绿 (低高点) + (10.5, 10.7, 10.80), # 红 + (10.7, 10.8, 10.90), # 红 (最高) ← 窗口末根 + (10.8, 10.0, 10.85), # 开盘窗口外的绿 + (10.0, 9.5, 10.05), # 开盘窗口外的绿 + ] + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + assert out["symbol"].to_list() == ["600000.SH"] + row = out.row(0, named=True) + assert row["red_count"] == 5 + assert row["close"] == 10.8 # 窗口末根收盘, 不是第9根的 9.5 + assert row["last_datetime"] == datetime(2026, 8, 25, 9, 36) + + +def test_pattern_opening_window_miss_not_rescued_by_late_reds(): + # 开盘7根仅4红不命中; 第8/9根转红 (最近7根口径会命中) → 开盘窗口仍不触发 + candles = [ + (10.0, 10.2, 10.30), # 红 + (10.2, 10.1, 10.25), # 绿 + (10.1, 10.4, 10.50), # 红 + (10.4, 10.3, 10.45), # 绿 + (10.3, 10.6, 10.70), # 红 + (10.6, 10.5, 10.65), # 绿 + (10.5, 10.8, 10.90), # 红 + (10.8, 10.9, 11.00), # 红 (窗口外) + (10.9, 11.0, 11.10), # 红 (窗口外) + ] + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + assert out.is_empty() + + # ── 引擎加载与运行 ────────────────────────────────────────────────── diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index f515151..7c435cc 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -308,57 +308,6 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {/* ========== 左列 ========== */}
{/* 行情状态 — 开关 + 间隔 */} - {/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */} - - save({ minute_refresh_enabled: v })} - disabled={!hasIntradayBatchCap || !!rs?.custom_provider_active} - /> -
-
-
-
刷新间隔
-
- 交易时段内全市场脉冲落盘一轮的间隔; 下限 60s 保证不超 intraday.batch 配额 -
-
- - {minuteRefreshIntervalDraft >= 60 && minuteRefreshIntervalDraft % 60 === 0 ? `${minuteRefreshIntervalDraft / 60}m` : `${minuteRefreshIntervalDraft}s`} - -
-
- setMinuteRefreshIntervalDraft(parseInt(e.target.value, 10))} - className="flex-1 h-1 accent-accent cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" - /> - - {minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '60s — 300s'} - -
- {rs?.available && rs.rounds != null && rs.rounds > 0 && ( -
- 已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求 - {rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''} - {rs.last_error ? ` · ${rs.last_error}` : ''} -
- )} -
-
-
+ {/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */} + + save({ minute_refresh_enabled: v })} + disabled={!hasIntradayBatchCap || !!rs?.custom_provider_active} + /> +
+
+
+
刷新间隔
+
+ 交易时段内全市场脉冲落盘一轮的间隔; 下限 60s 保证不超 intraday.batch 配额 +
+
+ + {minuteRefreshIntervalDraft >= 60 && minuteRefreshIntervalDraft % 60 === 0 ? `${minuteRefreshIntervalDraft / 60}m` : `${minuteRefreshIntervalDraft}s`} + +
+
+ setMinuteRefreshIntervalDraft(parseInt(e.target.value, 10))} + className="flex-1 h-1 accent-accent cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" + /> + + {minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '60s — 300s'} + +
+ {rs?.available && rs.rounds != null && rs.rounds > 0 && ( +
+ 已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求 + {rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''} + {rs.last_error ? ` · ${rs.last_error}` : ''} +
+ )} +
+
+ {/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。 飞书 / 企业微信。 每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */} From bfa87d1f0b0356126dca8ece3121d69242d68dd9 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:08 +0800 Subject: [PATCH 05/51] =?UTF-8?q?fix(screener):=20=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=B1=A0=E6=8C=89=E6=97=A5=E7=BA=BF/=E5=88=86=E9=92=9F?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E9=9A=94=E7=A6=BB,=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E9=92=9F=E8=A7=86=E8=A7=92=E4=B8=8B=E6=95=B4=E6=B1=A0?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原设计: 日线/分钟共用一份策略池。切到分钟模式后, 池中 15 个日线策略 在分钟策略列表里解析不到, 策略池对话框右侧全部标成「失效」; 此时确认 还会把混合名单回写全局池, 日线池同样被污染。 - 池按周期隔离: 'strategy-pool' (日线, 沿用原 key 存量无损) + 'strategy-pool-1m' (分钟, 新 key); useStrategyPool(timeframe) 返回 当前周期的池, 任一池变更幂等落库双 key, 切换周期不会交叉写 - 「失效」徽章回归本义: 仅当策略真被删除 (不在本周期列表) 时出现 - prune 解除仅日线限制: 各周期用自身策略列表清理各自的池, 互不误删 - 策略构建器/叠加策略创建的是日线策略, 固定入日线池 (即使分钟页保存) 验证: 分钟对话框无失效列表、可正常添加分钟红7; 日线 15/31 ↔ 分钟 1/1 往返切换稳定; 刷新后双池持久。 注: 开发中 vite HMR 热替换瞬间曾把本地日线池写空 (旧代码对话框与新 模块混布的开发态一次性产物, 生产构建无 HMR 不受影响); 已从用户截图 复原原 15 策略及顺序 (boll_breakout → five_consecutive_yang)。 --- frontend/src/lib/storage.ts | 4 +- frontend/src/lib/useStrategyPool.ts | 57 ++++++++++++++++++++++------- frontend/src/pages/Screener.tsx | 11 +++--- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index a340ff5..91b56d7 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -24,8 +24,10 @@ export const storage = { /** 查询轮询 / SSE 配置 */ queryConfig: kv('tf-stocks-query-config'), - /** 策略池 (screener) */ + /** 策略池 (screener) — 日线池 */ strategyPool: kv('strategy-pool'), + /** 策略池 (screener) — 分钟池 (与日线池按周期隔离) */ + strategyPoolMinute: kv('strategy-pool-1m'), /** 自选列表列配置 */ watchlistColumns: kv('watchlist_columns'), diff --git a/frontend/src/lib/useStrategyPool.ts b/frontend/src/lib/useStrategyPool.ts index 7fcc86a..2195acf 100644 --- a/frontend/src/lib/useStrategyPool.ts +++ b/frontend/src/lib/useStrategyPool.ts @@ -1,36 +1,65 @@ import { useState, useCallback, useEffect } from 'react' import { storage } from '@/lib/storage' -export function useStrategyPool() { - const [pool, setPool] = useState(() => storage.strategyPool.get([])) +export type StrategyTimeframe = '1d' | '1m' - // 同步写入 localStorage - useEffect(() => { storage.strategyPool.set(pool) }, [pool]) +// 两周期各自独立持久化, 互不可见: 日线池只装日线策略, 分钟池只装分钟策略。 +// 切换周期时 pool 指向对应列表, 任何写操作都只会落到所属 key。 +const POOL_STORES: Record = { + '1d': storage.strategyPool, + '1m': storage.strategyPoolMinute, +} - const addToPool = useCallback((id: string) => { - setPool(prev => prev.includes(id) ? prev : [...prev, id]) - }, []) +/** + * 策略池 — 按周期 (日线/分钟) 隔离的两份池。 + * pool 为当前周期的列表; addToPool 可显式指定目标周期 + * (如策略构建器创建的是日线策略, 即使在分钟页保存也应入日线池)。 + */ +export function useStrategyPool(timeframe: StrategyTimeframe = '1d') { + const [pools, setPools] = useState>(() => ({ + '1d': storage.strategyPool.get([]), + '1m': storage.strategyPoolMinute.get([]), + })) + const pool = pools[timeframe] + + // 任一池变化即整体落库 (两份 key 幂等重写, 避免"只写当前池"漏掉非活跃池的更新) + useEffect(() => { + POOL_STORES['1d'].set(pools['1d']) + POOL_STORES['1m'].set(pools['1m']) + }, [pools]) + + const mutate = useCallback( + (tf: StrategyTimeframe, updater: (prev: string[]) => string[]) => { + setPools(prev => ({ ...prev, [tf]: updater(prev[tf]) })) + }, + [], + ) + + const addToPool = useCallback((id: string, tf: StrategyTimeframe = timeframe) => { + mutate(tf, prev => (prev.includes(id) ? prev : [...prev, id])) + }, [mutate, timeframe]) const removeFromPool = useCallback((id: string) => { - setPool(prev => prev.filter(x => x !== id)) - }, []) + mutate(timeframe, prev => prev.filter(x => x !== id)) + }, [mutate, timeframe]) const reorderPool = useCallback((newOrder: string[]) => { - setPool(newOrder) - }, []) + mutate(timeframe, () => newOrder) + }, [mutate, timeframe]) // 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。 + // 调用方需以"当前周期自己的策略列表"传入, 各周期只清理各自的池。 // 仅当确实有失效项时才更新,避免无谓重渲染。 const prune = useCallback((validIds: Iterable) => { const validSet = validIds instanceof Set ? validIds : new Set(validIds) - setPool(prev => { + mutate(timeframe, prev => { if (prev.length === 0) return prev const next = prev.filter(id => validSet.has(id)) return next.length === prev.length ? prev : next }) - }, []) + }, [mutate, timeframe]) const isInPool = useCallback((id: string) => pool.includes(id), [pool]) - return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool } + return { pool, pools, addToPool, removeFromPool, reorderPool, prune, isInPool } } diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index bd1e141..c4171c3 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -55,7 +55,7 @@ export function Screener() { const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create') const [showStore, setShowStore] = useState(false) const [showComposite, setShowComposite] = useState(false) - const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool() + const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool(timeframe) const [cardSize, setCardSize] = useState(loadCardSize) // 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化) const [dailyKChartVisible, setDailyKChartVisible] = useState(() => storage.screenerCandle.get(true)) @@ -203,14 +203,13 @@ export function Screener() { // 关键: 仅当本次拉取成功且返回非空列表时才 prune。 // 拉取中/失败/返回空(如引擎 reload 瞬时把某策略跳过)时一律不碰池, // 否则会把用户池里仍有效的 ID 永久清空并写入 localStorage,导致卡片全没。 + // 日线/分钟池按周期隔离, 各自用自身周期的列表清理, 互不影响。 useEffect(() => { if (strategies.isError) return // 拉取失败: 不 prune if (!strategies.isSuccess) return // 加载中: 不 prune if (allStrategyIds.size === 0) return // 空列表: 不 prune - // 分钟模式的列表只含分钟策略, prune 会误删池中的日线策略 → 仅日线模式清理 - if (timeframe !== '1d') return prune(allStrategyIds) - }, [allStrategyIds, prune, strategies.isError, strategies.isSuccess, timeframe]) + }, [allStrategyIds, prune, strategies.isError, strategies.isSuccess]) // 策略文件加载失败时提示用户(避免"策略静默消失"被误判为正常) const loadErrors = strategies.data?.load_errors ?? [] @@ -1056,7 +1055,7 @@ export function Screener() { if (!data.presets.some(s => s.id === id)) { throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`) } - addToPool(id) + addToPool(id, '1d') // 构建器创建的是日线策略, 即使在分钟页保存也入日线池 }} /> @@ -1065,7 +1064,7 @@ export function Screener() { onClose={() => setShowComposite(false)} onSavedId={async id => { await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) - addToPool(id) + addToPool(id, '1d') // 叠加策略为日线策略, 固定入日线池 }} /> From b82c4eaedbc59a08a04e854eb0eaa7621f057092 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:09 +0800 Subject: [PATCH 06/51] =?UTF-8?q?feat(minute):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E7=BA=A27=E6=96=B0=E5=A2=9E=E3=80=8CN=E6=97=A5=E5=86=85?= =?UTF-8?q?=E6=B6=A8=E5=81=9C=E8=BF=87=E3=80=8D=E6=9D=A1=E4=BB=B6=20+=20mi?= =?UTF-8?q?nute=5Ffilter=20=E6=97=A5=E7=BA=BF=E7=AA=97=E5=8F=A3=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分钟策略此前只能访问当日分钟窗口, 无法叠加日线维度条件。本次为 minute_filter 后端扩展可选日线历史契约: - 契约: 策略声明 META["daily_history_bars"] (0-250) + filter_minute_history 接受 daily 关键字 (加载期校验, 纯分钟策略零改动); 引擎聚合各策略声明 (minute_daily_history_bars) 后由 ScreenerService 1m 分支装配 context.daily_history (enriched 日线窗口), run() 以 daily= 注入 - 分钟红7: require_limit_up (默认开) + limit_up_days (5-60, 默认20) 参数; 涨停判定复用 enriched 预计算信号 signal_limit_up (收盘封板) 或 signal_broken_limit_up (炸板盘中触及), 任一命中即算涨停过, 输出 recent_limit_ups 次数列; 日线窗口缺失时失败闭合 (宁可漏过不可错报) - 测试 25 项: 涨停信号过滤/炸板计数/回看窗口边界(第20日含第21日不含)/ 失败闭合/开关旁路/加载校验(缺 daily 关键字与超范围)/引擎注入/服务装配 实盘验证: 用户参数(bars=6)基线 24 只 → 要求涨停过后 6 只, 全部带 recent_limit_ups; 全量 1112 项后端测试通过。 --- backend/app/services/screener.py | 8 + .../app/strategy/builtin/minute_red_streak.py | 61 +++++- backend/app/strategy/engine.py | 39 +++- backend/tests/test_minute_strategy.py | 192 +++++++++++++++++- 4 files changed, 285 insertions(+), 15 deletions(-) diff --git a/backend/app/services/screener.py b/backend/app/services/screener.py index 134cc90..c11cf64 100644 --- a/backend/app/services/screener.py +++ b/backend/app/services/screener.py @@ -390,12 +390,20 @@ class ScreenerService: # 分钟策略数据源是本地当日分钟K分区 (单分区文件直读), 与日线 # enriched 历史窗口无关, 不走 required_history_bars 日线路径。 history = self._load_minute_history(as_of, current) + # 策略声明 META["daily_history_bars"] 时额外装配日线 enriched 窗口, + # 供分钟策略叠加日线维度条件 (如 N 日内涨停过)。 + daily_history = None + if engine is not None: + daily_bars = engine.minute_daily_history_bars(strategy_ids) + if daily_bars > 0: + daily_history = self._load_enriched_history(as_of, daily_bars) return StrategyDataContext( asset_type=self.asset_type, timeframe=timeframe, as_of=as_of, current=current, history=history, + daily_history=daily_history, market=None, cache_key=cache_key, ) diff --git a/backend/app/strategy/builtin/minute_red_streak.py b/backend/app/strategy/builtin/minute_red_streak.py index 58bf2d3..9d2119d 100644 --- a/backend/app/strategy/builtin/minute_red_streak.py +++ b/backend/app/strategy/builtin/minute_red_streak.py @@ -4,6 +4,11 @@ (symbol, datetime, open, high, low, close, volume, amount), 由 ScreenerService.build_strategy_context 的 1m 分支从本地 kline_minute 分区注入; 策略本身不感知数据来源 (本地同步 / 盘中增量刷新对它透明)。 + +META["daily_history_bars"] 声明叠加日线维度的条件 (N 日内涨停过): +引擎会以 daily= 关键字注入日线 enriched 窗口, 涨停判定直接复用 +enriched 预计算信号 — signal_limit_up (收盘封板) 或 signal_broken_limit_up +(炸板: 盘中触及涨停未封住), 任一命中即算"盘中涨停过"。 """ import polars as pl @@ -11,10 +16,12 @@ import polars as pl META = { "id": "minute_red_streak", "name": "分钟红7", - "description": "开盘前7根1分钟K至少5根收红, 且最高的2根(按最高价)都是红K", + "description": "开盘前7根1分钟K至少5根收红, 最高的2根(按最高价)都是红K, 且近20日盘中触及过涨停", "tags": ["分钟", "形态", "短线"], "asset_types": ["stock"], "timeframes": ["1m"], + # 日线 enriched 窗口 (交易日语义, 含 as_of): 覆盖 limit_up_days 参数上限 + "daily_history_bars": 60, "params": [ { "id": "bars", @@ -49,6 +56,21 @@ META = { "type": "bool", "default": False, }, + { + "id": "require_limit_up", + "label": "要求N日内涨停过", + "type": "bool", + "default": True, + }, + { + "id": "limit_up_days", + "label": "涨停回看天数", + "type": "int", + "default": 20, + "min": 5, + "max": 60, + "step": 1, + }, ], "order_by": "red_count", "descending": True, @@ -60,12 +82,41 @@ ENTRY_SIGNALS: list[str] = [] EXIT_SIGNALS: list[str] = [] -def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: +def _recent_limit_ups(daily: pl.DataFrame | None, lookback: int) -> pl.DataFrame: + """日线窗口 → (symbol, recent_limit_ups) 近 lookback 个交易日的涨停次数。 + + 涨停过 = signal_limit_up (收盘封板) 或 signal_broken_limit_up (炸板触及)。 + 日线窗口缺失 / 无涨停信号列 → 返回空表 (调用方 inner join 即失败闭合, + 宁可漏过不可错报)。 + """ + empty = pl.DataFrame(schema={"symbol": pl.Utf8, "recent_limit_ups": pl.UInt32}) + if daily is None or daily.is_empty(): + return empty + if not {"signal_limit_up", "signal_broken_limit_up"}.issubset(daily.columns): + return empty + return ( + daily.select("symbol", "date", "signal_limit_up", "signal_broken_limit_up") + .sort(["symbol", "date"]) + .filter(pl.int_range(pl.len()).over("symbol") >= pl.len().over("symbol") - lookback) + .group_by("symbol") + .agg( + recent_limit_ups=( + pl.col("signal_limit_up").fill_null(False) + | pl.col("signal_broken_limit_up").fill_null(False) + ).sum() + ) + .filter(pl.col("recent_limit_ups") > 0) + ) + + +def filter_minute_history(df: pl.DataFrame, params: dict, *, daily: pl.DataFrame | None = None) -> pl.DataFrame: """红K形态过滤: 全向量化, 无逐行 Python 循环。 - 每标的按时间取当日最早 bars 根 (开盘窗口); 不足 bars 根不触发 - 红 = close > open; 窗口内红K数 >= min_red - 按 rank_by (high / close) 降序取前 top_red 根, 同值取时间更晚者, 需全红 + - require_limit_up: 近 limit_up_days 个交易日盘中触及过涨停 (日线维度, + 由 daily 窗口的预计算涨停信号判定; 窗口缺失时失败闭合不触发) """ bars = int(params.get("bars") or 7) min_red = min(int(params.get("min_red") or 5), bars) @@ -99,7 +150,7 @@ def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: .agg(top_red_count=pl.col("_red").sum()) ) - return ( + result = ( window.join(top, on="symbol", how="inner") .filter( (pl.col("bars_checked") >= bars) @@ -108,3 +159,7 @@ def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame: ) .drop("bars_checked") ) + if params.get("require_limit_up", True): + lookback = max(5, min(int(params.get("limit_up_days") or 20), 60)) + result = result.join(_recent_limit_ups(daily, lookback), on="symbol", how="inner") + return result diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 42dd140..517e897 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -155,6 +155,9 @@ class StrategyDataContext: as_of: date current: pl.DataFrame | None = None history: pl.DataFrame | None = None + # 仅 1m 分支: 策略声明 META["daily_history_bars"] 时注入的日线 enriched 窗口, + # 供分钟策略叠加日线维度条件 (如 N 日内涨停过); 未声明时为 None。 + daily_history: pl.DataFrame | None = None market: Any | None = None cache_key: str | None = None @@ -201,6 +204,9 @@ class StrategyDef: composite: CompositeSpec | None = None # 仅 backend=="composite" 时非空 # 仅 backend=="minute_filter" 时非空: 输入为当日分钟K窗口, 输出为命中标的行 filter_minute_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None = None + # 仅 minute_filter: META["daily_history_bars"] 声明需要的日线历史窗口 (0=不需要; + # >0 时 filter_minute_history 必须接受 daily 关键字, 引擎注入 context.daily_history) + minute_daily_bars: int = 0 @dataclass @@ -493,6 +499,7 @@ class StrategyEngine: matrix_strategy = getattr(mod, "MATRIX_STRATEGY", None) composite_spec: CompositeSpec | None = None + minute_daily_bars = 0 if execution_backend == "matrix_native": from app.backtest.matrix import MatrixStrategy @@ -535,6 +542,22 @@ class StrategyEngine: raise ValueError( "minute_filter strategy must declare timeframes == ['1m']" ) + # 可选日线历史窗口: 声明 daily_history_bars 时 fn 必须接受 daily 关键字, + # 引擎会把 context.daily_history (enriched 日线窗口) 注入进来。 + minute_daily_bars = int(meta.get("daily_history_bars") or 0) + if minute_daily_bars < 0 or minute_daily_bars > 250: + raise ValueError( + "minute_filter daily_history_bars must be within [0, 250]" + ) + if minute_daily_bars > 0: + import inspect + + sig = inspect.signature(filter_minute_history_fn) + if "daily" not in sig.parameters: + raise ValueError( + "minute_filter daily_history_bars requires " + "filter_minute_history to accept a 'daily' keyword" + ) elif filter_history_fn is None or filter_fn is not None: raise ValueError("python_history_legacy strategy must declare only filter_history") @@ -559,6 +582,7 @@ class StrategyEngine: matrix_strategy=matrix_strategy, composite=composite_spec, filter_minute_history_fn=filter_minute_history_fn, + minute_daily_bars=minute_daily_bars, ) def reload(self) -> None: @@ -673,6 +697,16 @@ class StrategyEngine: return None return max(0, int(value)) + def minute_daily_history_bars(self, strategy_ids: list[str]) -> int: + """1m 分支需要的日线 enriched 窗口大小: 各 minute_filter 策略声明的 + META["daily_history_bars"] 取 max, 未声明 (纯分钟策略) 为 0。""" + required = 0 + for strategy_id in strategy_ids: + strategy = self.get(strategy_id) + if strategy.execution_backend == "minute_filter": + required = max(required, strategy.minute_daily_bars) + return required + def required_history_bars( self, strategy_ids: list[str], @@ -917,7 +951,10 @@ class StrategyEngine: strategy_id=strategy_id, exit_signal_hits=exit_signal_hits, ) - df = s.filter_minute_history_fn(history, params) + if s.minute_daily_bars > 0: + df = s.filter_minute_history_fn(history, params, daily=context.daily_history) + else: + df = s.filter_minute_history_fn(history, params) # 基础过滤/展示列 (name/total_shares/change_pct 等) 来自 enriched 快照, # 在命中结果上事后联表, 避免把 enriched 列铺到全市场分钟行上。 if current is not None and not current.is_empty(): diff --git a/backend/tests/test_minute_strategy.py b/backend/tests/test_minute_strategy.py index b20d480..894f580 100644 --- a/backend/tests/test_minute_strategy.py +++ b/backend/tests/test_minute_strategy.py @@ -52,7 +52,7 @@ def test_pattern_hits_five_red_of_seven_with_red_top_two(): (10.5, 10.7, 10.80), # 红 (10.7, 10.8, 10.90), # 红 (最高) ] - out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False}) assert out["symbol"].to_list() == ["600000.SH"] row = out.row(0, named=True) assert row["red_count"] == 5 @@ -61,7 +61,7 @@ def test_pattern_hits_five_red_of_seven_with_red_top_two(): def test_pattern_insufficient_bars_never_triggers(): - out = minute_red_streak.filter_minute_history(_bars("600000.SH", [(10.0, 10.2, 10.3)] * 6), {}) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", [(10.0, 10.2, 10.3)] * 6), {"require_limit_up": False}) assert out.is_empty() @@ -76,7 +76,7 @@ def test_pattern_green_at_top_blocks_hit(): (11.5, 11.0, 12.00), # 绿 (最高) (11.0, 11.4, 11.90), # 红 (次高) ] - out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False}) assert out.is_empty() @@ -91,9 +91,9 @@ def test_pattern_rank_by_close_uses_close_not_high(): (11.4, 11.1, 11.50), # 绿 (high 最高, 并列) (11.1, 11.5, 11.55), # 红 (close 最高) ] - by_high = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + by_high = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False}) by_close = minute_red_streak.filter_minute_history( - _bars("600000.SH", candles), {"rank_by_close": True} + _bars("600000.SH", candles), {"rank_by_close": True, "require_limit_up": False} ) assert by_high.is_empty() assert by_close["symbol"].to_list() == ["600000.SH"] @@ -107,7 +107,7 @@ def test_pattern_sorts_unordered_input_by_datetime(): (10.6, 10.5, 10.65), (10.5, 10.7, 10.80), (10.7, 10.8, 10.90), ]), ]).sample(fraction=1.0, shuffle=True, seed=7) - out = minute_red_streak.filter_minute_history(bars, {}) + out = minute_red_streak.filter_minute_history(bars, {"require_limit_up": False}) assert out["symbol"].to_list() == ["600000.SH"] assert out.row(0, named=True)["close"] == 10.8 # 最后一根(时间最大)的收盘 @@ -124,7 +124,7 @@ def test_pattern_three_way_high_tie_prefers_later_bars(): (10.5, 10.6, 10.90), # 红 (并列最高, 中间) (10.6, 10.8, 10.90), # 红 (并列最高, 最晚) ] - out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False}) assert out["symbol"].to_list() == ["600000.SH"] assert out.row(0, named=True)["top_red_count"] == 2 @@ -141,8 +141,8 @@ def test_pattern_min_red_threshold_respected(): (10.5, 10.8, 10.90), # 红 ] bars = _bars("600000.SH", candles) - assert minute_red_streak.filter_minute_history(bars, {"min_red": 5}).is_empty() - assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4}).is_empty() + assert minute_red_streak.filter_minute_history(bars, {"min_red": 5, "require_limit_up": False}).is_empty() + assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4, "require_limit_up": False}).is_empty() def test_pattern_uses_opening_bars_even_if_day_turns_green(): @@ -159,7 +159,7 @@ def test_pattern_uses_opening_bars_even_if_day_turns_green(): (10.8, 10.0, 10.85), # 开盘窗口外的绿 (10.0, 9.5, 10.05), # 开盘窗口外的绿 ] - out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False}) assert out["symbol"].to_list() == ["600000.SH"] row = out.row(0, named=True) assert row["red_count"] == 5 @@ -180,10 +180,84 @@ def test_pattern_opening_window_miss_not_rescued_by_late_reds(): (10.8, 10.9, 11.00), # 红 (窗口外) (10.9, 11.0, 11.10), # 红 (窗口外) ] - out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {}) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False}) assert out.is_empty() +# ── 涨停条件 (日线维度) ───────────────────────────────────────────── + + +_HIT_CANDLES = [ + (10.0, 10.2, 10.30), # 红 + (10.2, 10.1, 10.25), # 绿 (低高点) + (10.1, 10.4, 10.50), # 红 + (10.4, 10.6, 10.70), # 红 (次高) + (10.6, 10.5, 10.65), # 绿 (低高点) + (10.5, 10.7, 10.80), # 红 + (10.7, 10.8, 10.90), # 红 (最高) +] + + +def _daily( + symbol: str, + days: int, + flag_on: set[int] | None = None, + *, + broken: bool = False, +) -> pl.DataFrame: + """days 个交易日的日线帧; flag_on 指定第几天 (0=最早) 触发涨停信号。""" + flag_on = flag_on or set() + base = date(2026, 8, 25) + return pl.DataFrame({ + "symbol": [symbol] * days, + "date": [base - _dt.timedelta(days=days - i) for i in range(days)], + "signal_limit_up": [i in flag_on and not broken for i in range(days)], + "signal_broken_limit_up": [i in flag_on and broken for i in range(days)], + }) + + +def test_pattern_limit_up_condition_filters_by_daily_signals(): + bars = pl.concat([ + _bars("600001.SH", _HIT_CANDLES), + _bars("600002.SH", _HIT_CANDLES), + _bars("600003.SH", _HIT_CANDLES), + ]) + daily = pl.concat([ + _daily("600001.SH", 20, {3}), # 收盘涨停 → 过 + _daily("600002.SH", 20, {15}, broken=True), # 炸板触及 → 过 + _daily("600003.SH", 20), # 无涨停 → 剔除 + ]) + out = minute_red_streak.filter_minute_history(bars, {}, daily=daily) + assert sorted(out["symbol"].to_list()) == ["600001.SH", "600002.SH"] + assert sorted(out["recent_limit_ups"].to_list()) == [1, 1] + + +def test_pattern_limit_up_lookback_window_boundary(): + # 25 个交易日, 涨停仅发生在第 5 天 (0=最早): 回看 20 日窗口 = 最后 20 根 + # (索引 5..24), 第 5 天在窗外 → 不命中; 回看放宽到 25 → 命中 + bars = _bars("600000.SH", _HIT_CANDLES) + daily = _daily("600000.SH", 25, {4}) + assert minute_red_streak.filter_minute_history(bars, {}, daily=daily).is_empty() + out = minute_red_streak.filter_minute_history( + bars, {"limit_up_days": 25}, daily=daily + ) + assert out["symbol"].to_list() == ["600000.SH"] + + +def test_pattern_limit_up_fails_closed_without_daily(): + # 日线窗口缺失时失败闭合 (宁可漏过不可错报) + out = minute_red_streak.filter_minute_history(_bars("600000.SH", _HIT_CANDLES), {}) + assert out.is_empty() + + +def test_pattern_limit_up_disabled_ignores_daily(): + out = minute_red_streak.filter_minute_history( + _bars("600000.SH", _HIT_CANDLES), {"require_limit_up": False} + ) + assert out["symbol"].to_list() == ["600000.SH"] + assert "recent_limit_ups" not in out.columns + + # ── 引擎加载与运行 ────────────────────────────────────────────────── @@ -225,6 +299,75 @@ def test_minute_filter_backend_validation(tmp_path): assert any("timeframes" in e["error"] for e in engine.load_errors()) +def test_minute_filter_daily_history_validation(tmp_path): + # 声明 daily_history_bars: fn 必须接受 daily 关键字, 且范围 [0, 250] + (tmp_path / "m_daily_ok.py").write_text( + 'import polars as pl\n' + 'META = {"id": "m_daily_ok", "name": "x", "asset_types": ["stock"], ' + '"timeframes": ["1m"], "daily_history_bars": 20}\n' + 'EXECUTION_BACKEND = "minute_filter"\n' + 'def filter_minute_history(df, params, *, daily=None):\n' + ' return df.group_by("symbol").agg(close=pl.col("close").max())\n' + ) + (tmp_path / "m_daily_kw.py").write_text( + 'import polars as pl\n' + 'META = {"id": "m_daily_kw", "name": "x", "asset_types": ["stock"], ' + '"timeframes": ["1m"], "daily_history_bars": 20}\n' + 'EXECUTION_BACKEND = "minute_filter"\n' + 'def filter_minute_history(df, params):\n' + ' return df.group_by("symbol").agg(close=pl.col("close").max())\n' + ) + (tmp_path / "m_daily_range.py").write_text( + 'import polars as pl\n' + 'META = {"id": "m_daily_range", "name": "x", "asset_types": ["stock"], ' + '"timeframes": ["1m"], "daily_history_bars": 300}\n' + 'EXECUTION_BACKEND = "minute_filter"\n' + 'def filter_minute_history(df, params, *, daily=None):\n' + ' return df.group_by("symbol").agg(close=pl.col("close").max())\n' + ) + engine = StrategyEngine(strategy_dirs=[tmp_path]) + assert engine.has("m_daily_ok") + assert engine.get("m_daily_ok").minute_daily_bars == 20 + assert not engine.has("m_daily_kw") + assert not engine.has("m_daily_range") + assert any("'daily' keyword" in e["error"] for e in engine.load_errors()) + assert any("[0, 250]" in e["error"] for e in engine.load_errors()) + + +def test_minute_run_injects_daily_history(tmp_path): + # fn 直接消费 daily (对涨停信号求和), 验证引擎把 context.daily_history 注入 + (tmp_path / "m_use_daily.py").write_text( + 'import polars as pl\n' + 'META = {"id": "m_use_daily", "name": "x", "asset_types": ["stock"], ' + '"timeframes": ["1m"], "daily_history_bars": 10}\n' + 'EXECUTION_BACKEND = "minute_filter"\n' + 'def filter_minute_history(df, params, *, daily=None):\n' + ' if daily is None:\n' + ' return pl.DataFrame(schema={"symbol": pl.Utf8})\n' + ' return daily.group_by("symbol").agg(\n' + ' close=pl.col("signal_limit_up").sum() + 10.0)\n' + ) + engine = StrategyEngine(strategy_dirs=[tmp_path]) + context = StrategyDataContext( + asset_type="stock", + timeframe="1m", + as_of=date(2026, 8, 25), + current=pl.DataFrame({ + "symbol": ["600001.SH"], + "name": ["正常股"], + "total_shares": [1e8], + "float_shares": [5e7], + "amount": [3e8], + "change_pct": [0.01], + }), + history=_bars("600001.SH", [(10.0, 10.2, 10.3)] * 7), + daily_history=_daily("600001.SH", 10, {2}), + ) + result = engine.run("m_use_daily", context) + assert result.total == 1 + assert result.rows[0]["close"] == 11 # 10 + 窗口内 1 次收盘涨停 + + def test_minute_context_run_applies_enriched_basic_filter(tmp_path): (tmp_path / "m_basic.py").write_text(_minute_code("m_basic")) engine = StrategyEngine(strategy_dirs=[tmp_path]) @@ -345,3 +488,30 @@ def test_minute_context_rejects_non_stock_asset(): raise AssertionError("expected ValueError") except ValueError as e: assert "A 股" in str(e) + + +def test_minute_context_loads_daily_history_for_declared_strategies(): + class _FakeEngine: + def minute_daily_history_bars(self, strategy_ids): + return 5 + + daily = _daily("600001.SH", 6, {1}) + repo = _FakeMinuteRepo({date(2026, 8, 25): _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3)}) + repo.get_enriched_history = lambda target_date, lookback_days: daily # type: ignore[method-assign] + repo.get_instruments_asset = lambda asset_type: None # type: ignore[method-assign] + svc = ScreenerService(repo, asset_type="stock") # type: ignore[arg-type] + ctx = svc.build_strategy_context( + _FakeEngine(), date(2026, 8, 25), ["m_x"], timeframe="1m", + current=pl.DataFrame({"symbol": ["600001.SH"], "name": ["x"]}), + ) + assert ctx.daily_history is not None + assert ctx.daily_history.height == 6 # 引擎声明 5 → 装配日线窗口 + + +def test_minute_context_without_engine_skips_daily_history(): + svc = _svc({date(2026, 8, 25): _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3)}) + ctx = svc.build_strategy_context( + None, date(2026, 8, 25), [], timeframe="1m", + current=pl.DataFrame({"symbol": ["600001.SH"]}), + ) + assert ctx.daily_history is None # 无引擎声明 → 不装配日线 From a342d94238749c8fbf8a3f4fd63ff7befb7c49a5 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:10 +0800 Subject: [PATCH 07/51] =?UTF-8?q?feat(screener):=20=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=B1=A0=E7=BB=9F=E4=B8=80=20=E2=80=94=20=E6=97=A5=E7=BA=BF/?= =?UTF-8?q?=E5=88=86=E9=92=9F=E5=90=88=E5=B9=B6=E5=8D=95=E6=B1=A0,=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=8C=89=E7=AD=96=E7=95=A5=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原设计: 日线/分钟双池隔离 (strategy-pool / strategy-pool-1m), 页面按 周期开关切换两套世界。拆池根因是列表按周期过滤 + 页面级周期切换: 分钟 视角下池内日线策略解析不到才显示「失效」, 属于修补表象而非消除根因。 - 池合并: 双 key 一次性迁移为单池 (日线在前、分钟在后、按 ID 去重, 完成后删旧分钟 key 保证幂等, StrictMode/HMR 重放安全) - 执行路由真相来源从 UI 开关改为策略自身 timeframes 声明: 日线走盘后 缓存/runAll, 分钟走本地分钟K分区实时单跑 (卡片点击/参数保存重跑均 按各自周期传参) - 周期开关降级为三态显示筛选 (全部/日线/分钟, 默认全部), 只过滤卡片 显示, 不影响池与执行; runAll 自动跑仅覆盖池内日线子集 - ETF 置灰移除: 分钟策略 asset_types 仅股票, ETF 列表自然不含 - 池对话框取全量列表, 待选/已选两侧显示「分钟」徽章; 失效判定回归 全周期合并列表, 消解「切视角整池失效」问题类别 - 后端零改动: /api/strategies 的 timeframe 为空本就不过滤, 前端 'all' 时省略参数; 其余依赖默认日线过滤的 strategyList 调用方行为不变 验证: pnpm build 通过; git diff --check 干净; 3 处 run.mutate 调用点 均带周期参数; 迁移幂等路径 (双池/仅分钟/空池) 走查通过。界面手工验证 待实机执行 (池迁移/分钟徽章/筛选空态/ETF 视角)。 --- .../screener/StrategyPoolDialog.tsx | 15 +- frontend/src/lib/api.ts | 9 +- frontend/src/lib/storage.ts | 7 +- frontend/src/lib/useStrategyPool.ts | 74 ++++---- frontend/src/pages/Screener.tsx | 161 ++++++++++-------- 5 files changed, 148 insertions(+), 118 deletions(-) diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index 8f41e09..ffee88a 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -8,8 +8,6 @@ interface Props { pool: string[] onConfirm: (newPool: string[]) => void onClose: () => void - /** 列表周期: 1d 日线 / 1m 分钟, 与策略页当前周期一致 */ - timeframe?: '1d' | '1m' } const SOURCE_CLS: Record = { @@ -26,6 +24,8 @@ const SOURCE_LABEL: Record = { invalid: '失效', } +const TF_BADGE_CLS = 'text-[8px] px-1 py-px rounded border leading-tight shrink-0 border-purple-500/30 bg-purple-500/10 text-purple-400' + type SourceTab = 'all' | 'builtin' | 'custom' | 'ai' const TABS: { id: SourceTab; label: string }[] = [ @@ -44,7 +44,7 @@ function fileStem(name: string): string { return name.replace(/\.py$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') } -export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' }: Props) { +export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { const backdrop = useDialogBackdrop(onClose) // 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交 const [draftPool, setDraftPool] = useState(() => [...pool]) @@ -59,7 +59,8 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' const loadStrategies = useCallback(async () => { setLoading(true) try { - const d = await api.strategyList(undefined, timeframe) + // 不按周期过滤: 日线+分钟策略合并展示, 分钟策略以徽章区分 + const d = await api.strategyList(undefined, 'all') setAllStrategies(d.strategies) } catch { setAllStrategies([]) @@ -238,6 +239,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' {SOURCE_LABEL[s.source] ?? '内置'} + {s.timeframes?.includes('1m') && ( + 分钟 + )} ))} @@ -282,6 +286,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' {SOURCE_LABEL[src] ?? '内置'} + {s?.timeframes?.includes('1m') && ( + 分钟 + )} - ) - })} -
- {/* 周期切换: 日线 (盘后缓存) / 分钟 (本地分钟K分区实时计算) */} -
- {(['1d', '1m'] as const).map(tf => ( + {(['stock', 'etf'] as const).map(t => ( + ))} +
+ {/* 周期筛选: 全部 / 日线 / 分钟 — 只过滤卡片显示, 不影响池与执行路由 */} +
+ {(['all', '1d', '1m'] as const).map(tf => ( + ))}
@@ -754,13 +772,15 @@ export function Screener() { {cardSize !== 'hidden' && (
{strategies.isLoading &&
加载中…
} - {!strategies.isLoading && visiblePool.length === 0 && ( + {!strategies.isLoading && displayPool.length === 0 && (
- 策略池为空,点击右上角「策略池」按钮添加策略 + {pool.length === 0 + ? '策略池为空,点击右上角「策略池」按钮添加策略' + : '当前周期筛选下无策略,切换周期筛选或编辑策略池'}
)}
- {visiblePool.map(id => { + {displayPool.map(id => { const s = strategyMap.get(id) if (!s) return null return ( @@ -814,9 +834,9 @@ export function Screener() { / {showAll ? allRows.length : result!.total} )} - · {visiblePool.length} 策略 - {!showAll && visiblePool.length > 0 && ( - <> · 共 {visiblePool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} 只 + · {displayPool.length} 策略 + {!showAll && displayPool.length > 0 && ( + <> · 共 {displayPool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} 只 )} {runAll.isPending && ( @@ -1002,7 +1022,9 @@ export function Screener() { onSaved={(limit) => { if (settingsStrategyId) { setStrategyLimits(prev => ({ ...prev, [settingsStrategyId]: limit })) - run.mutate({ id: settingsStrategyId, date: asOf }) + // 按策略自身周期重跑: 日线用当前 asOf, 分钟实时单跑交后端取最新分区 + const tf = strategyMap.get(settingsStrategyId)?.timeframes?.includes('1m') ? '1m' as const : '1d' as const + run.mutate({ id: settingsStrategyId, date: tf === '1m' ? '' : asOf, timeframe: tf }) } }} onAiModify={async () => { @@ -1038,7 +1060,6 @@ export function Screener() { {showPoolDialog && ( { reorderPool(newPool) }} @@ -1055,7 +1076,7 @@ export function Screener() { if (!data.presets.some(s => s.id === id)) { throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`) } - addToPool(id, '1d') // 构建器创建的是日线策略, 即使在分钟页保存也入日线池 + addToPool(id) }} /> @@ -1064,7 +1085,7 @@ export function Screener() { onClose={() => setShowComposite(false)} onSavedId={async id => { await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) - addToPool(id, '1d') // 叠加策略为日线策略, 固定入日线池 + addToPool(id) }} /> From 8ccb68121429abbe033c204aae6d8fcaef027d52 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:10 +0800 Subject: [PATCH 08/51] =?UTF-8?q?feat(settings):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=8D=A1=E7=89=87=E5=AE=9A=E4=BD=8D=E9=97=AA?= =?UTF-8?q?=E7=83=81=E9=94=9A=E7=82=B9=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前「从业务页引导去设置」只有 depth-fix 一处孤例 (SealedBadge → 连板 梯队修正卡片闪烁), 其余 8 个引导链接只带 tab 参数跳转, 用户落地后要 自己在设置页里找目标卡片。 - 通用锚点机制: useCardFlash hook (/settings?tab=X&highlight= 打开时目标卡片 scrollIntoView 居中 + ring 高亮 2s, 一次会话仅触发 一次防 StrictMode 重放) + AnchorWrap 包裹层 (无统一 Card 组件的面板用) - Settings 把 highlight 传给全部面板 (面板类型本就声明了 highlight?: string) - 锚点落位: 监控页 Card 组件 anchor 化 — quotes(行情轮询)/webhooks(推送 通知)/minute-refresh(盘中分钟增量)/intraday-refresh(分时图刷新)/ depth-fix(原实现迁移到统一机制); AI ai-connection; 数据源 data-sources; 信号库 signals - 8 个引导链接补 &highlight=: RuleEditor/Review webhook 未配置提示 → webhooks; Layout 实时无权限/监控入口 → data-sources/quotes; Data 页 数据源入口 → data-sources; 信号触发器 → signals 验证: pnpm build 通过; 浏览器实测 8 个锚点全部滚动定位+闪烁; 顺带补验 40e54ea 统一策略池欠的界面项 (双池→统一迁移 17/33、分钟徽章、三态筛选 只过滤显示不动池、ETF 视角无分钟策略、分钟卡片实时单跑)。 --- frontend/src/components/Layout.tsx | 4 +- .../src/components/monitor/RuleEditor.tsx | 2 +- .../signals/SignalTriggerActions.tsx | 2 +- frontend/src/lib/useCardFlash.tsx | 54 +++++++++++++++++++ frontend/src/pages/Data.tsx | 4 +- frontend/src/pages/Review.tsx | 2 +- frontend/src/pages/Settings.tsx | 2 +- frontend/src/pages/settings/AI.tsx | 25 +++++++-- frontend/src/pages/settings/CustomSignals.tsx | 5 +- frontend/src/pages/settings/DataSources.tsx | 5 +- frontend/src/pages/settings/Monitoring.tsx | 52 +++++++++--------- 11 files changed, 114 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/useCardFlash.tsx diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2d68a6d..bab4ac8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -754,7 +754,7 @@ export function Layout() { 当前数据源无实时行情权限,
@@ -661,7 +661,7 @@ export function Data() { 当前无需 API Key,历史日K将使用免费通道获取。 实时行情、分钟K等能力取决于所选数据源,可在 - + 数据源设置 中配置。 diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index ede6109..2cc6b31 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -469,7 +469,7 @@ export function Review() {

手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的 Webhook 配置。 {((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && ( - setShowSchedule(false)}> + setShowSchedule(false)}> 前往配置 → )} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 67957be..4493733 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -125,7 +125,7 @@ export function Settings() { > {activeTab.key === 'monitoring' ? - : } + : }

diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index 0e7a248..727cc19 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, createContext, useContext } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield, @@ -8,6 +8,7 @@ import { import { useSettings } from '@/lib/useSharedQueries' import { api, type SettingsState } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash' // 统一的输入框样式(与项目其他设置页一致) const INPUT_CLS = @@ -70,7 +71,7 @@ const findPreset = (provider: string, baseUrl: string, codexCommand: string) => return provider === CODEX_PROVIDER ? p.codexCommand === codexCommand : p.url === baseUrl }) ?? PRESETS[0] -export function SettingsAIPanel() { +export function SettingsAIPanel({ highlight }: { highlight?: string } = {}) { const qc = useQueryClient() const settings = useSettings() const s = settings.data @@ -306,8 +307,9 @@ export function SettingsAIPanel() { } return ( +
- @@ -519,20 +521,27 @@ export function SettingsAIPanel() {
)}
+ ) } // ===== 通用卡片(与 Keys 页风格统一) ===== +// 卡片定位锚点: highlight= 时滚动到视口中央并闪烁 (见 useCardFlash) +const HighlightContext = createContext('') + interface CardProps { icon: React.ComponentType<{ className?: string }> title: string right?: React.ReactNode children: React.ReactNode + anchor?: string } -function Card({ icon: Icon, title, right, children }: CardProps) { - return ( +function Card({ icon: Icon, title, right, children, anchor }: CardProps) { + const highlight = useContext(HighlightContext) + const { ref, flash } = useCardFlash(anchor ? highlight : undefined, anchor ?? '') + const inner = (
@@ -544,6 +553,12 @@ function Card({ icon: Icon, title, right, children }: CardProps) { {children}
) + if (!anchor) return inner + return ( +
+ {inner} +
+ ) } // ===== 表单字段(统一 label + 输入框样式) ===== diff --git a/frontend/src/pages/settings/CustomSignals.tsx b/frontend/src/pages/settings/CustomSignals.tsx index d445b22..c19b12d 100644 --- a/frontend/src/pages/settings/CustomSignals.tsx +++ b/frontend/src/pages/settings/CustomSignals.tsx @@ -6,6 +6,7 @@ import { QK } from '@/lib/queryKeys' import { BUILTIN_SIGNAL_DEFINITIONS, type SignalKind } from '@/lib/signals' import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog' import { Skeleton } from '@/components/data/Skeleton' +import { AnchorWrap } from '@/lib/useCardFlash' type SignalSection = 'builtin' | 'custom' @@ -16,7 +17,7 @@ const KIND_CLASS: Record = { both: 'bg-muted/10 text-muted', } -export function SettingsCustomSignalsPanel() { +export function SettingsCustomSignalsPanel({ highlight }: { highlight?: string } = {}) { const qc = useQueryClient() const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList }) const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions }) @@ -87,6 +88,7 @@ export function SettingsCustomSignalsPanel() { return (
+
@@ -133,6 +135,7 @@ export function SettingsCustomSignalsPanel() {
+
{activeSection === 'builtin' && (
diff --git a/frontend/src/pages/settings/DataSources.tsx b/frontend/src/pages/settings/DataSources.tsx index 06f90f3..1917a9d 100644 --- a/frontend/src/pages/settings/DataSources.tsx +++ b/frontend/src/pages/settings/DataSources.tsx @@ -5,6 +5,7 @@ import { Check, Database, Eye, EyeOff, KeyRound, Plus, RefreshCw, Zap, FileWarni import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { useCapabilities, usePreferences } from '@/lib/useSharedQueries' +import { AnchorWrap } from '@/lib/useCardFlash' import { TIER_RANK, tierRank, tierStyle } from '@/lib/capability-labels' import { toast } from '@/components/Toast' import { DataSourceEditor } from './DataSourceEditor' @@ -367,7 +368,7 @@ function PluginKeyConfig({ plugin }: { plugin: PluginDataSourceItem }) { ) } -export function SettingsDataSourcesPanel() { +export function SettingsDataSourcesPanel({ highlight }: { highlight?: string } = {}) { const qc = useQueryClient() const prefs = usePreferences() const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources }) @@ -585,6 +586,7 @@ export function SettingsDataSourcesPanel() { return (
{/* ===== 顶部: 当前数据源 + 数据源选择 (一个大卡片) ===== */} +
@@ -779,6 +781,7 @@ export function SettingsDataSourcesPanel() {
+
{/* ===== 下方: 编辑区 ===== */} diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 7c435cc..779f1ba 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, createContext, useContext } from 'react' import { Link } from 'react-router-dom' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { @@ -19,9 +19,14 @@ import { import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash' import { toast } from '@/components/Toast' import { DepthConfigContent } from '@/components/data/DepthConfigCard' +// 卡片定位锚点: highlight= 时该卡片滚动到视口中央并闪烁高亮。 +// 其他页面用 /settings?tab=monitoring&highlight= 精确引导用户到某张卡片。 +const HighlightContext = createContext('') + // 页面 → 显示名 const PAGE_LABELS: Record = { 'overview-market': '看板', @@ -288,27 +293,13 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = return () => window.clearTimeout(t) }, [minuteRefreshIntervalDraft, minuteRefreshInterval, save]) - // highlight=depth-fix 时闪烁高亮连板梯队修正卡片 - const [flash, setFlash] = useState(false) - const flashedRef = useRef(false) - useEffect(() => { - if (highlight === 'depth-fix' && !flashedRef.current) { - flashedRef.current = true - // 延迟一帧确保 DOM 已渲染, 再触发闪烁 - requestAnimationFrame(() => { - setFlash(true) - const t = setTimeout(() => setFlash(false), 2000) - return () => clearTimeout(t) - }) - } - }, [highlight]) - return ( +
{/* ========== 左列 ========== */}
{/* 行情状态 — 开关 + 间隔 */} - + + - {/* 连板梯队降级修正 (移至右列顶部) */} -
+ {/* 连板梯队降级修正 (右列顶部) */} )} -
{/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */} - + +

监控规则命中后,可把告警推送到外部。勾选渠道作为新建规则的默认推送, 单条规则仍可在编辑页独立修改。 @@ -841,6 +828,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =

+
) } @@ -899,8 +887,10 @@ interface CardProps { children: React.ReactNode } -function Card({ icon: Icon, title, badge, right, children }: CardProps) { - return ( +function Card({ icon: Icon, title, badge, right, children, anchor }: CardProps & { anchor?: string }) { + const highlight = useContext(HighlightContext) + const { ref, flash } = useCardFlash(anchor ? highlight : undefined, anchor ?? '') + const inner = (
@@ -917,4 +907,10 @@ function Card({ icon: Icon, title, badge, right, children }: CardProps) { {children}
) + if (!anchor) return inner + return ( +
+ {inner} +
+ ) } From 9593883c591ffdbe753a041e29a1d2bf2488b532 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:11 +0800 Subject: [PATCH 09/51] =?UTF-8?q?refactor(strategy):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E7=BA=A27=E7=94=B1=E5=86=85=E7=BD=AE=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=87=AA=E5=AE=9A=E4=B9=89=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户要求, 分钟红7不再是产品内置形态, 以自定义策略形态交付: - 移除 app/strategy/builtin/minute_red_streak.py; 运行时副本落在 data/strategies/custom/ (gitignore, 用户可自行修改调参) - 策略 id (minute_red_streak) 不变: 参数覆盖 / 策略池引用 / minute_filter 契约 (daily_history_bars + daily= 注入) 全部不受位置影响 - 仓库保留参考实现作为测试夹具 (tests/fixtures/strategies/), 25 个行为测试改由夹具加载, 引擎加载测试同时断言 source == "custom" - builtin 相关不变量更新: 内置 19 个全部 matrix_native, 无 minute_filter 本地验证: /api/strategies/reload 后 source=custom, 08-25 分区分钟扫描 命中 5 只 (用户实盘参数: 6根开5红+最高2红+20日涨停过+沪深主板50-200亿)。 --- .../tests/backtest/test_matrix_strategy.py | 7 ++----- .../fixtures/strategies}/minute_red_streak.py | 0 backend/tests/test_minute_strategy.py | 19 ++++++++++++++----- backend/tests/test_screener_etf.py | 10 ++++------ 4 files changed, 20 insertions(+), 16 deletions(-) rename backend/{app/strategy/builtin => tests/fixtures/strategies}/minute_red_streak.py (100%) diff --git a/backend/tests/backtest/test_matrix_strategy.py b/backend/tests/backtest/test_matrix_strategy.py index 0f8defd..f0cab1e 100644 --- a/backend/tests/backtest/test_matrix_strategy.py +++ b/backend/tests/backtest/test_matrix_strategy.py @@ -319,13 +319,10 @@ def test_builtin_matrix_strategies_use_their_declared_formula_modules(): path for path in strategy_dir.glob("*.py") if path.name != "__init__.py" ) - # 非 matrix 后端的内置策略白名单 (当前仅分钟形态策略) - non_matrix = {"minute_red_streak"} - assert len(strategy_files) == 19 + len(non_matrix) + # 分钟形态策略 (minute_red_streak) 已迁至自定义策略目录, 内置策略全部 matrix 后端 + assert len(strategy_files) == 19 for strategy_path in strategy_files: strategy = StrategyEngine._load_file(strategy_path) - if strategy_path.stem in non_matrix: - continue assert strategy.execution_backend == "matrix_native" assert strategy.matrix_strategy is not None assert strategy.matrix_strategy.__class__.__module__ == strategy_path.stem diff --git a/backend/app/strategy/builtin/minute_red_streak.py b/backend/tests/fixtures/strategies/minute_red_streak.py similarity index 100% rename from backend/app/strategy/builtin/minute_red_streak.py rename to backend/tests/fixtures/strategies/minute_red_streak.py diff --git a/backend/tests/test_minute_strategy.py b/backend/tests/test_minute_strategy.py index 894f580..44a5198 100644 --- a/backend/tests/test_minute_strategy.py +++ b/backend/tests/test_minute_strategy.py @@ -12,15 +12,24 @@ from __future__ import annotations import datetime as _dt +import importlib.util from datetime import date, datetime from pathlib import Path import polars as pl from app.services.screener import ScreenerService -from app.strategy.builtin import minute_red_streak from app.strategy.engine import StrategyDataContext, StrategyEngine +# 分钟红7 已从内置策略改为自定义策略 (运行时 data/strategies/custom/, 不入库); +# 测试通过仓库内的参考实现夹具加载, 覆盖同一份策略逻辑。 +STRATEGY_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "strategies" +_spec = importlib.util.spec_from_file_location( + "minute_red_streak_fixture", STRATEGY_FIXTURE_DIR / "minute_red_streak.py" +) +minute_red_streak = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(minute_red_streak) + def _bars(symbol: str, candles: list[tuple[float, float, float]], start_hour: int = 9) -> pl.DataFrame: """candles: (open, close, high) 序列, 时间从 start_hour:30 起每分钟一根。""" @@ -261,15 +270,15 @@ def test_pattern_limit_up_disabled_ignores_daily(): # ── 引擎加载与运行 ────────────────────────────────────────────────── -def test_builtin_minute_strategy_loads_with_minute_filter_backend(): - engine = StrategyEngine( - strategy_dirs=[Path(__file__).resolve().parent.parent / "app" / "strategy" / "builtin"] - ) +def test_custom_minute_strategy_loads_with_minute_filter_backend(): + # 自定义策略与内置策略共用同一加载器: 夹具目录即一个 custom 目录 + engine = StrategyEngine(strategy_dirs=[STRATEGY_FIXTURE_DIR]) assert not [e for e in engine.load_errors() if "minute" in e["file"]] s = engine.get("minute_red_streak") assert s.execution_backend == "minute_filter" assert s.filter_minute_history_fn is not None assert s.meta["timeframes"] == ["1m"] + assert s.source == "custom" def _minute_code(sid: str, timeframes: str = '["1m"]', extra: str = "") -> str: diff --git a/backend/tests/test_screener_etf.py b/backend/tests/test_screener_etf.py index 32c4de7..7d37ead 100644 --- a/backend/tests/test_screener_etf.py +++ b/backend/tests/test_screener_etf.py @@ -41,8 +41,8 @@ def test_all_builtin_strategies_declare_asset_types_and_timeframes(): assert engine.load_errors() == [] for meta in engine.list_strategies(): assert meta["asset_types"] - # 分钟策略 timeframes 为 ["1m"], 日线内置策略为 ["1d"] - assert meta["timeframes"] in (["1d"], ["1m"]) + # 分钟红7已迁至自定义策略目录, 内置策略均为日线 + assert meta["timeframes"] == ["1d"] def test_all_builtin_strategies_use_matrix_backend_only(): @@ -54,10 +54,8 @@ def test_all_builtin_strategies_use_matrix_backend_only(): assert all(s.matrix_strategy is not None for s in matrix_strategies) assert all(s.filter_fn is None for s in matrix_strategies) assert all(s.filter_history_fn is None for s in matrix_strategies) - # 分钟形态策略 (minute_filter) 不参与日线矩阵不变量 - assert [s.meta["id"] for s in strategies if s.execution_backend == "minute_filter"] == [ - "minute_red_streak" - ] + # 分钟形态策略 (minute_filter) 已迁至自定义策略目录, 不在 builtin 加载范围 + assert [s.meta["id"] for s in strategies if s.execution_backend == "minute_filter"] == [] def test_all_builtin_matrix_formulas_accept_base_market_matrix(): From 68579f1b1087b1400c67b8f962041b9aa1d903b1 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:12 +0800 Subject: [PATCH 10/51] =?UTF-8?q?style(screener):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E5=BE=BD=E7=AB=A0=E6=8D=A2=20sky=20=E8=89=B2?= =?UTF-8?q?=E7=B3=BB,=20=E4=B8=8E=20AI=20=E6=9D=A5=E6=BA=90=E5=BE=BD?= =?UTF-8?q?=E7=AB=A0=E7=9A=84=E7=B4=AB=E8=89=B2=E5=8C=BA=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/screener/StrategyCard.tsx | 4 ++-- frontend/src/components/screener/StrategyPoolDialog.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/screener/StrategyCard.tsx b/frontend/src/components/screener/StrategyCard.tsx index 7281acb..7a9a580 100644 --- a/frontend/src/components/screener/StrategyCard.tsx +++ b/frontend/src/components/screener/StrategyCard.tsx @@ -128,7 +128,7 @@ export function StrategyCard({
{srcLabel} {timeframeBadge && ( - {timeframeBadge} + {timeframeBadge} )} {name}
@@ -170,7 +170,7 @@ export function StrategyCard({
{srcLabel} {timeframeBadge && ( - {timeframeBadge} + {timeframeBadge} )} {name} {count != null && !loading && ( diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index ffee88a..31f6b47 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -24,7 +24,7 @@ const SOURCE_LABEL: Record = { invalid: '失效', } -const TF_BADGE_CLS = 'text-[8px] px-1 py-px rounded border leading-tight shrink-0 border-purple-500/30 bg-purple-500/10 text-purple-400' +const TF_BADGE_CLS = 'text-[8px] px-1 py-px rounded border leading-tight shrink-0 border-sky-500/30 bg-sky-500/10 text-sky-400' type SourceTab = 'all' | 'builtin' | 'custom' | 'ai' From 7ceddc066969e678d5d104a85ca30398708b8cbe Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:13 +0800 Subject: [PATCH 11/51] =?UTF-8?q?docs(test):=20=E5=88=86=E9=92=9F=E7=BA=A2?= =?UTF-8?q?7=E5=A4=B9=E5=85=B7=E6=B3=A8=E6=98=8E=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E5=89=AF=E6=9C=AC=E7=9A=84=E5=8F=8C=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/fixtures/strategies/minute_red_streak.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/tests/fixtures/strategies/minute_red_streak.py b/backend/tests/fixtures/strategies/minute_red_streak.py index 9d2119d..9522e54 100644 --- a/backend/tests/fixtures/strategies/minute_red_streak.py +++ b/backend/tests/fixtures/strategies/minute_red_streak.py @@ -9,6 +9,10 @@ META["daily_history_bars"] 声明叠加日线维度的条件 (N 日内涨停过) 引擎会以 daily= 关键字注入日线 enriched 窗口, 涨停判定直接复用 enriched 预计算信号 — signal_limit_up (收盘封板) 或 signal_broken_limit_up (炸板: 盘中触及涨停未封住), 任一命中即算"盘中涨停过"。 + +本文件是分钟红7的参考实现 (测试夹具): 该策略按用户要求以自定义策略形态 +交付, 正式位置为运行时 data/strategies/custom/minute_red_streak.py +(gitignore, 用户可自行修改); 引擎按 id 加载, 参数覆盖不受位置影响。 """ import polars as pl From 08701f8886626e60eee7cdcc56e8c872c49dde89 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:14 +0800 Subject: [PATCH 12/51] =?UTF-8?q?fix(kline):=20=E4=B8=AA=E8=82=A1=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=88=86=E6=97=B6=E5=9B=BE=E7=8B=AC=E7=AB=8B=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2,=20=E7=9B=98=E4=B8=AD=E7=9B=B4=E6=8E=A5=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=8B=89=E5=8F=96=E6=9C=80=E6=96=B0K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/kline/minute 新增 live 参数: 当日连续竞价时段跳过本地优先直接 实时拉取, 避免盘中分钟增量落盘后 90% 完整度启发式让详情分时图停在 上一增量轮 (>=60s 滞后), 与行情列表节奏脱节 - StockPreviewDialog 打开即独立轮询 (间隔沿用偏好, 默认 6s), 不再要求 「分时刷新开关 + 实时行情运行」双条件 — PR #114 引入的门槛默认冻结详情图 - market_time 抽出公共 in_continuous_session, minute_refresh 复用同一实现 - 新增 12 个单测覆盖 live 路径, 本地回退与连续竞价时段边界 --- backend/app/api/kline.py | 19 ++- backend/app/market_time.py | 9 ++ backend/app/services/minute_refresh.py | 9 +- backend/tests/test_kline_minute_live.py | 137 ++++++++++++++++++ .../components/StockMultiDayIntradayChart.tsx | 3 +- .../src/components/StockPreviewDialog.tsx | 12 +- frontend/src/lib/api.ts | 4 +- 7 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 backend/tests/test_kline_minute_live.py diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 279fd0b..666f8c2 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -10,7 +10,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request from app.indicators.pipeline import compute_enriched, compute_enriched_single -from app.market_time import cn_now, cn_today +from app.market_time import cn_now, cn_today, in_continuous_session from app.price_limits import is_risk_warning_name, price_limit_pct from app.db_safe import is_valid_ext_ident from app.services import kline_sync @@ -780,11 +780,15 @@ def get_minute( request: Request, symbol: str = Query(..., description="标的代码"), trade_date: date | None = Query(None, alias="date", description="交易日期, 默认最新"), + live: bool = Query(False, description="当日盘中跳过本地优先, 直接实时拉取(个股详情分时轮询用)"), ): """读取某只股票某天的分钟 K 线。 - 本地有完整数据(240条) → 直接返回 - 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入) + - live=true 且当日连续竞价时段 → 跳过本地优先直接实时拉取: + 盘中分钟增量落盘的本地分区按 ≥60s 轮次更新, 90% 完整度启发式会让 + 详情分时图停在上一增量轮, 与行情列表的节奏脱节 """ repo = request.app.state.repo asset_type = repo.resolve_asset_type(symbol) @@ -834,6 +838,19 @@ def get_minute( price_limit = _get_price_limit_info( repo, symbol, trade_date, asset_type, stock_name, ) + + if live and trade_date == cn_today() and in_continuous_session(): + # 详情分时轮询: 当日盘中实时拉取最新一根K, 不落盘; 拉空(源侧延迟/ + # 时段边界)则落回下方本地优先路径。 + live_df = kline_sync.fetch_minute_single(symbol, trade_date, asset_type=asset_type) + if not live_df.is_empty(): + return { + "symbol": symbol, "name": stock_name, "stock_info": stock_info, + "date": str(trade_date), "rows": live_df.to_dicts(), + "source": "live", "asset_type": asset_type, + "price_limit": price_limit, "prev_close": prev_close, + } + df = repo.get_minute(symbol, trade_date, asset_type=asset_type) # 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算 diff --git a/backend/app/market_time.py b/backend/app/market_time.py index f98835a..d25e212 100644 --- a/backend/app/market_time.py +++ b/backend/app/market_time.py @@ -28,6 +28,15 @@ def cn_today() -> date: return datetime.now(CN_TZ).date() +def in_continuous_session(now: datetime | None = None) -> bool: + """A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。""" + now = now or cn_now() + return now.weekday() < 5 and ( + _MORNING_START <= now.time() <= _MORNING_END + or _AFTERNOON_START <= now.time() <= _AFTERNOON_END + ) + + def trading_minutes_elapsed_from_dt(dt: datetime) -> float: """根据北京时间 datetime 计算当日已交易分钟数。 diff --git a/backend/app/services/minute_refresh.py b/backend/app/services/minute_refresh.py index 43035de..5dd621c 100644 --- a/backend/app/services/minute_refresh.py +++ b/backend/app/services/minute_refresh.py @@ -24,12 +24,11 @@ from __future__ import annotations import threading import time from dataclasses import dataclass, field -from datetime import time as dt_time from typing import Any import polars as pl -from app.market_time import cn_now +from app.market_time import in_continuous_session from app.services import preferences # 轮询间隔允许范围 (秒): 下限 60s 保证任何滑动窗口 ≤1 个脉冲, 上限防误配。 @@ -41,11 +40,7 @@ _LOOP_STEP_S = 2.0 def _in_continuous_session(now=None) -> bool: """A股连续竞价时段 (北京时间): 9:30-11:30 / 13:00-15:00, 仅工作日。""" - now = now or cn_now() - t = now.time() - morning = dt_time(9, 30) <= t <= dt_time(11, 30) - afternoon = dt_time(13, 0) <= t <= dt_time(15, 0) - return now.weekday() < 5 and (morning or afternoon) + return in_continuous_session(now) @dataclass diff --git a/backend/tests/test_kline_minute_live.py b/backend/tests/test_kline_minute_live.py new file mode 100644 index 0000000..7764024 --- /dev/null +++ b/backend/tests/test_kline_minute_live.py @@ -0,0 +1,137 @@ +"""个股详情分时轮询的 live 直拉路径测试。 + +背景: 盘中分钟增量落盘后, 当日本地分区很快达到 90% 完整度, +/api/kline/minute 的本地优先启发式会拦截实时补拉, 详情分时图停在 +上一增量轮 (≥60s 滞后)。live=1 让详情轮询在连续竞价时段绕过本地优先。 +""" +from __future__ import annotations + +from datetime import date, datetime + +import polars as pl +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.kline import router +from app.market_time import CN_TZ, in_continuous_session + +# 2026-08-26 是周三; 10:00 处于上午连续竞价, expected(已交易分钟) = 30 +_NOW = datetime(2026, 8, 26, 10, 0, tzinfo=CN_TZ) +_TODAY = date(2026, 8, 26) +_LOCAL_ROWS = 30 + + +class _FakeRepo: + def resolve_asset_type(self, symbol: str) -> str: + return "stock" + + def get_instruments(self) -> pl.DataFrame: + return pl.DataFrame( + {"symbol": [], "name": [], "total_shares": [], "float_shares": []} + ) + + def get_daily_asset(self, asset_type, symbol, start, end, columns=None): + return pl.DataFrame({"date": [], "close": []}) + + def get_minute(self, symbol, trade_date, asset_type="stock") -> pl.DataFrame: + return pl.DataFrame({ + "datetime": [ + datetime(2026, 8, 26, 9, 30 + offset // 60, offset % 60) + for offset in range(_LOCAL_ROWS) + ], + "close": [10.0] * _LOCAL_ROWS, + }) + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router) + app.state.repo = _FakeRepo() + return TestClient(app) + + +def _patch_market(monkeypatch, *, in_session: bool) -> None: + import app.api.kline as kline_api + + monkeypatch.setattr(kline_api, "cn_now", lambda: _NOW) + monkeypatch.setattr(kline_api, "cn_today", lambda: _TODAY) + monkeypatch.setattr(kline_api, "in_continuous_session", lambda: in_session) + + +def _patch_live_fetch(monkeypatch) -> None: + import app.api.kline as kline_api + + def _fake_fetch(symbol, trade_date, asset_type="stock"): + return pl.DataFrame({ + "datetime": [datetime(2026, 8, 26, 9, 59)], + "close": [11.11], + }) + + monkeypatch.setattr( + kline_api.kline_sync, "fetch_minute_single", _fake_fetch + ) + + +def test_minute_live_param_bypasses_local_first_during_session(monkeypatch): + _patch_market(monkeypatch, in_session=True) + _patch_live_fetch(monkeypatch) + + resp = _client().get( + "/api/kline/minute", params={"symbol": "600000.SH", "live": 1} + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["source"] == "live" + assert body["rows"][0]["close"] == 11.11 + + +def test_minute_without_live_keeps_local_first(monkeypatch): + _patch_market(monkeypatch, in_session=True) + _patch_live_fetch(monkeypatch) + + resp = _client().get( + "/api/kline/minute", params={"symbol": "600000.SH"} + ) + + assert resp.status_code == 200 + body = resp.json() + # 本地 30 根 >= expected(30)*0.9 → 完整, 走本地 + assert body["source"] == "local" + assert body["rows"][0]["close"] == 10.0 + + +def test_minute_live_param_falls_back_to_local_off_session(monkeypatch): + _patch_market(monkeypatch, in_session=False) + _patch_live_fetch(monkeypatch) + + resp = _client().get( + "/api/kline/minute", params={"symbol": "600000.SH", "live": 1} + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["source"] == "local" + + +@pytest.mark.parametrize( + ("hour", "minute", "expected"), + [ + (9, 29, False), + (9, 30, True), + (11, 30, True), + (11, 31, False), + (12, 30, False), + (13, 0, True), + (15, 0, True), + (15, 1, False), + ], +) +def test_in_continuous_session_boundaries(hour: int, minute: int, expected: bool): + now = datetime(2026, 8, 26, hour, minute, tzinfo=CN_TZ) # 周三 + assert in_continuous_session(now) is expected + + +def test_in_continuous_session_rejects_weekend(): + assert in_continuous_session(datetime(2026, 8, 29, 10, 0, tzinfo=CN_TZ)) is False diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 852fb37..3024129 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -37,7 +37,8 @@ export function StockMultiDayIntradayChart({ }) const latest = useQuery({ queryKey: QK.klineMinute(symbol, ''), - queryFn: () => api.klineMinute(symbol), + // live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢 + queryFn: () => api.klineMinute(symbol, undefined, true), enabled: !!symbol, refetchInterval: refetchIntervalMs, }) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 968d915..3ca187a 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -161,15 +161,11 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props return () => clearFocusSymbol() }, [symbol]) - // 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。 - // 仅实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)。 + // 分时图实时轮询: 详情打开即独立轮询, 不再依赖自选列表的「分时刷新」开关 + // 与实时行情运行状态 (打开详情就是要看实时分时); 间隔沿用偏好, 默认 6s。 + // 最新一根K由后端 live 参数直接实时拉取, 与行情列表节奏一致。 const { data: prefs } = usePreferences() - const { data: quoteStatus } = useQuoteStatus() - const realtimeRunning = quoteStatus?.running ?? false - const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false - const intradayRefetchMs = (intradayRefreshOn && realtimeRunning) - ? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000 - : undefined + const intradayRefetchMs = (prefs?.minute_intraday_refresh_interval ?? 6) * 1000 const handleRefresh = () => { if (!symbol) return diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ef5976d..33b504d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1936,7 +1936,7 @@ export const api = { method: 'POST', body: JSON.stringify(symbols), }), - klineMinute: (symbol: string, date?: string) => + klineMinute: (symbol: string, date?: string, live?: boolean) => request<{ symbol: string name?: string @@ -1948,7 +1948,7 @@ export const api = { price_limit?: PriceLimitInfo | null prev_close?: number | null }>( - `/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}`, + `/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}${live ? '&live=1' : ''}`, ), klineMinuteRange: (symbol: string, days = 10) => request<{ From 974a3dccd23387d509a264d6456c01cb96186af2 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:15 +0800 Subject: [PATCH 13/51] =?UTF-8?q?chore(dialog):=20=E7=A7=BB=E9=99=A4=20Sto?= =?UTF-8?q?ckPreviewDialog=20=E6=AE=8B=E7=95=99=E7=9A=84=20useQuoteStatus?= =?UTF-8?q?=20=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/StockPreviewDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 3ca187a..f793d5e 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -14,7 +14,7 @@ import { DatePicker } from '@/components/DatePicker' import { RuleEditor } from '@/components/monitor/RuleEditor' import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog' import { buildMonitorPriceLines } from '@/lib/price-alerts' -import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' +import { usePreferences } from '@/lib/useSharedQueries' import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' import { storage } from '@/lib/storage' From d9ff91fa12e7826900ae8aa35df4e7d396c90a77 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:16 +0800 Subject: [PATCH 14/51] =?UTF-8?q?fix(dialog):=20=E6=97=A5K=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=B7=A6=E5=8F=B3=E5=8F=8C=E6=A0=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E5=88=B7=E6=96=B0=20=E2=80=94=20=E8=9C=A1?= =?UTF-8?q?=E7=83=9B=E4=B8=8E=E5=86=85=E5=B5=8C=E5=88=86=E6=97=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=8B=AC=E7=AB=8B=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上个提交只覆盖了「分时」标签页 (StockMultiDayIntradayChart); 默认日K视图 的 StockPanel 双栏仍不刷新: - 左侧蜡烛 (StockDailyKChart): 日K查询挂载后从不轮询, 今日实时蜡烛 (后端 _maybe_inject_live_candle 注入) 不随行情更新 → 透传 refetchIntervalMs 到 StockDailyKChart 接入同一轮询 - 右侧内嵌分时 (StockIntradayChart): 对话框漏传 refetchIntervalMs 给 StockPanel 日K分支, 且请求未带 live → 补传参数; 轮询上下文中 klineMinute 带 live=1, 当日盘中直接实时拉取 (历史日期后端自行忽略) 浏览器实测 (实时行情关闭): 日K视图下 kline/daily 与 kline/minute?date=当日&live=1 均以约 6s 节奏持续轮询 --- frontend/src/components/StockDailyKChart.tsx | 4 ++++ frontend/src/components/StockIntradayChart.tsx | 4 +++- frontend/src/components/StockPanel.tsx | 1 + frontend/src/components/StockPreviewDialog.tsx | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index 9f87e67..495ecc4 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -54,6 +54,8 @@ interface Props { onDataChange?: (result: StockDailyKChartResult) => void /** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */ extColumns?: string + /** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */ + refetchIntervalMs?: number } function isValidRow(r: any): boolean { @@ -136,6 +138,7 @@ export function StockDailyKChart({ onPriceDoubleClick, onDataChange, extColumns, + refetchIntervalMs, }: Props) { const [activeIndicators, setActiveIndicators] = useState(['vol']) const [showMarkers, setShowMarkers] = useState(true) @@ -150,6 +153,7 @@ export function StockDailyKChart({ queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns), queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns), enabled: !!symbol, + refetchInterval: refetchIntervalMs, placeholderData: (prev) => prev, }) diff --git a/frontend/src/components/StockIntradayChart.tsx b/frontend/src/components/StockIntradayChart.tsx index 95d5ca0..6f014e9 100644 --- a/frontend/src/components/StockIntradayChart.tsx +++ b/frontend/src/components/StockIntradayChart.tsx @@ -36,7 +36,9 @@ export function StockIntradayChart({ const minute = useQuery({ queryKey: QK.klineMinute(symbol, date ?? ''), - queryFn: () => api.klineMinute(symbol, date ?? undefined), + // 轮询上下文 (个股详情) 传 live: 当日盘中后端直接实时拉取最新K, + // 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。 + queryFn: () => api.klineMinute(symbol, date ?? undefined, refetchIntervalMs != null), enabled: !!symbol && !!date, refetchInterval: refetchIntervalMs, }) diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index ef5aa90..04c9544 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -167,6 +167,7 @@ export function StockPanel({ onDataChange={setDailyResult} visibleBars={showIntraday ? 40 : 60} extColumns={extColumns} + refetchIntervalMs={refetchIntervalMs} /> {showIntraday && selectedDate && !intradayDismissed && ( diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index f793d5e..26f422e 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -475,6 +475,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props dateRange={dateRange} priceLines={monitorPriceLines} onPriceDoubleClick={openPriceAlert} + refetchIntervalMs={intradayRefetchMs} /> ) : ( <> From 0559632de648accf31af7e21f701e80ffe1985f0 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:16 +0800 Subject: [PATCH 15/51] =?UTF-8?q?feat(backtest):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=9B=9E=E6=B5=8B=20v1=20=E2=80=94=20?= =?UTF-8?q?=E9=80=90=E4=BA=A4=E6=98=93=E6=97=A5=E5=9B=9E=E6=94=BE=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E5=88=86=E9=92=9F=E6=94=B6=E7=9B=98=E5=85=A5=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MinuteSignalReplayer: 枚举区间内分钟分区日, 逐日组装 StrategyDataContext (timeframe=1m, 日线窗口严格止于 T-1) 并复用 strategy_engine.run, 与实盘选股同路径 - 缺分区日显式跳过并记入 skipped_days, 不回退最近分区 - 涨停拒买: 信号分钟收盘 >= 当日涨停价(T-1 raw_close + 板块幅度)剔除并计数 - MarketMatrix 新增 entry_price 覆盖矩阵, 引擎在有限值处优先于 open/close 惯例 - repository.list_minute_dates 按目录名枚举分钟分区日, 零 parquet 扫描 - strategy.run 增加 minute_filter 分支: 入场 delay 0/离场沿用日K matcher, trades.entry_date 补全为 YYYY-MM-DD HH:MM (北京时间) --- backend/app/backtest/engine.py | 12 +- backend/app/backtest/matrix.py | 11 + backend/app/backtest/minute_replay.py | 286 +++++++++++++++++++++++ backend/app/backtest/strategy.py | 323 +++++++++++++++++++++++++- backend/app/tickflow/repository.py | 23 ++ 5 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 backend/app/backtest/minute_replay.py diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index b464462..6501fff 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -813,6 +813,14 @@ class BacktestEngine: matrix, raw_candidates, config, progress_cb, cancel_event, ) + @staticmethod + def _resolve_entry_prices(matrix: "MarketMatrix", config: "MatcherConfig") -> np.ndarray: + """入场价矩阵: 分钟策略的逐格覆盖有限值处优先, 否则按 open/close 惯例。""" + base = matrix.open if config.entry_fill == "open_t+1" else matrix.close + if matrix.entry_price is None: + return base + return np.where(np.isfinite(matrix.entry_price), matrix.entry_price, base) + def _simulate_independent_matrix( self, matrix: MarketMatrix, @@ -823,7 +831,7 @@ class BacktestEngine: options: SimulationOptions | None = None, ) -> SimResult: options = options or SimulationOptions() - entry_prices = matrix.open if config.entry_fill == "open_t+1" else matrix.close + entry_prices = self._resolve_entry_prices(matrix, config) exit_prices = matrix.open if config.exit_fill == "open_t+1" else matrix.close buy_cost_pct = config.buy_cost_pct() sell_cost_pct = config.sell_cost_pct() @@ -1722,7 +1730,7 @@ class BacktestEngine: ) -> SimResult: options = options or SimulationOptions() time_count, asset_count = matrix.shape - entry_prices = matrix.open if config.entry_fill == "open_t+1" else matrix.close + entry_prices = self._resolve_entry_prices(matrix, config) exit_prices = matrix.open if config.exit_fill == "open_t+1" else matrix.close buy_cost_pct = config.buy_cost_pct() sell_cost_pct = config.sell_cost_pct() diff --git a/backend/app/backtest/matrix.py b/backend/app/backtest/matrix.py index d9cfda8..a70c69f 100644 --- a/backend/app/backtest/matrix.py +++ b/backend/app/backtest/matrix.py @@ -551,6 +551,9 @@ class MarketMatrix: exit_signal_code: np.ndarray entry_signal_ids: tuple[str, ...] exit_signal_ids: tuple[str, ...] + # 逐格入场价覆盖 (time x asset, NaN=回退 open/close 惯例)。分钟策略回测用: + # 信号在盘中第 m 根触发, 入场价 = 触发分钟收盘价, 而非当日开盘/收盘。 + entry_price: np.ndarray | None = None @property def shape(self) -> tuple[int, int]: @@ -2302,11 +2305,14 @@ def build_market_matrix_from_signals( exit_delay_bars: int = 0, reference_price: np.ndarray | None = None, minute_exit_trigger: bool = False, + entry_price_override: np.ndarray | None = None, ) -> MarketMatrix: """Combine base data and strategy signals into the matcher input matrix.""" if entry_delay_bars not in (0, 1) or exit_delay_bars not in (0, 1): raise ValueError("phase-two MarketMatrix supports only zero or one bar delay") validate_signal_matrix(signals, market.shape) + if entry_price_override is not None and entry_price_override.shape != market.shape: + raise ValueError("entry_price_override shape does not match MarketDataMatrix") present = _present_matrix(market.open, market.high, market.low, market.close, market.volume) entry, entry_signal_time, entry_signal_code = _delay_signal_matrix( @@ -2379,6 +2385,11 @@ def build_market_matrix_from_signals( exit_signal_code=exit_signal_code, entry_signal_ids=signals.entry_signal_ids, exit_signal_ids=signals.exit_signal_ids, + entry_price=( + np.array(entry_price_override, dtype=np.float32, copy=True) + if entry_price_override is not None + else None + ), ) diff --git a/backend/app/backtest/minute_replay.py b/backend/app/backtest/minute_replay.py new file mode 100644 index 0000000..2e3dd89 --- /dev/null +++ b/backend/app/backtest/minute_replay.py @@ -0,0 +1,286 @@ +"""分钟策略回测回放器 — 逐交易日回放 filter_minute_history 产生入场信号。 + +与实盘选股 (ScreenerService 1m context) 走同一条 StrategyEngine.run 执行路径, +消除回测/实盘偏差。语义铁律: + +- 分钟侧: 传入当日全量分钟分区, 策略函数自身因果 (第 m 根只用 <=m 的K线); +- 日线侧: T 日的日线条件窗口只含 T-1 及更早的完成态日K — 与实盘盘中行为一致 + (当日成形K不进窗口), 杜绝未来函数; +- 按交易日精确对日: 缺分钟分区的日子显式跳过, 不做"回退最近分区" (那是实盘语义)。 +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import date, timedelta + +import polars as pl + +from app.price_limits import is_risk_warning_name, price_limit_pct +from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine + +# 日线面板列: 基础行情 + 涨停/炸板信号 (策略日线窗口契约) + 基础过滤/展示列。 +# raw_close 用于涨停价计算 (分钟价是未复权真实价, 涨停规则定义在原始价上)。 +MINUTE_DAILY_PANEL_COLUMNS = frozenset({ + "open", "high", "low", "close", "volume", "amount", + "raw_close", "raw_high", "raw_low", + "turnover_rate", + "signal_limit_up", "signal_limit_down", "signal_broken_limit_up", +}) +MINUTE_INSTRUMENT_COLUMNS = frozenset({"name", "total_shares", "float_shares"}) + + +def minute_replay_feature_plan(daily_bars: int): + """分钟回测的日线面板加载计划。 + + execution_backend 用 polars_expr 走"按需计算信号"路径: enriched 分区只落 + 基础列, 涨停/炸板信号由 load_panel_for_backtest 的 compute_limit_signals + 按 signal_columns 需求现算 (matrix_native 路径会跳过通用信号计算)。 + """ + # 函数级导入规避与 strategy.py 的循环依赖 (strategy 顶层导入本模块)。 + from app.backtest.strategy import ResolvedFeaturePlan + + return ResolvedFeaturePlan( + base_columns=MINUTE_DAILY_PANEL_COLUMNS, + intermediate_columns=frozenset(), + indicator_columns=frozenset(), + signal_columns=frozenset({ + "signal_limit_up", "signal_limit_down", "signal_broken_limit_up", + }), + matrix_columns=frozenset(), + instrument_columns=MINUTE_INSTRUMENT_COLUMNS, + warmup_bars=max(daily_bars, 1), + full_feature_fallback=False, + execution_backend="polars_expr", + ) + + +def minute_panel_start(start: date, daily_bars: int) -> date: + """日线面板加载起点: 覆盖首个回测日的 daily_bars 交易日窗口。 + + N 个交易日约需 N*2 自然日 (周末/节假日), 再留 warmup 余量。 + """ + calendar_days = max(daily_bars, 1) * 2 + 30 + return start - timedelta(days=calendar_days) + + +def _trigger_hhmm(value) -> str: + """从 last_datetime 提取北京时间 "HH:MM" 触发分钟。 + + 分区 datetime 为 UTC 存储 (tz-aware 或 naive-UTC), 统一折算到北京时区。 + """ + from app.market_time import CN_TZ + + if hasattr(value, "astimezone"): + if value.tzinfo is None: + from datetime import timezone + + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(CN_TZ).strftime("%H:%M") + text = str(value or "") + if len(text) >= 16 and text[13] == ":": + return text[11:16] + return text[-5:] if text else "" + + +def _scalar_limit_up_price(prev_close: float, limit_pct: float) -> float: + """与 polars_limit_price 同口径的标量涨停价 (整数分半进位)。""" + cents = int(prev_close * 100 + 0.5) + numerator = round((1 + limit_pct) * 100) + return ((cents * numerator + 50) // 100) / 100 + + +@dataclass +class MinuteReplayHit: + """一个盘中入场信号: 触发分钟收盘买入。""" + + trade_date: date + symbol: str + # 已按当日 复权close/原始close 比例折算到复权价系的入场价, 与日线出场价同尺度。 + entry_price: float + trigger_time: str # "HH:MM" — 触发分钟K的时间戳 + score: float = 0.0 + + +@dataclass +class MinuteReplayResult: + hits: list[MinuteReplayHit] = field(default_factory=list) + skipped_days: list[date] = field(default_factory=list) + replayed_days: int = 0 + strategy_matches: int = 0 + buy_limit_up: int = 0 + elapsed_ms: float = 0.0 + + +class MinuteSignalReplayer: + """逐交易日回放分钟策略, 产出与实盘选股同源的入场命中。""" + + def __init__(self, engine, strategy_engine: StrategyEngine) -> None: + # engine: BacktestEngine — 只用其 repo (分钟分区读取)。 + self.engine = engine + self.strategy_engine = strategy_engine + + def replay( + self, + strategy: StrategyDef, + *, + panel: pl.DataFrame, + start: date, + end: date, + params: dict, + overrides: dict, + pool: list[str] | None = None, + symbols: list[str] | None = None, + progress_cb: Callable[[dict], None] | None = None, + cancel_event=None, + ) -> MinuteReplayResult: + t0 = time.perf_counter() + result = MinuteReplayResult() + repo = self.engine.repo + if panel.is_empty(): + return result + + universe = symbols if symbols else panel.get_column("symbol").unique().to_list() + daily_bars = int(strategy.minute_daily_bars or 0) + + # 面板交易日序列 (升序) — 日线窗口切片与缺分区日判定的基准。 + panel_dates = panel.get_column("date").unique().sort().to_list() + date_to_window: dict[date, tuple[date, date]] = {} + for i, day in enumerate(panel_dates): + window_start = panel_dates[max(0, i - daily_bars)] if daily_bars > 0 else day + date_to_window[day] = (window_start, day) + + # 逐分区日回放: 只回放 [start, end] 内有分钟分区的交易日。 + minute_days = repo.list_minute_dates(start, end, "stock") + minute_day_set = set(minute_days) + replay_days = [day for day in panel_dates if start <= day <= end] + result.skipped_days = [day for day in replay_days if day not in minute_day_set] + total = len(minute_days) + + # 逐标的的 T-1 原始收盘/复权收盘查表 (涨停价与复权折算用)。 + prev_raw_close: dict[str, float] = {} + prev_name: dict[str, str] = {} + adj_factor: dict[str, float] = {} + + for i, day in enumerate(minute_days): + if cancel_event is not None and cancel_event.is_set(): + break + if progress_cb is not None: + progress_cb({ + "day": i + 1, + "total": max(total, 1), + "date": str(day), + }) + + history = repo.get_minute_by_dates(universe, [day], "stock") + if history.is_empty(): + result.skipped_days.append(day) + continue + + # 日线窗口: 截至 T-1 的完成态日K (index of last panel date < day)。 + prior = [d for d in panel_dates if d < day] + if not prior: + # 面板起点之前的分区日 (窗口数据不足), 策略按数据不足自然不命中。 + daily_history = pl.DataFrame() + current = pl.DataFrame() + else: + last_prior = prior[-1] + window_start, _ = date_to_window[last_prior] + daily_history = panel.filter( + (pl.col("date") >= window_start) & (pl.col("date") <= last_prior) + ) if daily_bars > 0 else pl.DataFrame() + current = panel.filter(pl.col("date") == last_prior) + + # T-1 收盘/名称 + T 日复权因子查表。 + _refresh_day_lookups(prev_raw_close, prev_name, current, prior) + day_rows = panel.filter(pl.col("date") == day).select( + "symbol", "close", "raw_close", + ) + adj_factor.clear() + adj_factor.update(_adj_factors(day_rows)) + + context = StrategyDataContext( + asset_type="stock", + timeframe="1m", + as_of=day, + current=current if not current.is_empty() else None, + history=history, + daily_history=daily_history if not daily_history.is_empty() else None, + ) + try: + run_result = self.strategy_engine.run( + strategy.meta.get("id", ""), + context, + pool, + params, + overrides, + ) + except ValueError: + # 单日执行失败 (如窗口缺列) 记为跳过, 不中断整个回放。 + result.skipped_days.append(day) + continue + + result.replayed_days += 1 + result.strategy_matches += len(run_result.rows) + for row in run_result.rows: + symbol = row.get("symbol") + close = row.get("close") + if not symbol or close is None or float(close) <= 0: + continue + raw_close = float(close) + name = prev_name.get(str(symbol), "") + prev = prev_raw_close.get(str(symbol)) + # 涨停拒买: 触发分钟收盘已达当日涨停价 (按 T-1 原始收盘 + 板块规则)。 + if prev is not None and prev > 0: + limit_up = _scalar_limit_up_price( + prev, price_limit_pct(str(symbol), day, is_risk_warning=is_risk_warning_name(name)), + ) + if raw_close >= limit_up - 1e-9: + result.buy_limit_up += 1 + continue + trigger = row.get("last_datetime") + trigger_time = _trigger_hhmm(trigger) + result.hits.append(MinuteReplayHit( + trade_date=day, + symbol=str(symbol), + entry_price=raw_close * adj_factor.get(str(symbol), 1.0), + trigger_time=trigger_time, + score=float(run_result.scores.get(str(symbol), 0.0) or 0.0), + )) + + result.elapsed_ms = round((time.perf_counter() - t0) * 1000, 1) + return result + + +def _refresh_day_lookups( + prev_raw_close: dict[str, float], + prev_name: dict[str, str], + prior_snapshot: pl.DataFrame, + prior: list[date], +) -> None: + """从 T-1 快照刷新逐标的原始收盘与名称查表 (涨停价/ST 判定用)。""" + if prior_snapshot.is_empty(): + return + frame = prior_snapshot + if "raw_close" not in frame.columns: + frame = frame.with_columns(pl.col("close").alias("raw_close")) + if "name" not in frame.columns: + frame = frame.with_columns(pl.lit("").alias("name")) + prev_raw_close.clear() + prev_name.clear() + for symbol, raw_close, name in frame.select("symbol", "raw_close", "name").iter_rows(): + prev_raw_close[str(symbol)] = float(raw_close) if raw_close is not None else 0.0 + prev_name[str(symbol)] = str(name or "") + + +def _adj_factors(day_rows: pl.DataFrame) -> dict[str, float]: + """T 日 复权close/原始close 比例: 把分钟原始价折算到复权价系。""" + factors: dict[str, float] = {} + if day_rows.is_empty() or "raw_close" not in day_rows.columns: + return factors + for symbol, close, raw_close in day_rows.select("symbol", "close", "raw_close").iter_rows(): + if close and raw_close and float(raw_close) > 0: + factors[str(symbol)] = float(close) / float(raw_close) + return factors diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 9ae6803..7a5d83d 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -28,13 +28,20 @@ from app.backtest.matrix import ( MatrixPipelineConfig, MatrixPrewarmCancelledError, MatrixStrategyPipeline, + SignalMatrix, apply_time_masks, + build_market_data_matrix, build_market_matrix, build_market_matrix_from_signals, rolling_mean, slice_market_data_matrix, slice_signal_matrix, ) +from app.backtest.minute_replay import ( + MinuteSignalReplayer, + minute_panel_start, + minute_replay_feature_plan, +) from app.backtest.minute_trigger import unsupported_minute_exit_signals from app.config import settings from app.indicators.pipeline import ( @@ -995,7 +1002,7 @@ class StrategyBacktestService: s, StrategyDataContext( asset_type=config.asset_type, - timeframe="1d", + timeframe="1m" if s.execution_backend == "minute_filter" else "1d", as_of=config.end, ), ) @@ -1044,6 +1051,26 @@ class StrategyBacktestService: overrides.get("score_max"), ) + if s.execution_backend == "minute_filter": + # 分钟策略回测: 逐交易日回放 filter_minute_history (与实盘选股同源), + # 信号分钟收盘价入场, 之后复用日K矩阵模拟的离场与组合管理。 + return self._run_minute_backtest( + config, s, params, overrides, + stop_loss=stop_loss, + take_profit=take_profit, + trailing_stop=trailing_stop, + trailing_take_profit_activate=trailing_take_profit_activate, + trailing_take_profit_drawdown=trailing_take_profit_drawdown, + max_hold_days=max_hold_days, + score_min=score_min, + score_max=score_max, + progress_cb=progress_cb, + cancel_event=cancel_event, + result_policy=result_policy, + run_id=run_id, + t0=t0, + ) + try: if s.execution_backend == "composite": # composite 回测: 子策略必须全为 matrix_native(否则 fail-closed), @@ -1629,6 +1656,300 @@ class StrategyBacktestService: elapsed_ms=round(elapsed, 1), ) + # ── 分钟策略回测: 逐日回放入场 + 日K矩阵离场 ── + + def _run_minute_backtest( + self, + config: StrategyBacktestConfig, + s: StrategyDef, + params: dict, + overrides: dict, + *, + stop_loss, + take_profit, + trailing_stop, + trailing_take_profit_activate, + trailing_take_profit_drawdown, + max_hold_days, + score_min, + score_max, + progress_cb, + cancel_event, + result_policy: BacktestResultPolicy, + run_id: str, + t0: float, + ) -> StrategyBacktestResult: + def _err(msg: str) -> StrategyBacktestResult: + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + error=msg, + elapsed_ms=(time.perf_counter() - t0) * 1000, + ) + + if config.asset_type != "stock": + return _err("分钟策略回测当前仅支持 A 股 (stock)") + if config.exit_fill == "signal_next_minute": + return _err("分钟策略回测暂不支持「信号触发卖出」离场口径") + + minute_days = self.engine.repo.list_minute_dates(config.start, config.end, "stock") + if not minute_days: + earliest = self.engine.repo.earliest_minute_date() + hint = f"本地分钟K最早到 {earliest}, " if earliest else "本地无分钟K数据, " + return _err( + f"回测区间内无分钟K数据: {hint}请先用「扩展分钟K历史」拉取, 或开启盘中分钟增量" + ) + + # 日线面板一次加载: 覆盖首个回测日的日线窗口 + 模拟区间 (含 full 模式尾部)。 + daily_bars = int(s.minute_daily_bars or 0) + feature_plan = minute_replay_feature_plan(daily_bars) + load_start = minute_panel_start(config.start, daily_bars) + full_horizon_days = int(max_hold_days or config.holding_days or 5) + load_end = config.end + if config.mode == "full": + load_end = config.end + timedelta(days=(full_horizon_days + 5) * 2) + sim_end = load_end if config.mode == "full" else config.end + + timing_ms: dict[str, float] = {} + t_load = time.perf_counter() + try: + panel = self.engine.load_panel_for_backtest( + config.symbols, + load_start, + load_end, + feature_plan, + asset_type="stock", + ) + except (ValueError, OSError, pl.exceptions.PolarsError) as e: + return _err(f"回测特征准备失败: {e}") + timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1) + if panel.is_empty(): + return _err("无日线数据, 请检查日期范围或先运行盘后管道") + + replayer = MinuteSignalReplayer(self.engine, self.strategy_engine) + replay = replayer.replay( + s, + panel=panel, + start=config.start, + end=config.end, + params=params, + overrides=overrides, + symbols=config.symbols, + progress_cb=progress_cb, + cancel_event=cancel_event, + ) + timing_ms["minute_replay"] = replay.elapsed_ms + if cancel_event is not None and cancel_event.is_set(): + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + error="cancelled", + elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), + ) + if not replay.hits: + skipped_hint = ( + f" (区间内 {len(replay.skipped_days)} 个交易日缺分钟K分区被跳过)" + if replay.skipped_days else "" + ) + return _err("在指定区间内未产生买入信号" + skipped_hint) + + # 日频信号网格: 正式区间面板 → time x asset 矩阵, 命中格写入入场价覆盖。 + sim_panel = panel.filter( + (pl.col("date") >= config.start) & (pl.col("date") <= sim_end) + ) + if sim_panel.is_empty(): + return _err("正式回测区间内无数据") + axis_dates = sim_panel.get_column("date").unique().sort().to_list() + # 轴顺序必须与 build_market_data_matrix 的 _encode_axes 一致 (unique().sort()), + # 否则 (time, asset) 下标指向错误的标的。 + axis_symbols = sim_panel.get_column("symbol").cast(pl.Utf8).unique().sort().to_list() + time_index = {day: i for i, day in enumerate(axis_dates)} + asset_index = {sym: i for i, sym in enumerate(axis_symbols)} + shape = (len(axis_dates), len(axis_symbols)) + + entry = np.zeros(shape, dtype=np.uint8) + score = np.zeros(shape, dtype=np.float32) + entry_price_override = np.full(shape, np.nan, dtype=np.float32) + trigger_times: dict[tuple[str, date], str] = {} + dropped_axis_hits = 0 + for hit in replay.hits: + time_id = time_index.get(hit.trade_date) + asset_id = asset_index.get(hit.symbol) + if time_id is None or asset_id is None: + dropped_axis_hits += 1 + continue + entry[time_id, asset_id] = 1 + score[time_id, asset_id] = hit.score + entry_price_override[time_id, asset_id] = hit.entry_price + trigger_times[(hit.symbol, hit.trade_date)] = hit.trigger_time + raw_candidates = int(entry.sum()) + entry.setflags(write=False) + score.setflags(write=False) + entry_price_override.setflags(write=False) + exit_mask = np.zeros(shape, dtype=np.uint8) + exit_mask.setflags(write=False) + codes = np.zeros(shape, dtype=np.int16) + codes.setflags(write=False) + signals = SignalMatrix( + entry=entry, + exit=exit_mask, + score=score, + entry_signal_code=codes, + exit_signal_code=codes, + entry_signal_ids=(), + exit_signal_ids=(), + ) + + matcher_config = MatcherConfig( + matching=config.matching, + entry_fill="close_t", + exit_fill=config.exit_fill, + fees_pct=config.fees_pct, + commission_pct=config.commission_pct, + stamp_tax_pct=config.stamp_tax_pct, + slippage_bps=config.slippage_bps, + stop_loss_pct=stop_loss, + take_profit_pct=take_profit, + trailing_stop_pct=trailing_stop, + trailing_take_profit_activate_pct=trailing_take_profit_activate, + trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown, + max_hold_days=max_hold_days, + max_positions=config.max_positions, + max_exposure_pct=config.max_exposure_pct, + score_min=score_min, + score_max=score_max, + initial_capital=config.initial_capital, + position_sizing=config.position_sizing, + # 分钟策略的成交价由 entry_price_override 提供 (触发分钟收盘), + # 不再叠加日线口径的分钟成交细化。 + minute_fill=False, + ) + + t_matrix = time.perf_counter() + market_data = build_market_data_matrix(sim_panel) + market_matrix = build_market_matrix_from_signals( + market_data, + signals, + # 入场即信号日盘中 (分钟价覆盖), 离场沿用日K口径。 + entry_delay_bars=0, + exit_delay_bars=1 if matcher_config.exit_fill == "open_t+1" else 0, + entry_price_override=entry_price_override, + ) + timing_ms["matrix_build"] = round((time.perf_counter() - t_matrix) * 1000, 1) + del sim_panel, market_data + + t_sim = time.perf_counter() + if config.mode == "full": + result = self.engine.simulate_independent_market_matrix( + market_matrix, + raw_candidates, + matcher_config, + progress_cb, + cancel_event, + result_policy.simulation_options(), + ) + else: + result = self.engine.simulate_market_matrix( + market_matrix, + matcher_config, + progress_cb, + cancel_event, + result_policy.simulation_options(), + ) + timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1) + timing_ms["statistics"] = float(result.stats.pop("statistics_ms", 0.0)) + + if cancel_event is not None and cancel_event.is_set(): + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + error="cancelled", + elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), + ) + if result.stats.get("error"): + return _err(result.stats["error"]) + + execution = result.stats.get("execution") or {} + execution["buy_limit_up"] = int(execution.get("buy_limit_up", 0)) + replay.buy_limit_up + result.stats["execution"] = execution + timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1) + result.stats["timing_ms"] = timing_ms + result.stats["panel_rows"] = int(len(axis_dates) * len(axis_symbols)) + result.stats["panel_columns"] = 0 + result.stats["feature_columns"] = 0 + result.stats["execution_backend"] = s.execution_backend + result.stats["selection"] = { + "strategy_matches": replay.strategy_matches, + "entry_candidates": raw_candidates, + "entry_trigger_filtered": max(replay.strategy_matches - raw_candidates, 0), + "entry_trigger_enabled": False, + } + result.stats["minute_replay"] = { + "replayed_days": replay.replayed_days, + "skipped_days": [str(day) for day in replay.skipped_days[:50]], + "skipped_day_count": len(replay.skipped_days), + "dropped_axis_hits": dropped_axis_hits, + } + + benchmark_curve = ( + self._build_benchmark_curve(config.start, config.end) + if result_policy.include_benchmark + else [] + ) + strategy_info = { + "id": s.meta.get("id", config.strategy_id), + "name": s.meta.get("name", config.strategy_id), + "description": s.meta.get("description", ""), + "entry_signals": [], + "exit_signals": [], + "stop_loss": stop_loss, + "take_profit": take_profit, + "trailing_stop": trailing_stop, + "trailing_take_profit_activate": trailing_take_profit_activate, + "trailing_take_profit_drawdown": trailing_take_profit_drawdown, + "max_hold_days": max_hold_days, + "full_horizon_days": full_horizon_days, + "score_min": score_min, + "score_max": score_max, + "source": s.source, + "execution_backend": s.execution_backend, + } if result_policy.include_strategy_info else {} + + trades = ( + [self._trade_to_dict(t) for t in result.trades] + if result_policy.include_trades + else [] + ) + # 入场时间戳补分钟: 交易记录携带触发分钟 (HH:MM), 与日线回测的纯日期区分。 + for trade in trades: + entry_text = str(trade.get("entry_date") or "") + try: + key = (str(trade.get("symbol")), date.fromisoformat(entry_text[:10])) + except ValueError: + continue + trigger = trigger_times.get(key) + if trigger: + trade["entry_date"] = f"{entry_text[:10]} {trigger}" + + selected_stats = result_policy.select_stats(result.stats) + elapsed = (time.perf_counter() - t0) * 1000 + return StrategyBacktestResult( + run_id=run_id, + config=self._config_to_dict(config), + stats=selected_stats, + equity_curve=result.equity_curve if result_policy.include_curves else [], + drawdown_curve=result.drawdown_curve if result_policy.include_curves else [], + benchmark_curve=benchmark_curve, + trades=trades, + per_symbol_stats=( + result.per_symbol_stats + if result_policy.include_per_symbol_stats + else [] + ), + strategy_info=strategy_info, + elapsed_ms=round(elapsed, 1), + ) + # ── 全量模拟 (选股能力统计, 不建组合不算净值) ── def _run_full_simulation( diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index 64f89b7..81aae5e 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -1866,6 +1866,29 @@ class KlineRepository: return None return None + def list_minute_dates(self, start: date, end: date, asset_type: str = "stock") -> list[date]: + """枚举 [start, end] 内存在的分钟K分区日 (目录名直读, 零 parquet 扫描)。 + + 分钟回测按交易日精确对日: 缺分区的日子由调用方显式跳过, + 不做"回退最近分区" (那是实盘选股的语义, 回放会串日)。 + """ + dirname = "kline_minute" if asset_type == "stock" else f"kline_{asset_type}_minute" + minute_dir = self.store.data_dir / dirname + if not minute_dir.exists(): + return [] + out: list[date] = [] + for entry in minute_dir.iterdir(): + if not (entry.is_dir() and entry.name.startswith("date=")): + continue + try: + day = date.fromisoformat(entry.name[5:]) + except ValueError: + continue + if start <= day <= end: + out.append(day) + out.sort() + return out + def latest_daily_date(self) -> date | None: """本地日K数据的最新日期。""" try: From d9f7bc645d68dffd89d84b1661c824528074148a Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:17 +0800 Subject: [PATCH 16/51] =?UTF-8?q?feat(backtest):=20=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E9=A1=B5=E6=94=AF=E6=8C=81=E5=88=86=E9=92=9F=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E9=92=9F=E7=BA=A7=E6=88=90=E4=BA=A4=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 策略下拉改取 'all' 时段, 分钟策略入列表并加 sky 色「分钟」徽章 - 选中分钟策略条件化 UI: 隐藏分钟成交开关与 signal_next_minute, 建仓口径固定显示「信号分钟收盘」, 展示本地分钟K覆盖提示与起始日越界警告 - 交易明细买入列渲染 HH:MM 分钟徽章 (entry_date 携带 YYYY-MM-DD HH:MM) - SSE 连接前先 fetch 探测, HTTP 400 直接展示后端 detail, 不再误入 EventSource 无限重连 --- frontend/src/lib/api.ts | 2 +- frontend/src/lib/backtestTask.ts | 22 ++++- frontend/src/lib/queryKeys.ts | 2 +- .../src/pages/backtest/StrategyBacktest.tsx | 82 +++++++++++++++---- 4 files changed, 89 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 33b504d..a9bcff3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -632,7 +632,7 @@ export interface StrategyDetail { description: string tags: string[] source: 'builtin' | 'custom' | 'ai' | 'composite' - execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite' + execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite' | 'minute_filter' asset_types: string[] timeframes: string[] version: string diff --git a/frontend/src/lib/backtestTask.ts b/frontend/src/lib/backtestTask.ts index 01ae568..515b50f 100644 --- a/frontend/src/lib/backtestTask.ts +++ b/frontend/src/lib/backtestTask.ts @@ -66,7 +66,7 @@ function buildQuery(params: Record { const id = current?.id ?? ++taskSeq // 关闭旧连接 @@ -75,6 +75,26 @@ function connectSSE(url: string): void { eventSource = null } + // 预检: 4xx 拒绝 (如分钟策略回测的分钟K覆盖守卫 400) 时 EventSource 只会无 data 地 + // onerror, 会被当成"连接中断"有界重试 — 先 fetch 一次把后端 detail 直接展示给用户。 + try { + const probe = await fetch(url, { headers: { Accept: 'text/event-stream' } }) + if (!probe.ok) { + let message = `回测请求失败 (${probe.status})` + try { + message = (await probe.json())?.detail ?? message + } catch { /* ignore */ } + await probe.body?.cancel().catch(() => {}) + if (current?.id === id) { + current = { ...current, isPending: false, error: message, reconnecting: false } + emit() + localStorage.removeItem(RECONNECT_KEY) + } + return + } + await probe.body?.cancel().catch(() => {}) + } catch { /* 网络层异常: 交给下方 EventSource 的重连逻辑 */ } + const es = new EventSource(url) eventSource = es diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 4f7f140..a706a88 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -40,7 +40,7 @@ export const QK = { // Screener screener: ['screener'] as const, - screenerStrategies: (assetType: string = 'stock') => ['screener-strategies', assetType] as const, + screenerStrategies: (assetType: string = 'stock', timeframe: '1d' | '1m' | 'all' = '1d') => ['screener-strategies', assetType, timeframe] as const, screenerCachedSummary: ['screener-cached', 'summary'] as const, screenerCachedResult: (strategyId: string, asOf?: string, ext?: string) => ['screener-cached', 'strategy', strategyId, asOf ?? '', ext ?? ''] as const, screenerCached: (asOf?: string, ext?: string) => ['screener-cached', 'all', asOf ?? '', ext ?? ''] as const, diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index c631035..930b16b 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -476,7 +476,10 @@ function DailyTradeChip({ trade, side, strategyName, onClick, signalNames }: { t function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; signalNames?: Record }) { const isBuy = side === 'buy' - const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10) + // 分钟策略入场携带 "YYYY-MM-DD HH:MM" (盘中触发分钟); 日线口径为纯日期 + const raw = String(isBuy ? trade.entry_date : trade.exit_date) + const date = raw.slice(0, 10) + const minuteTime = raw.length > 10 ? raw.slice(11, 16) : '' const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10) const price = isBuy ? trade.entry_price : trade.exit_price const amount = isBuy ? trade.entry_value : trade.exit_value @@ -487,7 +490,12 @@ function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTra return (
- 成交 {date} + + 成交 {date} + {minuteTime && ( + {minuteTime} + )} + @@ -954,8 +962,8 @@ export function StrategyBacktest() { const loadedStrategyRef = useRef(null) const strategies = useQuery({ - queryKey: QK.screenerStrategies(assetType), - queryFn: () => api.screenerStrategies(assetType), + queryKey: QK.screenerStrategies(assetType, 'all'), + queryFn: () => api.screenerStrategies(assetType, 'all'), }) const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data]) const filteredStrategyList = useMemo(() => ( @@ -1080,7 +1088,7 @@ export function StrategyBacktest() { positionSizing, mode: simMode, holdingDays, - minuteFill: highGranularity, + minuteFill: isMinuteStrategy ? false : highGranularity, regimeStates, regimeMinScore, params: strategyParams, @@ -1118,7 +1126,7 @@ export function StrategyBacktest() { overrides: requestOverrides, mode: simMode, holding_days: Number(holdingDays) || 5, - minute_fill: highGranularity, + minute_fill: isMinuteStrategy ? false : highGranularity, regime_filter: regimeStates.length > 0 || regimeMinScore !== '' ? { ...(regimeStates.length > 0 ? { states: regimeStates } : {}), @@ -1286,11 +1294,22 @@ export function StrategyBacktest() { const minuteTriggerSignals = detail?.minute_exit_trigger_supported_signals ?? [] const unsupportedMinuteExitSignals = effectiveExitSignals.filter(signal => !minuteTriggerSignals.includes(signal)) const minuteExitTriggerSupported = effectiveExitSignals.length > 0 && unsupportedMinuteExitSignals.length === 0 + // 分钟策略: 入场在盘中触发分钟成交, 日线专属的成交口径选项不适用 + const isMinuteStrategy = detail?.execution_backend === 'minute_filter' + const { data: minuteDataStatus } = useQuery({ + queryKey: QK.dataStatus, + queryFn: api.dataStatus, + enabled: isMinuteStrategy, + staleTime: 60_000, + }) + // 分钟回测窗口守卫: 开始日期早于本地分钟K起点会被后端拒绝, 前置警示 + const minuteEarliest = minuteDataStatus?.minute?.earliest_date + const minuteStartMismatch = isMinuteStrategy && !!minuteEarliest && start < minuteEarliest useEffect(() => { - if (highGranularity && minuteExitTriggerSupported) return + if (highGranularity && minuteExitTriggerSupported && !isMinuteStrategy) return if (exitFill === 'signal_next_minute') setExitFill('close_t') - }, [exitFill, highGranularity, minuteExitTriggerSupported]) + }, [exitFill, highGranularity, minuteExitTriggerSupported, isMinuteStrategy]) const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring]) const scoringDirections = useMemo( @@ -1395,7 +1414,8 @@ export function StrategyBacktest() {
- {/* 分钟K成交 */} + {/* 分钟K成交 — 日线策略专属 (分钟策略入场天然按触发分钟成交) */} + {!isMinuteStrategy && (
+ )}
+ {/* 分钟策略提示条: 数据窗口 + 成交语义 */} + {isMinuteStrategy && ( +
+ +
+ 分钟策略回测 + :逐日回放分钟K,信号分钟收盘价买入;日线条件按 T-1 完成态评估。 + {minuteDataStatus?.minute?.earliest_date + ? ` 本地分钟K ${minuteDataStatus.minute.earliest_date} ~ ${minuteDataStatus.minute.latest_date}(${minuteDataStatus.minute.trading_days} 个交易日),缺分区的日子自动跳过。` + : ' 本地暂无分钟K数据,请先在数据页拉取。'} + {minuteStartMismatch && ( + + 当前开始日期 {start} 早于分钟数据起点 {minuteEarliest},运行会被拒绝 — 请把开始日期调整到 {minuteEarliest} 之后,或先用「扩展分钟K历史」拉取。 + + )} +
+
+ )} {/* 分钟K开启时的提示条 */} - {highGranularity && hasMinuteBatch && ( + {highGranularity && hasMinuteBatch && !isMinuteStrategy && (
@@ -1465,6 +1504,9 @@ export function StrategyBacktest() { }`} > {st.name} + {st.timeframes?.includes('1m') && ( + 分钟 + )} {st.source && st.source !== 'builtin' && ( {SRC_MAP[st.source] ?? ''} @@ -1619,10 +1661,18 @@ export function StrategyBacktest() {
- + {isMinuteStrategy ? ( +
+ + 信号分钟收盘 + 分钟 +
+ ) : ( + + )}
@@ -1633,12 +1683,12 @@ export function StrategyBacktest() { > - {highGranularity && minuteExitTriggerSupported && ( + {highGranularity && minuteExitTriggerSupported && !isMinuteStrategy && ( )}
- {(entryFill === 'close_t' || exitFill === 'close_t') && ( + {!isMinuteStrategy && (entryFill === 'close_t' || exitFill === 'close_t') && (
信号日收盘仅适合收盘前已确认的信号 From d4aa9ed6ad1a8ff6d8678f0c8c51659e24dcb66d Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:18 +0800 Subject: [PATCH 17/51] =?UTF-8?q?test(backtest):=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E5=AE=88=E5=8D=AB=E4=B8=8E=E5=85=AD=E9=A1=B9?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _guard_minute_strategy_backtest: 非股票资产与回测起点早于本地分钟K起点 均返回 400 并提示「扩展分钟K历史」, 覆盖 run 与 SSE stream 两入口 - 合成分钟分区+日线面板夹具, 断言: 触发分钟收盘成交价/日线窗口严格止于 T-1 的因果性/涨停拒买计数/缺分区日跳过/离场走日K次日开盘/入口守卫 --- backend/app/api/backtest.py | 29 ++ .../tests/backtest/test_minute_backtest.py | 330 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 backend/tests/backtest/test_minute_backtest.py diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 2605e73..5eccf99 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -346,6 +346,33 @@ class StrategyBacktestRequest(BaseModel): regime_filter: dict | None = None +def _guard_minute_strategy_backtest( + request: Request, strategy_id: str, start: date, asset_type: str, +) -> None: + """分钟策略回测入口守卫: 仅 A 股 + 本地分钟K覆盖检查 (fail-fast)。""" + engine = getattr(request.app.state, "strategy_engine", None) + if engine is None: + return + try: + s = engine.get(strategy_id) + except ValueError: + return + if s is None or s.execution_backend != "minute_filter": + return + if asset_type != "stock": + raise HTTPException(400, detail="分钟策略回测当前仅支持 A 股 (stock)") + earliest = request.app.state.repo.earliest_minute_date() + if earliest is None or start < earliest: + have = f"最早到 {earliest}, " if earliest else "" + raise HTTPException( + 400, + detail=( + f"本地分钟K{have}无法覆盖回测起始日 {start}。" + "请先用「扩展分钟K历史」拉取更多数据, 或缩小回测区间" + ), + ) + + @router.post("/strategy/run") def strategy_run(req: StrategyBacktestRequest, request: Request): """策略回测 — 复用 StrategyDef 体系做全周期回测。""" @@ -355,6 +382,7 @@ def strategy_run(req: StrategyBacktestRequest, request: Request): end = req.end or date.today() start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS) _guard_server_backtest_range(start, end) + _guard_minute_strategy_backtest(request, req.strategy_id, start, req.asset_type) cfg = StrategyBacktestConfig( strategy_id=req.strategy_id, @@ -505,6 +533,7 @@ async def strategy_stream( # 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口 earliest = request.app.state.repo.earliest_daily_date() start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS)) + _guard_minute_strategy_backtest(request, strategy_id, start_date, asset_type) # 服务端范围保护 guard_violated = False diff --git a/backend/tests/backtest/test_minute_backtest.py b/backend/tests/backtest/test_minute_backtest.py new file mode 100644 index 0000000..7ea69f1 --- /dev/null +++ b/backend/tests/backtest/test_minute_backtest.py @@ -0,0 +1,330 @@ +"""分钟策略回测端到端集成测试 (三期 v1)。 + +用合成的分钟K分区 + 合成日线面板 + 专用测试策略, 完整跑通 +StrategyBacktestService.run() 的 minute_filter 分支: +逐日回放 (与实盘选股同一条 StrategyEngine.run 路径) → 信号分钟收盘入场 +→ 涨停拒买 → 日K矩阵离场 → 交易记录携带分钟时间戳。 + +核心断言: +- 日线窗口因果性: T 日的日线条件窗口只含 T-1 及更早 (测试策略内置守卫, + 窗口含 T 则拒绝命中 — 若回放器传错窗口, 全部用例的信号归零); +- 入场价 = 触发分钟收盘价 (entry_price_override 机制); +- 涨停拒买: 触发分钟收盘 >= 当日涨停价 (T-1 收盘 + 板块规则) 不成交; +- 缺分钟分区的交易日显式跳过 (不回退最近分区); +- 离场复用日K口径 (max_hold → 次日开盘)。 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from pathlib import Path + +import polars as pl +import pytest + +from app.backtest.engine import BacktestEngine +from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService +from app.strategy.engine import StrategyEngine + +# ── 测试策略: 内置因果性守卫 ────────────────────────────────────── +# 命中条件: 当日某分钟 close > T-1 close * 1.05, 触发分钟 = 首根满足条件的K。 +# daily 窗口的最后一个日期必须 < 触发日, 否则返回空 (回放器传错窗口时信号归零)。 +TEST_STRATEGY_SOURCE = ''' +import polars as pl + +META = { + "id": "test_minute_ping", + "name": "test_minute_ping", + "asset_types": ["stock"], + "timeframes": ["1m"], + "daily_history_bars": 5, + "order_by": "close", + "descending": True, + "limit": 100, +} +EXECUTION_BACKEND = "minute_filter" + + +def filter_minute_history(df, params, *, daily=None): + if daily is None or daily.is_empty(): + return pl.DataFrame() + trigger_day = df.select(pl.col("datetime").max()).item().date() + # 因果性守卫: 日线窗口不得包含触发日。 + if daily.get_column("date").max() >= trigger_day: + return pl.DataFrame() + prev = ( + daily.sort("date").group_by("symbol").last() + .select(pl.col("symbol"), pl.col("close").alias("prev_close")) + ) + joined = df.join(prev, on="symbol", how="inner") + hits = joined.filter(pl.col("close") > pl.col("prev_close") * 1.05) + if hits.is_empty(): + return pl.DataFrame() + return ( + hits.sort("datetime").group_by("symbol").first() + .select( + pl.col("symbol"), + pl.col("datetime").alias("last_datetime"), + pl.col("close"), + ) + ) +''' + + +# ── 合成数据 ───────────────────────────────────────────────────── +def _trading_days(n: int, start: date = date(2026, 7, 1)) -> list[date]: + days: list[date] = [] + cur = start + while len(days) < n: + if cur.weekday() < 5: + days.append(cur) + cur += timedelta(days=1) + return days + + +def _daily_panel(days: list[date], symbols: list[str]) -> pl.DataFrame: + """合成日线面板: 三个符号的慢涨走势, raw_close == close (复权因子 1)。""" + rows = [] + for sym_idx, sym in enumerate(symbols): + base = 10.0 + sym_idx * 4.0 + for t, day in enumerate(days): + close = round(base * (1 + t * 0.002), 3) + open_p = round(close - 0.05, 3) + rows.append({ + "symbol": sym, + "date": day, + "open": open_p, + "high": round(close + 0.08, 3), + "low": round(open_p - 0.06, 3), + "close": close, + "raw_close": close, + # 成交额需过 DEFAULT_BASIC_FILTER.amount_min (2e8) — 命中行的 + # amount 由 T-1 enriched 快照联表注入 (与实盘同路径)。 + "volume": 2e7, + "amount": round(close * 2e7, 3), + "name": f"股票{sym_idx}", + "total_shares": 5e8, + "float_shares": 4e8, + "signal_limit_up": False, + "signal_limit_down": False, + }) + return pl.DataFrame(rows).sort(["symbol", "date"]).with_columns( + pl.col("date").cast(pl.Date), + ) + + +def _minute_frame(day: date, bars: list[tuple[str, str, float]]) -> pl.DataFrame: + """bars: (symbol, "HH:MM"(北京), close)。分区 datetime 为 naive-UTC 存储 (北京 - 8h)。""" + rows = [] + for sym, hm, close in bars: + local = datetime(day.year, day.month, day.day, int(hm[:2]), int(hm[3:])) + rows.append({ + "symbol": sym, + "datetime": local - timedelta(hours=8), + "open": close - 0.01, + "high": close + 0.01, + "low": close - 0.02, + "close": close, + "volume": 1000.0, + "amount": close * 1000.0, + }) + return pl.DataFrame(rows).sort(["symbol", "datetime"]).with_columns( + pl.col("datetime").cast(pl.Datetime("us")), + ) + + +class _FakeMinuteRepo: + """仅实现分钟回测所需的最小 repo 接口。""" + + def __init__(self, minute_frames: dict[date, pl.DataFrame]) -> None: + self.minute_frames = minute_frames + self.store = None + + def list_minute_dates(self, start, end, asset_type="stock"): + return sorted(d for d in self.minute_frames if start <= d <= end) + + def get_minute_by_dates(self, symbols, dates, asset_type="stock"): + frames = [self.minute_frames[d] for d in dates if d in self.minute_frames] + if not frames: + return pl.DataFrame( + schema={"symbol": pl.Utf8, "datetime": pl.Datetime("us"), + "open": pl.Float64, "high": pl.Float64, "low": pl.Float64, + "close": pl.Float64, "volume": pl.Float64, "amount": pl.Float64}, + ) + df = pl.concat(frames) + if symbols: + df = df.filter(pl.col("symbol").is_in(list(symbols))) + return df.sort(["symbol", "datetime"]) + + def earliest_minute_date(self): + return min(self.minute_frames) if self.minute_frames else None + + def get_index_daily(self, *args, **kwargs) -> pl.DataFrame: + return pl.DataFrame() + + +def _make_service( + tmp_path: Path, panel: pl.DataFrame, minute_frames: dict[date, pl.DataFrame], +) -> StrategyBacktestService: + strat_dir = tmp_path / "strategies" + strat_dir.mkdir(exist_ok=True) + (strat_dir / "test_minute_ping.py").write_text(TEST_STRATEGY_SOURCE, encoding="utf-8") + strategy_engine = StrategyEngine(strategy_dirs=[strat_dir]) + + repo = _FakeMinuteRepo(minute_frames) + bt_engine = BacktestEngine(repo) + + def _load_panel(self, symbols, start, end, feature_plan, asset_type="stock", **kw): + df = panel.filter((pl.col("date") >= start) & (pl.col("date") <= end)) + if symbols: + df = df.filter(pl.col("symbol").is_in(list(symbols))) + keep = set(feature_plan.base_columns) | set(feature_plan.instrument_columns) | {"symbol", "date"} + return df.select(sorted(c for c in df.columns if c in keep)) + + bt_engine.load_panel_for_backtest = _load_panel.__get__(bt_engine) + return StrategyBacktestService(bt_engine, strategy_engine) + + +def _config(start: date, end: date, **kw) -> StrategyBacktestConfig: + defaults = dict( + strategy_id="test_minute_ping", + symbols=None, + start=start, + end=end, + exit_fill="open_t+1", + max_positions=10, + mode="position", + holding_days=1, + overrides={"max_hold_days": 1}, + ) + defaults.update(kw) + return StrategyBacktestConfig(**defaults) + + +@pytest.fixture() +def scenario(tmp_path: Path): + """三个符号 x 三个回测日。面板共 30 个交易日 (指数慢涨, 涨停价按 T-1 收盘 +10%)。 + + - 000001.SZ: T1 触发 (close 10.72 > prev 10.19*1.05), T2/T3 不再触发; + - 000002.SZ: 三天都不触发 (涨幅不足 5%); + - 600000.SH: T2 触发但触发分钟收盘已达涨停价 → 拒买; T3 正常触发。 + """ + days = _trading_days(30) + t1, t2, t3 = days[-4], days[-3], days[-2] # 留一天做 T+1 离场 + symbols = ["000001.SZ", "000002.SZ", "600000.SH"] + panel = _daily_panel(days, symbols) + + def _prev_close(sym: str, before: date) -> float: + return panel.filter( + (pl.col("symbol") == sym) & (pl.col("date") < before) + ).sort("date").get_column("close")[-1] + + minute_frames = { + t1: _minute_frame(t1, [ + ("000001.SZ", "09:31", round(_prev_close("000001.SZ", t1) * 1.005, 3)), + ("000001.SZ", "09:35", round(_prev_close("000001.SZ", t1) * 1.07, 3)), # 触发 + ("000002.SZ", "09:31", round(_prev_close("000002.SZ", t1) * 1.01, 3)), + ("600000.SH", "09:31", round(_prev_close("600000.SH", t1) * 1.01, 3)), + ]), + t2: _minute_frame(t2, [ + ("000001.SZ", "09:31", round(_prev_close("000001.SZ", t2) * 1.004, 3)), + ("000002.SZ", "09:31", round(_prev_close("000002.SZ", t2) * 1.01, 3)), + # 涨停拒买: 触发分钟收盘 = T-1收盘 * 1.10 (主板涨停价, 半进位后相等) + ("600000.SH", "09:40", round(_prev_close("600000.SH", t2) * 1.10, 3)), + ]), + t3: _minute_frame(t3, [ + ("000001.SZ", "09:31", round(_prev_close("000001.SZ", t3) * 1.004, 3)), + ("000002.SZ", "09:31", round(_prev_close("000002.SZ", t3) * 1.01, 3)), + ("600000.SH", "09:50", round(_prev_close("600000.SH", t3) * 1.06, 3)), # 触发 + ]), + } + service = _make_service(tmp_path, panel, minute_frames) + return service, panel, {"t1": t1, "t2": t2, "t3": t3, "t4": days[-1]}, minute_frames + + +def test_entry_at_trigger_minute_price(scenario): + service, panel, days, _ = scenario + result = service.run(_config(days["t1"], days["t3"])) + assert not result.error, result.error + entries = [t for t in result.trades if t["symbol"] == "000001.SZ"] + assert len(entries) == 1 + trade = entries[0] + # 入场价 = 触发分钟 (09:35) 收盘价, 入场时间戳精确到分钟 + prev_close = panel.filter( + (pl.col("symbol") == "000001.SZ") & (pl.col("date") < days["t1"]) + ).sort("date").get_column("close")[-1] + expected_price = round(prev_close * 1.07, 3) + assert trade["entry_price"] == pytest.approx(expected_price, abs=1e-6) + assert trade["entry_date"].startswith(f"{days['t1']} 09:35") + + +def test_daily_window_strictly_before_trigger_day(scenario): + """因果性: 测试策略拒绝含触发日的日线窗口 — 有信号即证明窗口止于 T-1。""" + service, _, days, _ = scenario + result = service.run(_config(days["t1"], days["t3"])) + assert not result.error, result.error + assert result.trades, "日线窗口若含触发日, 测试策略会拒绝命中 — 信号归零" + + +def test_limit_up_entry_rejected(scenario): + service, panel, days, _ = scenario + result = service.run(_config(days["t1"], days["t3"])) + assert not result.error, result.error + # T2 的 600000.SH 触发分钟收盘 = 涨停价 → 拒买; T3 才有它的成交 + entries_600000 = [t for t in result.trades if t["symbol"] == "600000.SH"] + assert all(t["entry_date"][:10] == str(days["t3"]) for t in entries_600000) + execution = result.stats.get("execution", {}) + assert execution.get("buy_limit_up", 0) >= 1 + replay_stats = result.stats.get("minute_replay", {}) + assert replay_stats.get("replayed_days") == 3 + + +def test_missing_partition_day_skipped(tmp_path): + days = _trading_days(30) + t1, t2, t3 = days[-4], days[-3], days[-2] + symbols = ["000001.SZ"] + panel = _daily_panel(days, symbols) + + def _prev(before: date) -> float: + return panel.filter( + (pl.col("symbol") == "000001.SZ") & (pl.col("date") < before) + ).sort("date").get_column("close")[-1] + + frames = { + t1: _minute_frame(t1, [("000001.SZ", "09:35", round(_prev(t1) * 1.07, 3))]), + t3: _minute_frame(t3, [("000001.SZ", "09:35", round(_prev(t3) * 1.06, 3))]), + # t2 无分区 → 应被跳过, 而不是回退到 t1/t3 的数据 + } + service = _make_service(tmp_path, panel, frames) + result = service.run(_config(t1, t3)) + assert not result.error, result.error + replay_stats = result.stats.get("minute_replay", {}) + assert replay_stats.get("replayed_days") == 2 + assert str(t2) in replay_stats.get("skipped_days", []) + entry_days = {t["entry_date"][:10] for t in result.trades} + assert str(t2) not in entry_days + + +def test_exit_reuses_daily_next_open(scenario): + """离场复用日K口径: max_hold=1 → 次日开盘卖出。""" + service, panel, days, _ = scenario + result = service.run(_config(days["t1"], days["t3"])) + assert not result.error, result.error + trade = next(t for t in result.trades if t["symbol"] == "000001.SZ") + entry_day = date.fromisoformat(trade["entry_date"][:10]) + exit_day = date.fromisoformat(str(trade["exit_date"])[:10]) + assert exit_day > entry_day + next_open = panel.filter( + (pl.col("symbol") == "000001.SZ") & (pl.col("date") == exit_day) + ).get_column("open")[0] + assert trade["exit_price"] == pytest.approx(next_open, abs=1e-6) + + +def test_guards(scenario, tmp_path): + service, panel, days, _ = scenario + # 信号触发卖出离场口径不支持 (通用校验或分钟分支守卫, 任一拒绝即可) + result = service.run(_config(days["t1"], days["t3"], exit_fill="signal_next_minute")) + assert result.error and "分钟" in result.error + # 无分钟分区 → 明确报错 + empty_service = _make_service(tmp_path, panel, {}) + result = empty_service.run(_config(days["t1"], days["t3"])) + assert "分钟K" in (result.error or "") From b8fa08f027ef05d285043df410cd16496b6d824f Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:19 +0800 Subject: [PATCH 18/51] =?UTF-8?q?fix(worker):=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E5=AD=90=E8=BF=9B=E7=A8=8B=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=A2=E5=A4=B1=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 整年区间回测偶发 'backtest worker exited without result (exitcode=0)'。 根因: 子进程 event_queue.put 只是把消息交给后台 feeder 线程, 主线程随即退出, feeder 随进程销毁, 大结果/高负载下消息尾部未刷入管道; 父进程 0.1s 轮询 恰在 Empty+进程已死时跳出循环, 未读到的 result 被丢弃。 修复 (双侧): - 子进程 finally 中 close+join_thread 队列, 保证退出前消息完整刷入管道 - 父进程在判定无结果前做一次兜底排空 (join 后 1s×2 轮 get) --- backend/app/backtest/worker.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/app/backtest/worker.py b/backend/app/backtest/worker.py index f4fb102..a84c295 100644 --- a/backend/app/backtest/worker.py +++ b/backend/app/backtest/worker.py @@ -254,6 +254,12 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None: if store is not None: with suppress(Exception): store.db.close() + # 保证结果消息在进程退出前完整刷入管道: put 只是入队, + # 实际写管道的是后台 feeder 线程; 不 join 的话主线程先退出, + # feeder 随进程销毁, 消息尾部丢失 → 父进程误判 "exited without result"。 + with suppress(Exception): + event_queue.close() + event_queue.join_thread() def run_worker_task( @@ -310,6 +316,21 @@ def run_worker_task( elif message_type == "error": failure = message + # 子进程退出后, 队列读线程可能尚未把管道尾部的 result/error 搬进本地缓冲 + # (0.1s 轮询在系统高负载下会先看到 Empty+进程已死)。join 后做一次兜底排空, + # 只要消息完整刷入过管道就一定能取到。 + if result is None and failure is None: + for _ in range(2): + try: + message = events.get(timeout=1.0) + except queue.Empty: + break + message_type = message.get("type") + if message_type == "result": + result = message["payload"] + elif message_type == "error": + failure = message + process.join(timeout=10.0) if process.is_alive(): process.terminate() From 0b4bde6dda544e89912a02ffc99966180f8a56ea Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:20 +0800 Subject: [PATCH 19/51] =?UTF-8?q?feat(fuyao):=20=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=20limit=3D6000=20=E4=B8=80=E6=AC=A1=E6=8B=89?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E5=B8=82=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测服务端单页 6000 不截断(全市场 ~5600 只), 一轮轮询从 11 请求降到 1 请求, 大幅降低 4001 限频压力; 分页循环保留作未来扩容/服务端截断兜底。 插件开发文档重写并补充单页上限实测结论与页间隔自限速要求。 --- backend/app/plugins/fuyao/client.py | 5 +- docs/plugin-development.md | 174 ++++++++++++++++++++++------ 2 files changed, 143 insertions(+), 36 deletions(-) diff --git a/backend/app/plugins/fuyao/client.py b/backend/app/plugins/fuyao/client.py index 821f97b..d206b5e 100644 --- a/backend/app/plugins/fuyao/client.py +++ b/backend/app/plugins/fuyao/client.py @@ -14,8 +14,9 @@ logger = logging.getLogger(__name__) BASE_URL = "https://fuyao.aicubes.cn" -# A 股约 5400 只, 500/页约 11 页; 50 页上限防御 count 异常导致的死循环。 -_SNAPSHOT_PAGE_SIZE = 500 +# 单页 6000 覆盖全市场(实测 ~5600 含北交所, 2026-08 服务端不截断 limit=6000), +# 一次请求拉完; 分页循环兜底未来标的扩容或服务端改为截断的场景。 +_SNAPSHOT_PAGE_SIZE = 6000 _SNAPSHOT_MAX_PAGES = 50 _PAGE_INTERVAL_S = 0.15 # 页间隔, 降低触发限频 (code=4001) 的概率 diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 9d04a4f..d258653 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,9 +1,13 @@ # 数据源插件开发指南 -数据源插件是可选的行情数据来源(stock-sdk、akshare 等),作为独立模块放在 -`backend/app/plugins/` 下。用户**手动安装依赖**后才可用(开发模式);不安装完全不影响主功能。 +数据源插件是可选的行情数据来源(fuyao、stock-sdk、akshare 等),作为独立模块放在 +`backend/app/plugins/` 下。services 层(kline_sync / quote_service / financial_sync) +全部通过统一路由点分流:插件声明了某数据集就走插件,未声明自动回退 TickFlow。 +因此**一个合格的插件只需要正确实现契约,不需要改动任何 service / API 代码**; +反过来,插件也必须遵守内部数据契约(单位、代码格式、复权口径),框架不会替你转换。 -> ⚠️ **Docker 默认不打包 stock-sdk**(合规考虑:它抓取第三方财经网站接口,存在版权与反爬风险)。如需在 Docker 中启用,构建时传 `--build-arg INCLUDE_STOCKSDK=1`,使用风险自负。下方"手动安装依赖"适用于开发模式及自定义 Docker 构建。 +> 无代码接入(纯 HTTP YAML 配置)请看 [custom-data-source.md](./custom-data-source.md), +> 两种方式遵循同一套内部数据契约。 ## 快速上手 @@ -13,7 +17,7 @@ backend/app/plugins// ├── plugin.yaml # 清单(必需) ├── provider.py # Provider 实现(必需) -├── ... # 桥接/依赖文件(按需) +├── ... # client/桥接/依赖文件(按需) ``` ### plugin.yaml 字段 @@ -21,16 +25,19 @@ backend/app/plugins// ```yaml name: my_source # 唯一标识, 只允许 [a-z0-9_], 也是 provider name display_name: "我的数据源" # 设置页显示名 -runtime: python # 运行时类型: node | python | none +runtime: none # 运行时类型: node | python | none entry: app.plugins.my_source.provider:MyProvider # provider 类的导入路径 check: app.plugins.my_source.bridge:availability # 可用性检测函数(可选) -datasets: [daily, adj_factor, minute, realtime] # 支持的数据集 +datasets: [realtime] # 支持的数据集: daily/adj_factor/minute/realtime/financial api_key_env: MY_SOURCE_API_KEY # (可选)声明后设置页提供 Key 输入框 hidden: false # (可选)true = 已加载但对设置页隐藏,不注册不展示 description: "数据源描述" install_hint: "pip install xxx" # 未装依赖时显示的安装提示 ``` +只声明真实提供的数据集;未声明的数据集 `provider_has_dataset` 返回 False,自动回退 +TickFlow。不要声明做不了的数据集(粒度含义见下文"能力声明的粒度")。 + #### api_key_env(界面配置 API Key) 声明 `api_key_env` 的插件可以在设置页的数据源卡片中直接填写 Key, 对齐 @@ -49,36 +56,74 @@ TickFlow 的「先探后存」语义: | runtime | 含义 | 典型场景 | |---|---|---| | `python` | 纯 Python 依赖, `pip install` | akshare、tushare | -| `node` | 需要 Node.js 运行时, `npm install` | stock-sdk(Docker 默认不打包,见 [deployment.md](./deployment.md)) | - -> stock-sdk 在 Docker 中默认不打包(合规考虑);如需启用,构建时传 `--build-arg INCLUDE_STOCKSDK=1`,开发模式下需手动 `npm install`。 +| `node` | 需要 Node.js 运行时, `npm install` | stock-sdk | | `none` | 无额外依赖 | 纯 HTTP API 源 | +> ⚠️ stock-sdk 在 Docker 中默认不打包(合规考虑:它抓取第三方财经网站接口,存在版权与 +> 反爬风险)。如需启用,构建时传 `--build-arg INCLUDE_STOCKSDK=1`,使用风险自负。 +> 详见 [deployment.md](./deployment.md)。 + `runtime` 字段当前仅用于 UI 展示, 实际依赖检测由 `check` 函数负责。 ### check 函数 -插件自己负责检测依赖是否已安装。后端启动时会调用此函数: +插件自己负责检测依赖/Key 是否就绪。后端启动时会调用此函数: ```python -# app/plugins/my_source/bridge.py +# app/plugins/my_source/provider.py (或 bridge.py) def availability() -> tuple[bool, str]: """返回 (是否可用, 原因)。不抛异常。""" - try: - import akshare # noqa: F401 - return True, "ok" - except ImportError: - return False, "未安装 akshare, 运行: pip install akshare" + if not get_api_key(): + return False, "未配置 MY_SOURCE_API_KEY(可在设置页数据源卡片中直接填写)" + return True, "ok" ``` - **可用** → 插件注册进路由表, 设置页可切换 -- **不可用** → 设置页显示插件卡片但灰显, 展示 `install_hint` +- **不可用** → 设置页显示插件卡片但灰显, 展示原因/`install_hint` + +## 内部数据契约(所有数据集必须遵守) + +以下口径是全项目红线(详见 CONTRIBUTING §3)。金融数据错误往往不抛异常,而是生成 +**看似合理的错误结果**——单位、代码格式、复权口径错了,页面照样能渲染,只是数字全错。 +插件必须在 provider 内完成适配。 + +### 代码格式 + +- symbol 统一带交易所后缀: `600519.SH` / `000001.SZ` / `300750.SZ`; ETF、指数同格式。 +- 接口返回裸代码(如 `600519`)或异构格式时,在 client 层实测一页并归一,不要直接透传。 + +### 单位制 + +| 字段 | 契约 | 说明 | +| --- | --- | --- | +| `change_pct` | **小数制**, `0.0366` = 3.66% | 接口给百分数(3.66)时必须在 provider 内显式 /100 | +| `turnover_rate`(realtime 入口) | **小数制**, `0.05` = 5% | 下游 enriched 管道统一转百分数值存储 | +| `volume` | 股 | | +| `amount` / `turnover` | 元 | | +| 日K OHLC | **不复权原始价** | 复权由 adj_factor + enriched 管道处理, provider 不得自行复权 | + +### 缺字段与空数据 + +- 接口不提供的字段返回 `None`,禁止"数值小于 1 就乘 100"之类启发式补全——那会掩盖 + 真实的数据错误。 +- 可推导字段按固定口径推导: `change_pct = change_amount / prev_close`(小数制,不乘 100)。 +- 接口结构整体变化(如所有行都识别不出 symbol)要打明确告警日志,不要静默返回空数据。 + +## 能力声明的粒度(重要) + +`datasets` 声明是**数据集级**的,不是资产类型级的:声明了 `realtime`,整个全市场实时 +轮询周期(含指数与 ETF 部分)就全部路由给插件。若你的快照只覆盖 A 股股票: + +- 指数行情自动降级为日线推导值(非实时),不报错; +- ETF 实时计数为 0。 + +这是当前框架的设计行为。要么在数据里尽量覆盖指数/ETF,要么接受降级并在 +`description` 里向用户说明覆盖范围。 ## Provider 接口契约 -Provider 是一个普通 Python 类(无需继承基类), 实现以下方法签名。方法签名对齐 -`GenericHTTPProvider`, 这样 services 层(kline_sync / quote_service 等)的路由逻辑 -零改动即可路由到插件。 +Provider 是普通 Python 类(无需继承基类),方法签名对齐 `GenericHTTPProvider`, +services 层零改动即可路由。只实现已声明数据集对应的方法,其余可缺省。 ```python class MyProvider: @@ -91,22 +136,57 @@ class MyProvider: def close(self) -> None: """清理资源(load_all 重建注册表时会调)。""" - def get_daily(self, symbols, start_time, end_time, asset_type="stock", on_chunk_done=None) -> pl.DataFrame: - """日K: 返回 schema [symbol, date, open, high, low, close, volume, amount]""" + def get_daily(self, symbols, start_time, end_time, asset_type="stock", + on_chunk_done=None) -> pl.DataFrame: + """日K: [symbol, date, open, high, low, close, volume, amount]; 不复权""" - def get_adj_factors(self, symbols, start_time, end_time, asset_type="stock", on_chunk_done=None) -> pl.DataFrame: - """除权因子: 返回 schema [symbol, trade_date, ex_factor]""" + def get_adj_factors(self, symbols, start_time, end_time, asset_type="stock", + on_chunk_done=None) -> pl.DataFrame: + """除权因子: [symbol, trade_date, ex_factor]""" - def get_minute(self, symbols, start_time, end_time, asset_type="stock", on_chunk_done=None, freq="1m") -> pl.DataFrame: - """分钟K: 返回 schema [symbol, datetime, open, high, low, close, volume, amount]""" + def get_minute(self, symbols, start_time, end_time, asset_type="stock", + on_chunk_done=None, freq="1m") -> pl.DataFrame: + """分钟K: [symbol, datetime, open, high, low, close, volume, amount]""" def get_realtime(self) -> list[dict]: - """全市场实时快照: 返回 list[dict], 每行含 symbol/last_price/prev_close/open/high/low/volume""" + """全市场实时快照 → list[dict]。失败软返回 [], 不抛异常(不阻断轮询线程)。""" + + def get_financials(self, table, symbols, latest_only=False) -> pl.DataFrame: + """财务数据(声明 financial 数据集时实现, table 见 financial_sync 调用)。""" def get_instruments(self, asset_type="stock") -> list[dict]: - """标的维表(可选): 返回 tickflow Instrument 形状的行, 供 instrument_sync 复用 flatten""" + """(可选)标的维表: 返回 tickflow Instrument 形状的行, 供 instrument_sync 复用 flatten""" + + def test_dataset(self, dataset: str, symbols=None) -> dict: + """(强烈建议)设置页"试拉"按钮。 + 返回 {provider, dataset, rows, columns, preview, error?}; 未支持的数据集 + 返回 error 字段说明会回退 TickFlow。""" ``` +### 异常语义 + +| 方法 | 失败行为 | +| --- | --- | +| `get_realtime` | **软失败**: 返回 `[]` + warning 日志, 保证轮询线程不中断 | +| `get_minute` | 抛异常时调用方自动回退 TickFlow 重试 | +| `get_daily` / `get_adj_factors` / `get_financials` | 异常由上层同步流程捕获记录; 无数据返回空 DataFrame | + +### get_realtime 行字段 + +| 字段 | 必需 | 契约 | +| --- | --- | --- | +| `symbol` | ✅ | 标准代码带后缀 | +| `last_price` | ✅ | 最新价 | +| `prev_close` | ✅ | 昨收, 涨跌幅推导基准 | +| `open` / `high` / `low` | ✅ | 当日 OHLC | +| `volume` | ✅ | 股 | +| `amount` | 建议 | 成交额(元) | +| `change_pct` | 建议 | **小数制**; 缺失时下游按 change_amount/prev_close 推导 | +| `change_amount` | 建议 | 涨跌额(元) | +| `timestamp` | 建议 | 毫秒; 优先用服务端时间(行情归属), 缺失退本地时间 | +| `name` | 可选 | 快照无名称时置 None, 下游用标的维表关联 | +| `amplitude` / `turnover_rate` / `session` | 可选 | 缺失置 None, 不启发式伪造; turnover_rate 入口为小数制 | + ### config.datasets 的作用 `provider_has_dataset(name, dataset)` 通过 `dataset in provider.config.datasets` 判断。 @@ -118,14 +198,40 @@ class MyConfig: datasets = {"daily": ..., "realtime": ...} # key 是数据集名, value 任意 ``` +## 限频与性能 + +- realtime 默认 6s 轮询一轮。优先确认服务端单次 limit 上限: fuyao 实测单页 + limit=6000 可一次拉完全市场(~5600 只), 1 请求/轮; 若服务端强制小页, 必须做 + 页间隔/自限速(参考 fuyao 的 0.15s 页间隔兜底), 并建议用户把轮询间隔调大(15-30s)。 +- 分页必须有页数上限(防 count 异常导致死循环)和空页终止条件。 +- 拉取由 fetch 锁串行化, 慢不会并发重叠; 实际刷新周期 = 轮询间隔 + 拉取耗时, + 串行分页的全量快照本身就需要数秒, 不要按"6s 内必须完成"设计。 + +## 测试要求 + +插件 PR 必须带契约测试(CONTRIBUTING §9), **不依赖真实网络与 API Key**——用假 +Client/桥接注入。以 `backend/tests/test_fuyao_provider.py` 为范本, 至少覆盖: + +1. 字段映射与单位转换: 百分数→小数制、缺失字段按口径推导、缺失字段置 None 不伪造 +2. 接口响应结构变体: 实测结构 vs 官方文档示例双兼容(供应商文档与实际不一致是常态) +3. 分页: 多页合并、空页终止、页数上限 +4. 软失败: 接口报错返回 []; 整页 schema 变化有告警而非静默空数据 +5. 能力声明: 未声明数据集 `provider_has_dataset` 为 False +6. Key 语义: 先探后存(无效不落盘)、secrets.json > .env 优先级、availability 两态 +7. loader 集成: 清单解析后正确注册(或 hidden 时正确跳过) + +```bash +cd backend && uv run --extra dev python -m pytest tests/test__provider.py -q +uv run --extra dev python -m ruff check app/plugins// tests/test__provider.py +``` + ## 现有插件参考 - **`backend/app/plugins/fuyao/`** — 同花顺官方 REST 数据源(runtime: none, 纯 HTTP 零依赖) - 当前提供 `realtime`(A 股全市场快照, 分页拉取); Key 在设置页卡片直接配置(先探后存), 或 `.env` 配 `FUYAO_API_KEY` - - `client.py` — httpx 客户端(X-api-key 认证 + 统一信封解包 + 分页) - - `provider.py` — Provider 实现(字段映射、百分数→小数制单位转换、软失败、Key 探测) - - 单位口径注意: 扶摇 `price_change_ratio_pct` 为百分数数值(1.74 = +1.74%), - 内部 `change_pct` 契约为小数制, provider 内显式 / 100(见 CONTRIBUTING §3.1) + - `client.py` — httpx 客户端(X-api-key 认证 + 统一信封解包 + 分页 + 页间隔限频) + - `provider.py` — Provider 实现(实测/文档双字段名映射、百分数→小数制、软失败、Key 探测) + - `tests/test_fuyao_provider.py` — 32 个契约测试, 是新插件的测试范本 - **`backend/app/plugins/stocksdk/`** — Node 型插件, 通过 subprocess 桥接调用 stock-sdk - `bridge.py` — Python↔Node 桥接 + availability 检测 - `bridge.mjs` — Node 端(并发池、重试、SDK 解析) @@ -133,9 +239,9 @@ class MyConfig: ## 路由机制(无需关心, 仅参考) -后端启动时, `loader.py` 的 `_load_builtin_plugins()` 扫描 `plugins/` 目录: +后端启动时, `loader.py` 扫描 `plugins/` 目录: 1. 读每个子目录的 `plugin.yaml` -2. 调 `check` 函数检测可用性 +2. `hidden: true` → 跳过(不注册不展示); 否则调 `check` 函数检测可用性 3. 可用 → 动态 import `entry` 指向的 Provider 类 → 注册进 `_PROVIDERS` 4. 不可用 → 记录状态, 设置页显示但不可切换 From 578a531743c348f8c40706f87a308f734120dfca Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:20 +0800 Subject: [PATCH 20/51] =?UTF-8?q?fix(data):=20=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=BA=90=E6=AF=94=E4=BE=8B=E5=AD=97=E6=AE=B5=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20pct=5F?= =?UTF-8?q?unit=20=E6=9C=AA=E5=A3=B0=E6=98=8E=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amplitude/turnover_rate 的百分制与小数制数值区间重叠(0.05 既可能是 0.05% 也可能是 5%), 截面中位数启发式不可判, 赌错即整体放大 100 倍, 违反 CONTRIBUTING §3.1 禁止启发式转换的约束。 - realtime 数据集新增 pct_unit: percent|decimal 显式声明, 声明即契约 (percent 无条件 /100, decimal 无条件透传, 不受数值外观影响) - 未声明时 change_pct 保留涨跌停 30% 上限的截面判定(物理可判), amplitude/turnover_rate 置 None 交 enriched 管道按价格/股本口径重算 并记录 WARNING; 已配置 transforms 的列视为用户接管单位, 透传 - 配置解析/清洗/序列化全链路校验取值, 非 realtime 数据集声明即报错 - 契约测试重写覆盖声明优先、边界值、fail-closed 与 transforms 兼容 --- backend/app/data_providers/custom/config.py | 8 + backend/app/data_providers/custom/loader.py | 8 + backend/app/data_providers/custom/provider.py | 67 ++++- backend/tests/test_custom_pct_units.py | 274 +++++++++++++++--- docs/custom-data-source.md | 24 +- 5 files changed, 330 insertions(+), 51 deletions(-) diff --git a/backend/app/data_providers/custom/config.py b/backend/app/data_providers/custom/config.py index cf4b732..e5004b3 100644 --- a/backend/app/data_providers/custom/config.py +++ b/backend/app/data_providers/custom/config.py @@ -37,6 +37,9 @@ class DatasetConfig: end_param: str = "end_time" asset_type_param: str | None = None freq_param: str | None = None + # realtime 比例字段(change_pct/amplitude/turnover_rate)的单位声明: + # "percent"(返回 3.66 表示 3.66%)或 "decimal"(返回 0.0366 表示 3.66%)。 + pct_unit: str | None = None @dataclass(frozen=True) @@ -75,6 +78,10 @@ def _dataset_from_dict(raw: dict[str, Any]) -> DatasetConfig: if not 0 < timeout <= MAX_TIMEOUT: raise ValueError(f"timeout must be between 0 and {MAX_TIMEOUT:g} seconds") + pct_unit = str(raw.get("pct_unit") or "").strip().lower() or None + if pct_unit not in (None, "percent", "decimal"): + raise ValueError(f"pct_unit must be 'percent' or 'decimal', got {pct_unit!r}") + return DatasetConfig( url=str(raw.get("url", "") or ""), method=str(raw.get("method", "GET") or "GET").upper(), @@ -91,6 +98,7 @@ def _dataset_from_dict(raw: dict[str, Any]) -> DatasetConfig: end_param=str(raw.get("end_param", "end_time") or "end_time").strip() or "end_time", asset_type_param=(str(raw.get("asset_type_param") or "").strip() or None), freq_param=(str(raw.get("freq_param") or "").strip() or None), + pct_unit=pct_unit, ) diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index 4f3aa1d..2a5985b 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -353,6 +353,7 @@ def _config_to_dict(config: CustomSourceConfig) -> dict: } if ds_name != "realtime" else {}), **({"asset_type_param": ds.asset_type_param} if ds_name == "minute" and ds.asset_type_param else {}), **({"freq_param": ds.freq_param} if ds_name == "minute" and ds.freq_param else {}), + **({"pct_unit": ds.pct_unit} if ds_name == "realtime" and ds.pct_unit else {}), } return out @@ -476,6 +477,13 @@ def _sanitize_dataset(ds_name: str, ds_cfg: dict) -> dict: out["start_param"] = start_param if end_param: out["end_param"] = end_param + pct_unit = str(ds_cfg.get("pct_unit") or "").strip().lower() + if pct_unit: + if ds_name != "realtime": + raise ValueError(f"{ds_name}: pct_unit 仅用于 realtime 数据集") + if pct_unit not in ("percent", "decimal"): + raise ValueError(f"{ds_name}: pct_unit 必须是 percent 或 decimal") + out["pct_unit"] = pct_unit if ds_name == "minute": asset_type_param = str(ds_cfg.get("asset_type_param") or "").strip() freq_param = str(ds_cfg.get("freq_param") or "").strip() diff --git a/backend/app/data_providers/custom/provider.py b/backend/app/data_providers/custom/provider.py index 0d6f031..346e894 100644 --- a/backend/app/data_providers/custom/provider.py +++ b/backend/app/data_providers/custom/provider.py @@ -34,29 +34,56 @@ _REQUIRED = { "financial": {"symbol"}, } -# 小数制下 change_pct/amplitude/turnover_rate 的物理上限: A股最大涨跌停 30% (+容差)。 +# 小数制下 change_pct 的物理上限: A股最大涨跌停 30% (+容差)。 # 中位数口径下小数制批次不可能超过该值, 百分制批次(典型中位数 0.5~3)必然超过。 +# 仅对 change_pct 有效——amplitude/turnover_rate 的两种单位在数值区间上重叠 +# (百分制 0.05 = 0.05% 与小数制 0.05 = 5%), 无物理依据可判。 _PCT_FRACTION_MAX = 0.31 +_PCT_COLUMNS = ("change_pct", "amplitude", "turnover_rate") -def _normalize_pct_units(df: pl.DataFrame) -> pl.DataFrame: - """百分制源自适应归一为小数制 (契约: change_pct/amplitude/turnover_rate 为小数, - 0.0366 = 3.66%)。不少第三方接口(如 a-stock-data)直接返回 3.66 表示 3.66%, - 若不归一, 下游(行业/概念统计、前端 x100 展示)会整体放大 100 倍。 - 截面判定: 样本 >= 5 用 |值| 中位数(对个别无涨跌幅限制新股免疫), - 小样本退用最大值。整批同除 100, 避免逐值阈值在 0.3~1 区间的歧义。 +def _normalize_pct_units( + df: pl.DataFrame, + pct_unit: str | None = None, + transformed_cols: frozenset[str] = frozenset(), +) -> pl.DataFrame: + """比例字段单位归一为契约小数制 (change_pct/amplitude/turnover_rate, + 0.0366 = 3.66%, CONTRIBUTING §3.1)。单位只认显式声明, 不靠数值猜: + + - pct_unit="percent" → 三列无条件 /100 (声明即契约, 即使数值看着像小数制); + - pct_unit="decimal" → 原样透传 (即使数值看着像百分制也不动); + - 未声明 → change_pct 保留截面中位数判定(涨跌停 30% 上限使其物理可判: + 样本 >= 5 用 |值| 中位数, 小样本退用最大值, 整批同除 100); + amplitude/turnover_rate 置 None 交下游重算(enriched 管道按 + high/low/prev_close 与股本口径重算), 除非该列已被 transforms 显式 + 处理过(视为用户已接管单位, 原样透传)。 """ - for col in ("change_pct", "amplitude", "turnover_rate"): + dropped_undeclared = False + for col in _PCT_COLUMNS: if col not in df.columns: continue df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False).alias(col)) - vals = df[col].drop_nulls().abs() - if vals.is_empty(): - continue - stat = vals.median() if vals.len() >= 5 else vals.max() - if stat > _PCT_FRACTION_MAX: + if pct_unit == "percent": df = df.with_columns((pl.col(col) / 100).alias(col)) + elif pct_unit == "decimal" or col in transformed_cols: + continue + elif col == "change_pct": + vals = df[col].drop_nulls().abs() + if vals.is_empty(): + continue + stat = vals.median() if vals.len() >= 5 else vals.max() + if stat > _PCT_FRACTION_MAX: + df = df.with_columns((pl.col(col) / 100).alias(col)) + else: + df = df.with_columns(pl.lit(None, dtype=pl.Float64).alias(col)) + dropped_undeclared = True + if dropped_undeclared: + logger.warning( + "自定义源 realtime 未声明 pct_unit: amplitude/turnover_rate 的单位" + "无法从数值判定, 已置 None 交由下游按股本/价格口径重算;" + "请在 realtime 数据集配置中显式声明 pct_unit: percent 或 decimal" + ) return df @@ -82,6 +109,11 @@ class GenericHTTPProvider: missing = sorted(required - mapped) if missing: errors.append(f"{dataset}: missing mapped fields: {', '.join(missing)}") + if cfg.pct_unit is not None: + if dataset != "realtime": + errors.append(f"{dataset}: pct_unit 仅用于 realtime 数据集") + elif cfg.pct_unit not in ("percent", "decimal"): + errors.append(f"{dataset}: pct_unit 必须是 percent 或 decimal") if dataset != "realtime": request_params = [cfg.symbols_param, cfg.start_param, cfg.end_param] if dataset == "minute": @@ -146,8 +178,13 @@ class GenericHTTPProvider: cfg = self._dataset("realtime") rows = self._request_rows(cfg) df = self._mapped_frame(cfg, rows) - # 百分制源(返回 3.66 表示 3.66%)截面归一为契约小数制 - df = _normalize_pct_units(df) + # 单位归一: 显式 pct_unit 声明优先; 未声明时 amplitude/turnover_rate + # fail-closed 置 None(交下游重算), change_pct 保留截面判定 + df = _normalize_pct_units( + df, + pct_unit=cfg.pct_unit, + transformed_cols=frozenset(cfg.transforms) & set(_PCT_COLUMNS), + ) if df.is_empty(): return [] return df.to_dicts() diff --git a/backend/tests/test_custom_pct_units.py b/backend/tests/test_custom_pct_units.py index 50aeab6..616351d 100644 --- a/backend/tests/test_custom_pct_units.py +++ b/backend/tests/test_custom_pct_units.py @@ -1,15 +1,19 @@ -"""自定义源实时行情涨跌幅单位自适应归一测试。 +"""自定义源实时行情比例字段单位归一测试 (CONTRIBUTING §3.1)。 -契约要求 change_pct/amplitude/turnover_rate 用小数制 (0.0366 = 3.66%), -但不少第三方接口(如 a-stock-data)直接返回 3.66 表示 3.66%。未归一会把 -行业/概念统计与前端 x100 展示整体放大 100 倍(用户反馈)。 +契约: change_pct/amplitude/turnover_rate 为小数制 (0.0366 = 3.66%)。 +单位只认显式声明 pct_unit: percent|decimal, 不靠数值猜: + - 声明 percent → 无条件 /100; 声明 decimal → 无条件透传; + - 未声明 → change_pct 保留截面中位数判定(涨跌停 30% 上限物理可判), + amplitude/turnover_rate 置 None 交下游重算(fail-closed), + 已被 transforms 显式处理过的列视为用户接管单位, 透传。 """ + from __future__ import annotations import polars as pl import pytest -from app.data_providers.custom.config import CustomSourceConfig, DatasetConfig +from app.data_providers.custom.config import CustomSourceConfig, DatasetConfig, config_from_dict from app.data_providers.custom.provider import GenericHTTPProvider, _normalize_pct_units @@ -22,24 +26,64 @@ def _df(pcts, amps=None, turnovers=None): return pl.DataFrame(data) -def test_percent_unit_batch_is_divided_by_100(): - out = _normalize_pct_units(_df( - [1.5, -2.2, 0.9, 2.8, -1.1, 0.6, 3.3, -0.8], - amps=[2.0, 3.5, 1.8, 4.0, 2.5, 1.2, 5.0, 1.6], - turnovers=[0.5, 1.2, 0.8, 2.0, 0.9, 0.4, 1.5, 0.7], - )) +# ---- 显式声明: percent ---- + + +def test_declared_percent_divides_all_columns(): + out = _normalize_pct_units( + _df( + [1.5, -2.2, 0.9, 2.8, -1.1, 0.6, 3.3, -0.8], + amps=[2.0, 3.5, 1.8, 4.0, 2.5, 1.2, 5.0, 1.6], + turnovers=[0.5, 1.2, 0.8, 2.0, 0.9, 0.4, 1.5, 0.7], + ), + pct_unit="percent", + ) assert out["change_pct"][0] == pytest.approx(0.015) assert out["amplitude"][0] == pytest.approx(0.02) assert out["turnover_rate"][0] == pytest.approx(0.005) -def test_fraction_unit_batch_untouched(): +def test_declared_percent_wins_even_when_values_look_decimal(): + # 百分制低波动日: 0.25 表示 0.25%, 数值落在小数制区间内——声明优先, 不靠猜 + out = _normalize_pct_units( + _df( + [0.25, 0.30, 0.28, 0.27, 0.26, 0.22], + amps=[0.4, 0.5, 0.45, 0.6, 0.5, 0.4], + turnovers=[0.05, 0.08, 0.06, 0.1, 0.07, 0.05], + ), + pct_unit="percent", + ) + assert out["change_pct"][0] == pytest.approx(0.0025) + assert out["amplitude"][0] == pytest.approx(0.004) + assert out["turnover_rate"][0] == pytest.approx(0.0005) + + +# ---- 显式声明: decimal ---- + + +def test_declared_decimal_passes_through(): pcts = [0.015, -0.022, 0.009, 0.028, -0.011, 0.006, 0.033, -0.008] - out = _normalize_pct_units(_df(pcts, amps=[0.02, 0.035, 0.018, 0.04, 0.025, 0.012, 0.05, 0.016])) + out = _normalize_pct_units( + _df(pcts, amps=[0.02, 0.035, 0.018, 0.04, 0.025, 0.012, 0.05, 0.016]), pct_unit="decimal" + ) assert out["change_pct"].to_list() == pcts assert out["amplitude"][0] == pytest.approx(0.02) +def test_declared_decimal_wins_even_when_values_look_percent(): + # 用户声明了小数制就按小数制契约透传, 不替用户"修正"数据 + out = _normalize_pct_units(_df([3.66, -2.15, 0.9, 2.8, 1.1]), pct_unit="decimal") + assert out["change_pct"][0] == pytest.approx(3.66) + + +# ---- 未声明: change_pct 保留截面判定(物理可判) ---- + + +def test_undeclared_change_pct_percent_batch_normalized(): + out = _normalize_pct_units(_df([1.5, -2.2, 0.9, 2.8, 3.3, 0.6])) + assert out["change_pct"][0] == pytest.approx(0.015) + + def test_limit_up_fraction_30cm_not_divided(): # 北交所 30% 涨跌停的小数制极值不应被误判为百分制 out = _normalize_pct_units(_df([0.30, 0.29, 0.28, 0.27, 0.26])) @@ -60,38 +104,85 @@ def test_string_values_are_cast(): assert out["change_pct"][0] == pytest.approx(0.015) +# ---- 未声明: amplitude/turnover_rate fail-closed (核心修复) ---- + + +def test_undeclared_amplitude_and_turnover_are_nulled(): + # 百分制 0.05 = 0.05% 与小数制 0.05 = 5% 数值相同, 不可判定 → 置 None + out = _normalize_pct_units( + _df( + [1.5, -2.2, 0.9, 2.8, 3.3, 0.6], + amps=[2.0, 3.5, 1.8, 4.0, 5.0, 1.6], + turnovers=[0.05, 1.2, 0.8, 2.0, 1.5, 0.7], + ) + ) + assert out["amplitude"].null_count() == 6 + assert out["turnover_rate"].null_count() == 6 + # change_pct 仍正常归一 + assert out["change_pct"][0] == pytest.approx(0.015) + + +def test_undeclared_transformed_column_passes_through(): + # 用户已用 transforms 显式处理过单位(如 value / 100)的列: 视为接管, 不置 None + out = _normalize_pct_units( + _df([1.5, -2.2, 0.9, 2.8, 3.3, 0.6], turnovers=[0.005, 0.012, 0.008, 0.02, 0.015, 0.007]), + transformed_cols=frozenset({"turnover_rate"}), + ) + assert out["turnover_rate"][0] == pytest.approx(0.005) + # 未 transform 的 amplitude 仍 fail-closed + assert "amplitude" not in out.columns + + def test_missing_or_null_columns_noop(): out = _normalize_pct_units(pl.DataFrame({"close": [1.0, 2.0]})) assert out.columns == ["close"] out2 = _normalize_pct_units(_df([None, None, None, None, None, None])) assert out2["change_pct"].null_count() == 6 + # 全 null 的不可判定列保持 null + out3 = _normalize_pct_units(_df([1.5, -2.2, 0.9, 2.8, 3.3, 0.6], turnovers=[None] * 6)) + assert out3["turnover_rate"].null_count() == 6 -def _realtime_provider(rows): - provider = GenericHTTPProvider(CustomSourceConfig( - name="pct_source", - display_name="Pct Source", - datasets={"realtime": DatasetConfig( - url="https://example.test/realtime", - field_map={ - "code": "symbol", "price": "last_price", "pre_close": "prev_close", - "pct": "change_pct", "amp": "amplitude", "turnover": "turnover_rate", +# ---- provider 集成 ---- + + +def _realtime_provider(rows, **ds_kwargs): + provider = GenericHTTPProvider( + CustomSourceConfig( + name="pct_source", + display_name="Pct Source", + datasets={ + "realtime": DatasetConfig( + url="https://example.test/realtime", + field_map={ + "code": "symbol", + "price": "last_price", + "pre_close": "prev_close", + "pct": "change_pct", + "amp": "amplitude", + "turnover": "turnover_rate", + }, + **ds_kwargs, + ) }, - )}, - )) + ) + ) provider._request_rows = lambda cfg, **kwargs: rows return provider -def test_get_realtime_normalizes_percent_source(): - provider = _realtime_provider([ - {"code": "S1", "price": 10.0, "pre_close": 9.85, "pct": 1.52, "amp": 2.4, "turnover": 1.1}, - {"code": "S2", "price": 20.0, "pre_close": 20.44, "pct": -2.15, "amp": 3.1, "turnover": 0.8}, - {"code": "S3", "price": 30.0, "pre_close": 29.8, "pct": 0.67, "amp": 1.9, "turnover": 0.5}, - {"code": "S4", "price": 40.0, "pre_close": 38.9, "pct": 2.83, "amp": 4.2, "turnover": 2.0}, - {"code": "S5", "price": 50.0, "pre_close": 50.55, "pct": -1.09, "amp": 2.0, "turnover": 0.9}, - {"code": "S6", "price": 60.0, "pre_close": 59.64, "pct": 0.60, "amp": 1.6, "turnover": 0.7}, - ]) +_ROWS = [ + {"code": "S1", "price": 10.0, "pre_close": 9.85, "pct": 1.52, "amp": 2.4, "turnover": 1.1}, + {"code": "S2", "price": 20.0, "pre_close": 20.44, "pct": -2.15, "amp": 3.1, "turnover": 0.8}, + {"code": "S3", "price": 30.0, "pre_close": 29.8, "pct": 0.67, "amp": 1.9, "turnover": 0.5}, + {"code": "S4", "price": 40.0, "pre_close": 38.9, "pct": 2.83, "amp": 4.2, "turnover": 2.0}, + {"code": "S5", "price": 50.0, "pre_close": 50.55, "pct": -1.09, "amp": 2.0, "turnover": 0.9}, + {"code": "S6", "price": 60.0, "pre_close": 59.64, "pct": 0.60, "amp": 1.6, "turnover": 0.7}, +] + + +def test_get_realtime_declared_percent_source(): + provider = _realtime_provider(_ROWS, pct_unit="percent") try: rows = provider.get_realtime() finally: @@ -101,3 +192,120 @@ def test_get_realtime_normalizes_percent_source(): assert by_sym["S1"]["amplitude"] == pytest.approx(0.024) assert by_sym["S1"]["turnover_rate"] == pytest.approx(0.011) assert by_sym["S2"]["change_pct"] == pytest.approx(-0.0215) + + +def test_get_realtime_undeclared_nulls_ambiguous_columns(): + provider = _realtime_provider(_ROWS) + try: + rows = provider.get_realtime() + finally: + provider.close() + by_sym = {r["symbol"]: r for r in rows} + # change_pct 截面判定仍归一 + assert by_sym["S1"]["change_pct"] == pytest.approx(0.0152) + # 不可判定列 fail-closed + assert by_sym["S1"]["amplitude"] is None + assert by_sym["S1"]["turnover_rate"] is None + + +def test_get_realtime_transformed_turnover_kept(): + provider = _realtime_provider(_ROWS, transforms={"turnover_rate": "value / 100"}) + try: + rows = provider.get_realtime() + finally: + provider.close() + by_sym = {r["symbol"]: r for r in rows} + assert by_sym["S1"]["turnover_rate"] == pytest.approx(0.011) + assert by_sym["S1"]["amplitude"] is None + + +# ---- 配置解析与校验 ---- + + +def test_config_parses_pct_unit(): + cfg = config_from_dict( + { + "name": "s", + "datasets": { + "realtime": { + "url": "https://example.test", + "pct_unit": "Percent", + } + }, + } + ) + assert cfg.datasets["realtime"].pct_unit == "percent" + + +def test_config_rejects_invalid_pct_unit(): + with pytest.raises(ValueError, match="pct_unit"): + config_from_dict( + { + "name": "s", + "datasets": { + "realtime": { + "url": "https://example.test", + "pct_unit": "basis_point", + } + }, + } + ) + + +def test_validate_flags_pct_unit_on_non_realtime(): + provider = GenericHTTPProvider( + CustomSourceConfig( + name="s", + display_name="S", + datasets={ + "daily": DatasetConfig( + url="https://example.test", + field_map={ + "c": "symbol", + "d": "date", + "o": "open", + "h": "high", + "l": "low", + "cl": "close", + "v": "volume", + "a": "amount", + }, + pct_unit="percent", + ) + }, + ) + ) + try: + errors = provider.validate() + finally: + provider.close() + assert any("pct_unit" in e and "realtime" in e for e in errors) + + +def test_validate_flags_invalid_pct_unit_value(): + provider = GenericHTTPProvider( + CustomSourceConfig( + name="s", + display_name="S", + datasets={ + "realtime": DatasetConfig( + url="https://example.test", + field_map={ + "c": "symbol", + "p": "last_price", + "pc": "prev_close", + "o": "open", + "h": "high", + "l": "low", + "v": "volume", + }, + pct_unit="bp", + ) + }, + ) + ) + try: + errors = provider.validate() + finally: + provider.close() + assert any("pct_unit" in e for e in errors) diff --git a/docs/custom-data-source.md b/docs/custom-data-source.md index e4cc73d..ae2f1b1 100644 --- a/docs/custom-data-source.md +++ b/docs/custom-data-source.md @@ -125,7 +125,23 @@ datasets: 建议实时接口额外提供 `amount`、`change_pct`、`change_amount`、`amplitude`、`turnover_rate`、`name`。缺失时部分字段会由 pipeline 回算,但精度取决于可用输入。 -`change_pct` 和 `amplitude` 使用小数制,例如 `0.0366` 表示 `3.66%`(`turnover_rate` 同)。若接口直接返回百分数值 `3.66`,实时行情会按截面中位数自动归一为小数制,但仍建议接口直接提供小数制以避免小样本歧义。 +`change_pct`、`amplitude`、`turnover_rate` 统一使用小数制,例如 `0.0366` 表示 `3.66%`。百分制单位必须在 realtime 数据集上**显式声明**,不做数值猜测(数值无法区分两种单位:`0.05` 既可能是 0.05% 也可能是 5%): + +```yaml +datasets: + realtime: + url: https://api.example.com/snapshot + pct_unit: percent # 接口返回 3.66 表示 3.66%;小数制源声明 decimal 或省略 +``` + +处理规则: + +| 声明 | 行为 | +| --- | --- | +| `pct_unit: percent` | `change_pct` / `amplitude` / `turnover_rate` 无条件 `/100` | +| `pct_unit: decimal` | 三列原样透传 | +| 未声明 | `change_pct` 按截面中位数归一(A 股涨跌停 30% 上限使两种单位物理可分);`amplitude` / `turnover_rate` **置 `None`** 交由 pipeline 按价格与股本口径重算,并记录 WARNING | +| 列已配置 `transforms` | 视为用户已接管该列单位,原样透传 | ## 请求约定 @@ -273,8 +289,10 @@ cp docs/examples/custom-data-source/mock_source.yaml data/data_sources/mock_sour amount = 成交额 change_pct = 涨跌幅 (小数, 0.0366 = 3.66%) change_amount = 涨跌额 - amplitude = 振幅 - turnover_rate = 换手率 (小数, 0.05 = 5%; 若上游返回 5 表示 5%, 配置 transforms: turnover_rate: "value / 100") + amplitude = 振幅 (小数, 0.024 = 2.4%) + turnover_rate = 换手率 (小数, 0.05 = 5%) + # 上游若返回百分数值 (3.66 表示 3.66%), 在 realtime 数据集声明 pct_unit: percent, + # 不要依赖数值自动识别; 逐列转换也可用 transforms: turnover_rate: "value / 100" 分钟K (minute): symbol = 股票代码 From b4aa5918fcc43b408f4feaa397b1190bb102a3eb Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:21 +0800 Subject: [PATCH 21/51] =?UTF-8?q?feat(data):=20=E8=83=BD=E5=8A=9B=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E7=9F=A9=E9=98=B5=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=BA=94=E6=A1=A3=E5=85=A5=E5=86=8C=E3=80=81=E9=99=A4=E6=9D=83?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=B7=AF=E7=94=B1=E3=80=81=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=BA=90=E4=B8=8B=E9=99=90=E6=94=BE=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增能力注册表 capabilities.py 与 /capability-matrix: 六能力(实时/日K/ 分钟/五档/除权/财务)统一 candidates/pending/usable, 各页门控以 usable 为准 - 五档盘口 (depth5) 进入矩阵: tf_tier=pro, 插件数据集白名单暂未开放 - 除权「跟随日K」(same_as_daily) 下线: 每能力独立路由, 四处跟随解析移除, 存量旧值经 preferences getter 回退 tickflow 自愈; 矩阵显示与实际拉取口径一致 - 实时轮询下限: 路由到插件/自定义源时不受 TickFlow 档位限速, 通用下限 1s (默认间隔仍 6s, TickFlow 路由档位查表不变) - 插件 manifest 新增 homepage 透传; loader 输出 api_key_masked (Key 脱敏 与 TickFlow 同一展示契约); fuyao 声明官网 --- backend/app/api/settings.py | 33 ++- backend/app/data_providers/capabilities.py | 190 ++++++++++++++++ backend/app/data_providers/custom/loader.py | 16 ++ backend/app/jobs/daily_pipeline.py | 2 - backend/app/plugins/fuyao/plugin.yaml | 2 +- backend/app/services/extend_history.py | 2 - backend/app/services/kline_sync.py | 2 - backend/app/services/preferences.py | 12 +- backend/app/services/quote_service.py | 9 +- backend/app/tickflow/policy.py | 2 - backend/tests/test_capability_augment.py | 8 +- backend/tests/test_capability_matrix.py | 233 ++++++++++++++++++++ backend/tests/test_fuyao_provider.py | 47 +++- backend/tests/test_quote_interval_min.py | 48 ++++ 14 files changed, 581 insertions(+), 25 deletions(-) create mode 100644 backend/app/data_providers/capabilities.py create mode 100644 backend/tests/test_capability_matrix.py create mode 100644 backend/tests/test_quote_interval_min.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 9cf8098..131d35c 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -397,6 +397,7 @@ class DataProvidersIn(BaseModel): daily_data_provider: str | None = None adj_factor_provider: str | None = None minute_data_provider: str | None = None + depth5_data_provider: str | None = None realtime_data_provider: str | None = None financial_data_provider: str | None = None @@ -483,6 +484,7 @@ def get_preferences() -> dict: "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), "minute_data_provider": preferences.get_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), "realtime_data_provider": preferences.get_realtime_data_provider(), "financial_data_provider": preferences.get_financial_provider(), "data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(), @@ -543,6 +545,31 @@ def list_data_sources() -> dict: } +@router.get("/capability-matrix") +def get_capability_matrix() -> dict: + """能力 x 源路由矩阵: 能力注册表 + 各源能力声明 + 当前路由偏好, 设置页一次拉全。 + + 偏好值经 preferences getters 注入 (自带合法源校验, 非法值回退默认), + TickFlow 当前档位由 tickflow policy 注入 (候选按档位过滤), + 组装逻辑在 data_providers.capabilities, 本层保持薄。 + """ + from app.data_providers.capabilities import build_capability_matrix + from app.services import preferences + from app.tickflow import policy + + return build_capability_matrix( + { + "realtime_data_provider": preferences.get_realtime_data_provider(), + "daily_data_provider": preferences.get_daily_data_provider(), + "minute_data_provider": preferences.get_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), + "adj_factor_provider": preferences.get_adj_factor_provider(), + "financial_data_provider": preferences.get_financial_provider(), + }, + tickflow_tier=policy.base_tier_name(), + ) + + @router.post("/plugin-key") def save_plugin_key(req: PluginKeyIn) -> dict: """保存插件 API Key(先探后存, 对齐 /tickflow-key 语义)。 @@ -689,9 +716,8 @@ def delete_data_source(name: str, request: Request) -> dict: updates["realtime_data_provider"] = "tickflow" if preferences.get_financial_provider() == name: updates["financial_data_provider"] = "tickflow" - adj = preferences.get_adj_factor_provider() - if adj == name: - updates["adj_factor_provider"] = "same_as_daily" + if preferences.get_adj_factor_provider() == name: + updates["adj_factor_provider"] = "tickflow" if updates: preferences.save(updates) # 删除源可能触发偏好回退 tickflow, 同步刷新能力快照 @@ -737,6 +763,7 @@ def update_data_providers(req: DataProvidersIn, request: Request) -> dict: "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), "minute_data_provider": preferences.get_minute_data_provider(), + "depth5_data_provider": preferences.get_depth5_data_provider(), "realtime_data_provider": preferences.get_realtime_data_provider(), "financial_data_provider": preferences.get_financial_provider(), } diff --git a/backend/app/data_providers/capabilities.py b/backend/app/data_providers/capabilities.py new file mode 100644 index 0000000..548bbfa --- /dev/null +++ b/backend/app/data_providers/capabilities.py @@ -0,0 +1,190 @@ +"""能力注册表与能力路由矩阵 — 数据集维度的单一权威定义。 + +能力 (capability) = 一个标准化数据集 (CONTRIBUTING「数据源插件化要求」): +realtime / daily / minute / depth5 / adj_factor / financial。注册表集中声明每个 +能力的展示元数据、路由偏好字段与 TickFlow 档位要求, 前端设置页不再各自硬编码。 +depth5 目前仅 TickFlow 供 (插件数据集白名单未开放, 见 loader), 仍进矩阵是为了 +可用性门控诚实: 五档不可用时连板梯队封单/看板封单缺数据应有提示。 + +build_capability_matrix 把注册表、插件/自定义源的能力声明 (datasets) 和当前 +路由偏好合并为一个矩阵, 供设置页一次拉全。当前偏好由 API 层注入 +(preferences getters 自带合法源校验), 本模块不反向依赖 services 层。 + +候选契约: 每个能力的 candidates 只包含「当前确实可提供该能力」的源 — +TickFlow 按当前订阅档位过滤 (日K全档位, 其余按注册表 tf_tier 门槛), +未就绪的插件/自定义源 (依赖未装/Key 未配) 放入 pending 并携带原因, +供前端置灰提示。其他页面可以把 candidates 直接当作可用提供方名单。 + +usable 契约: 每个能力额外给出 usable = 生效源当前能否真正提供该能力 +(生效源在 candidates 中)。各页面的能力门控 (缺能力提示 → 数据源配置) +统一以 usable 为准, 而不是 TickFlow 套餐视角 — 路由到可用插件时同样可用, +路由到 TickFlow 但档位不足时同样不可用。 +""" + +from __future__ import annotations + +from app.data_providers import custom as custom_sources + +CAPABILITY_REGISTRY: list[dict] = [ + { + "id": "realtime", + "label": "实时行情", + "desc": "全市场实时快照", + "field": "realtime_data_provider", + "default": "tickflow", + "tf_tier": "starter", + }, + { + "id": "daily", + "label": "日K", + "desc": "历史K线与实时覆写", + "field": "daily_data_provider", + "default": "tickflow", + "tf_tier": "none", + }, + { + "id": "minute", + "label": "分钟K", + "desc": "分时图与分钟回测", + "field": "minute_data_provider", + "default": "tickflow", + "tf_tier": "pro", + }, + { + "id": "depth5", + "label": "五档盘口", + "desc": "连板梯队封单与盘口深度", + "field": "depth5_data_provider", + "default": "tickflow", + "tf_tier": "pro", + # 插件契约暂未开放 depth5 数据集 (loader 白名单), 当前仅 TickFlow 供 + }, + { + "id": "adj_factor", + "label": "除权因子", + "desc": "前复权计算基准", + "field": "adj_factor_provider", + "default": "tickflow", + "tf_tier": "starter", + # 独立路由 (曾经的「跟随日K」特殊值已下线: 每个能力单独配置, + # 复权口径一致性改由未来的一致性警示保障, 不做路由耦合) + }, + { + "id": "financial", + "label": "财务数据", + "desc": "财务指标与三大报表", + "field": "financial_data_provider", + "default": "tickflow", + "tf_tier": "expert", + }, +] + +_TICKFLOW_CANDIDATE = { + "name": "tickflow", + "display": "TickFlow", + "kind": "builtin", + "available": True, + "status": "ok", + "note": None, +} + +# 档位排序: none 最低 (无 Key/无效 Key, 仅免费通道历史日K), 未知档按 none 处理 (fail-closed) +_TIER_RANK = {"none": -1, "free": 0, "starter": 1, "pro": 2, "expert": 3} + + +def _tier_base(tier: str) -> str: + """归一化档位输入为基础名: "Pro +" -> "pro"; 空值归为 none。""" + text = str(tier or "").strip().lower() + if not text: + return "none" + return text.split()[0].split("+")[0] + + +def _declared_sources() -> list[dict]: + """插件 + 自定义源 → 统一能力声明视图。未注册 (hidden/加载失败) 的源不会出现。""" + rows: list[dict] = [] + for plugin in custom_sources.list_plugins(): + rows.append({ + "name": plugin["name"], + "display": plugin.get("display_name") or plugin["name"], + "datasets": set(plugin.get("datasets") or []), + "available": bool(plugin.get("available")), + "status": str(plugin.get("status") or ""), + "kind": "plugin", + }) + for source in custom_sources.list_sources(): + rows.append({ + "name": source["name"], + "display": source.get("display_name") or source["name"], + "datasets": set(source.get("datasets") or []), + # 自定义源注册即已通过加载校验, 视为可用 + "available": True, + "status": "ok", + "kind": "custom", + }) + return rows + + +def _display_of(sources: list[dict], name: str) -> str: + if name == "tickflow": + return "TickFlow" + for s in sources: + if s["name"] == name: + return s["display"] + return name + + +def build_capability_matrix(current: dict[str, str], tickflow_tier: str = "none") -> dict: + """注册表 + 源能力声明 + 当前偏好 → 能力路由矩阵。 + + current 为 {偏好字段: 当前值}, 由 API 层经 preferences getters 注入; + getters 已把非法值 (未注册源) 回退为默认, 这里直接信任。effective + 即当前值本身 (每个能力独立路由, 无跟随/派生特殊值)。 + + tickflow_tier 为 TickFlow 当前档位基础名 (none/free/starter/pro/expert), + 由 API 层从 tickflow policy 注入。当前档位不提供的能力里 TickFlow + 不进候选, 但偏好仍指向 tickflow 时以 tf_available=False 标记, + 供前端提示「档位不足」。未知档按 none 处理。 + """ + tier_base = _tier_base(tickflow_tier) + tier_rank = _TIER_RANK.get(tier_base, -1) + sources = _declared_sources() + + capabilities = [] + for cap in CAPABILITY_REGISTRY: + effective = current.get(cap["field"], cap["default"]) + tf_available = tier_rank >= _TIER_RANK[cap["tf_tier"]] + candidates: list[dict] = [] + pending: list[dict] = [] + if tf_available: + candidates.append(dict(_TICKFLOW_CANDIDATE)) + for s in sources: + if cap["id"] not in s["datasets"]: + continue + entry = { + "name": s["name"], + "display": s["display"], + "kind": s["kind"], + "available": s["available"], + "status": s["status"], + "note": None if s["available"] else (s["status"] or "不可用"), + } + (candidates if s["available"] else pending).append(entry) + usable = any(c["name"] == effective for c in candidates) + capabilities.append({ + "id": cap["id"], + "label": cap["label"], + "desc": cap["desc"], + "field": cap["field"], + "default": cap["default"], + "tf_tier": cap["tf_tier"], + "tf_available": tf_available, + "usable": usable, + "current": effective, + "current_display": _display_of(sources, effective), + "effective": effective, + "effective_display": _display_of(sources, effective), + "candidates": candidates, + "pending": pending, + }) + return {"tickflow_tier": tier_base, "capabilities": capabilities} diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index 2a5985b..6f71d83 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -11,6 +11,7 @@ from pathlib import Path import yaml +from app import secrets_store from app.config import settings from app.data_providers.custom.config import ( DEFAULT_TIMEOUT, @@ -89,6 +90,19 @@ def list_plugins() -> list[dict]: return list(_PLUGIN_STATUS.values()) +def _plugin_key_masked(name: str, api_key_env: str) -> str: + """插件当前生效 Key 的脱敏串 (secrets.json 优先, .env 兜底), 未配置返回空。 + + 与 TickFlow Key 的展示契约一致 (settings API 的 tickflow_api_key_masked): + 完整 Key 永不出后端, 只出 mask() 结果, 供设置页常驻显示。 + """ + env = str(api_key_env or "").strip() + if not env: + return "" + key = secrets_store.get_env_backed_secret(f"{name.lower()}_api_key", env) + return secrets_store.mask(key) if key else "" + + def plugin_manifest(name: str) -> dict | None: """读取指定插件的 plugin.yaml 清单。""" plugin_dir = plugins_dir() / (name or "") @@ -560,7 +574,9 @@ def _register_one_plugin(manifest: dict) -> None: "status": reason, "description": manifest.get("description", ""), "install_hint": manifest.get("install_hint", ""), + "homepage": manifest.get("homepage", ""), "api_key_env": manifest.get("api_key_env", ""), + "api_key_masked": _plugin_key_masked(name, manifest.get("api_key_env", "")), } if not available: return # 依赖没装: 不注册, 但状态已记录供 UI 显示 diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 840f860..3dc47f5 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -303,8 +303,6 @@ def run_now( written_adj = 0 affected_symbols: list[str] = [] adj_provider = _prefs.get_adj_factor_provider() - if adj_provider == "same_as_daily": - adj_provider = _prefs.get_daily_data_provider() can_sync_adj = capset.has(Cap.ADJ_FACTOR) or adj_provider != "tickflow" if can_sync_adj: from datetime import datetime, timedelta diff --git a/backend/app/plugins/fuyao/plugin.yaml b/backend/app/plugins/fuyao/plugin.yaml index aef54dd..7e11613 100644 --- a/backend/app/plugins/fuyao/plugin.yaml +++ b/backend/app/plugins/fuyao/plugin.yaml @@ -9,6 +9,6 @@ entry: app.plugins.fuyao.provider:FuyaoProvider check: app.plugins.fuyao.provider:availability datasets: [realtime] api_key_env: FUYAO_API_KEY # 声明后设置页提供 Key 输入框(先探后存, secrets.json 优先) -hidden: true # 优化完成前不在数据源页展示; 删除此行即可恢复 description: "同花顺官方 REST 数据 API。当前提供 A 股全市场实时快照(分页拉取);日K/分钟/财务未接入,自动回退 TickFlow。" install_hint: "点击卡片中的输入框配置 API Key(https://fuyao.aicubes.cn 申请),或在 .env 中配置 FUYAO_API_KEY" +homepage: "https://fuyao.aicubes.cn" diff --git a/backend/app/services/extend_history.py b/backend/app/services/extend_history.py index dc0037a..0249ad4 100644 --- a/backend/app/services/extend_history.py +++ b/backend/app/services/extend_history.py @@ -167,8 +167,6 @@ def run_extend_history( from app.services import preferences as _prefs adj_provider = _prefs.get_adj_factor_provider() - if adj_provider == "same_as_daily": - adj_provider = _prefs.get_daily_data_provider() can_sync_adj = capset.has(Cap.ADJ_FACTOR) or adj_provider != "tickflow" if can_sync_adj: emit("extend_history", 48, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…") diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index 2350141..2fc1a88 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -335,8 +335,6 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository, return 0, [] provider_name = preferences.get_adj_factor_provider() - if provider_name == "same_as_daily": - provider_name = preferences.get_daily_data_provider() if provider_name != "tickflow": from app.data_providers import custom as custom_sources if custom_sources.provider_has_dataset(provider_name, "adj_factor"): diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 906789d..7da2bf0 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -279,10 +279,9 @@ def get_daily_data_provider() -> str: def get_adj_factor_provider() -> str: - provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower() - if provider == "same_as_daily": - return provider - return provider if provider in _allowed_data_providers() else "same_as_daily" + # 「跟随日K」(same_as_daily) 特殊值已下线: 存量配置里的旧值按非法值回退 tickflow + provider = str(load().get("adj_factor_provider", "tickflow") or "tickflow").lower() + return provider if provider in _allowed_data_providers() else "tickflow" def get_minute_data_provider() -> str: @@ -290,6 +289,11 @@ def get_minute_data_provider() -> str: return provider if provider in _allowed_data_providers() else "tickflow" +def get_depth5_data_provider() -> str: + provider = str(load().get("depth5_data_provider", "tickflow") or "tickflow").lower() + return provider if provider in _allowed_data_providers() else "tickflow" + + def get_realtime_data_provider() -> str: provider = str(load().get("realtime_data_provider", "tickflow") or "tickflow").lower() return provider if provider in _allowed_data_providers() else "tickflow" diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 9844a5c..d3d1e5d 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -167,13 +167,15 @@ class QuoteService: CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH") - # 档位 → 最小轮询间隔 (秒) + # 档位 → 最小轮询间隔 (秒) — TickFlow 档位限速保护, 仅实时源为 tickflow 时适用 TIER_MIN_INTERVAL = { "expert": 1.0, "pro": 3.0, "starter": 6.0, "free": 6.0, } + # 插件/自定义源: 不受 TickFlow 档位保护约束, 通用下限 1s (默认间隔仍为 DEFAULT_INTERVAL) + CUSTOM_PROVIDER_MIN_INTERVAL = 1.0 DEFAULT_INTERVAL = 6.0 MAX_INTERVAL = 60.0 @@ -441,6 +443,11 @@ class QuoteService: @classmethod def _tier_min_interval(cls) -> float: + # 实时源路由到插件/自定义源时, TickFlow 档位限速不适用 (中立能力原则): + # 下限放宽到通用 1s, 默认/已保存间隔不变 + from app.services import preferences + if preferences.get_realtime_data_provider() != "tickflow": + return cls.CUSTOM_PROVIDER_MIN_INTERVAL tier = cls._current_tier() return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL) diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 54b475a..8111981 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -316,8 +316,6 @@ def _augment_custom_sources(capset: CapabilitySet) -> None: daily_provider = preferences.get_daily_data_provider() adj_provider = preferences.get_adj_factor_provider() - if adj_provider == "same_as_daily": - adj_provider = daily_provider active_providers = { "daily": daily_provider, "adj_factor": adj_provider, diff --git a/backend/tests/test_capability_augment.py b/backend/tests/test_capability_augment.py index 5543be6..0044649 100644 --- a/backend/tests/test_capability_augment.py +++ b/backend/tests/test_capability_augment.py @@ -12,7 +12,7 @@ from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet from app.tickflow.policy import _augment_custom_sources -def _set_providers(monkeypatch, *, daily="tickflow", adj="same_as_daily", +def _set_providers(monkeypatch, *, daily="tickflow", adj="tickflow", minute="tickflow", financial="tickflow") -> None: """mock preferences 各数据集 provider getter。""" from app.services import preferences @@ -42,9 +42,9 @@ def test_daily_custom_source_grants_daily_batch(monkeypatch): assert not capset.has(Cap.FINANCIAL) -def test_adj_same_as_daily_resolves_to_daily_provider(monkeypatch): - """adj_factor_provider=same_as_daily → 跟随 daily provider 判定。""" - _set_providers(monkeypatch, daily="mock_src", adj="same_as_daily") +def test_adj_custom_source_grants_adj_factor(monkeypatch): + """adj 显式路由到声明除权的自定义源 → 补授能力 (跟随日K已下线, 独立判定)。""" + _set_providers(monkeypatch, adj="mock_src") _set_datasets(monkeypatch, {"adj_factor"}) capset = CapabilitySet() _augment_custom_sources(capset) diff --git a/backend/tests/test_capability_matrix.py b/backend/tests/test_capability_matrix.py new file mode 100644 index 0000000..4220521 --- /dev/null +++ b/backend/tests/test_capability_matrix.py @@ -0,0 +1,233 @@ +"""能力路由矩阵契约测试。 + +覆盖: 注册表与路由偏好字段一一对应、候选按各源 datasets 声明过滤、 +候选只含当前可用源 (未就绪插件进 pending 并携带原因)、TickFlow 候选 +按当前订阅档位过滤、偏好指向 tickflow 但档位不足时的 tf_available +标记、usable 跟随生效源 (各页能力门控的统一判定)、每能力独立路由 +(「跟随日K」特殊值已下线, 存量旧值由 preferences getter 自愈回退)、 +偏好指向未知源时的回退形态。全部用假插件/自定义源, +不依赖真实网络与本地 data/ 目录。 +""" +from __future__ import annotations + +from app.data_providers import custom as custom_sources +from app.data_providers.capabilities import CAPABILITY_REGISTRY, build_capability_matrix + +DEFAULT_CURRENT = { + "daily_data_provider": "tickflow", + "adj_factor_provider": "tickflow", + "minute_data_provider": "tickflow", + "depth5_data_provider": "tickflow", + "realtime_data_provider": "tickflow", + "financial_data_provider": "tickflow", +} + + +def _fake_sources(monkeypatch, plugins: list[dict], customs: list[dict] | None = None) -> None: + monkeypatch.setattr(custom_sources, "list_plugins", lambda: plugins) + monkeypatch.setattr(custom_sources, "list_sources", lambda: customs or []) + + +def _by_id(matrix: dict) -> dict[str, dict]: + return {c["id"]: c for c in matrix["capabilities"]} + + +def test_registry_covers_all_routing_fields(): + """注册表是能力的单一权威: 六个能力、字段名与偏好键一一对应、无重复。""" + fields = [c["field"] for c in CAPABILITY_REGISTRY] + assert sorted(fields) == sorted(DEFAULT_CURRENT) + assert len(set(fields)) == len(fields) + assert {c["id"] for c in CAPABILITY_REGISTRY} == { + "realtime", "daily", "minute", "depth5", "adj_factor", "financial", + } + for cap in CAPABILITY_REGISTRY: + assert cap["default"] == "tickflow" + assert cap["tf_tier"] in ("none", "starter", "pro", "expert") + assert "follow" not in cap + + +def test_matrix_without_third_party_sources(monkeypatch): + """无插件无自定义源: 每个能力只剩 TickFlow 候选, 默认路由全部生效。""" + _fake_sources(monkeypatch, []) + matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert") + assert matrix["tickflow_tier"] == "expert" + assert len(matrix["capabilities"]) == 6 + for cap in matrix["capabilities"]: + names = [c["name"] for c in cap["candidates"]] + assert names == ["tickflow"] + assert cap["candidates"][0]["kind"] == "builtin" + assert cap["tf_available"] is True + assert cap["usable"] is True + assert cap["pending"] == [] + assert cap["current"] == cap["effective"] == "tickflow" + + +def test_candidates_only_available_unready_goes_pending(monkeypatch): + """候选只包含声明了该能力且可用的源; 未就绪插件进 pending 并携带原因。""" + _fake_sources( + monkeypatch, + [ + {"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"], + "available": True, "status": "ok"}, + {"name": "sdk", "display_name": "SDK", "datasets": ["daily", "minute"], + "available": False, "status": "依赖未安装"}, + ], + [{"name": "myhttp", "display_name": "MyHTTP", "datasets": ["financial", "realtime"]}], + ) + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="expert")) + assert [c["name"] for c in caps["realtime"]["candidates"]] == ["tickflow", "fuyao", "myhttp"] + assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"] + assert [c["name"] for c in caps["daily"]["pending"]] == ["sdk"] + assert caps["daily"]["pending"][0]["available"] is False + assert caps["daily"]["pending"][0]["note"] == "依赖未安装" + assert [c["name"] for c in caps["financial"]["candidates"]] == ["tickflow", "myhttp"] + assert caps["financial"]["candidates"][1]["kind"] == "custom" + assert [c["name"] for c in caps["adj_factor"]["candidates"]] == ["tickflow"] + + +def test_tickflow_candidates_filtered_by_tier(monkeypatch): + """TickFlow 只出现在当前档位确实提供的能力候选里 (free: 仅日K)。""" + _fake_sources(monkeypatch, []) + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="free")) + assert caps["daily"]["tf_available"] is True + assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"] + for cap_id in ("realtime", "minute", "depth5", "adj_factor", "financial"): + assert caps[cap_id]["tf_available"] is False + assert [c["name"] for c in caps[cap_id]["candidates"]] == [] + # starter 解锁实时与除权, 分钟/五档/财务仍锁 + caps = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter")) + assert caps["realtime"]["tf_available"] is True + assert caps["adj_factor"]["tf_available"] is True + assert caps["minute"]["tf_available"] is False + assert caps["depth5"]["tf_available"] is False + assert caps["financial"]["tf_available"] is False + + +def test_current_tickflow_unmet_tier_flagged(monkeypatch): + """偏好仍指向 tickflow 但档位不足: current 不动, tf_available=False 供前端警示。""" + _fake_sources(monkeypatch, []) + cap = _by_id( + build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="free"), + )["realtime"] + assert cap["current"] == cap["effective"] == "tickflow" + assert cap["tf_available"] is False + assert cap["usable"] is False + assert [c["name"] for c in cap["candidates"]] == [] + + +def test_usable_follows_effective_provider(monkeypatch): + """usable 跟随生效源而非 TickFlow 套餐: 各页能力门控的统一判定。 + + - 生效源是可用插件 → usable (即使 TickFlow 档位不足); + - 生效源是未就绪插件 → 不可用 (即使有其他可用候选); + - 除权独立路由, 档位门槛/插件可用性同样生效。 + """ + _fake_sources( + monkeypatch, + [ + {"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime", "minute"], + "available": True, "status": "ok"}, + {"name": "sdk", "display_name": "SDK", "datasets": ["daily", "adj_factor", "minute"], + "available": False, "status": "依赖未安装"}, + ], + ) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, realtime_data_provider="fuyao", minute_data_provider="sdk"), + tickflow_tier="free", + ), + ) + # 路由到可用插件: TickFlow 档位不足不影响 usable + assert caps["realtime"]["tf_available"] is False + assert caps["realtime"]["usable"] is True + # 路由到未就绪插件: 有可用候选 (fuyao) 也不算 usable + minute = caps["minute"] + assert [c["name"] for c in minute["candidates"]] == ["fuyao"] + assert minute["usable"] is False + # 除权默认路由 tickflow: 自身档位门槛 (starter+) 生效, free 档不可用 + # (独立路由, 不再随日K联动) + assert caps["adj_factor"]["effective"] == "tickflow" + assert caps["adj_factor"]["tf_available"] is False + assert caps["adj_factor"]["usable"] is False + # 除权显式路由到未就绪 sdk → 不可用 (有 tickflow 候选也不算, 日K不受影响) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, adj_factor_provider="sdk"), tickflow_tier="expert", + ), + ) + assert [c["name"] for c in caps["adj_factor"]["candidates"]] == ["tickflow"] + assert caps["adj_factor"]["effective"] == "sdk" + assert caps["adj_factor"]["usable"] is False + assert caps["daily"]["usable"] is True + + +def test_unknown_or_empty_tier_fails_closed(monkeypatch): + """未知档 (探测缺失) 与空档按 none 处理: 仅全档位能力 (日K) 保留 TickFlow。""" + _fake_sources(monkeypatch, []) + for tier in ("", "unknown", None): + matrix = build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier=tier) + caps = _by_id(matrix) + assert [c["name"] for c in caps["daily"]["candidates"]] == ["tickflow"] + assert caps["realtime"]["tf_available"] is False + assert caps["realtime"]["candidates"] == [] + assert caps["minute"]["tf_available"] is False + + +def test_adj_factor_routes_independently(monkeypatch): + """除权独立路由 (跟随日K已下线): 显式切到声明除权的插件即生效, 与日K当前源无关。 + + 历史遗留值 same_as_daily 不再被矩阵特判 — 存量配置经 preferences getter + 按非法源回退 tickflow (getter 层自愈), 矩阵只信任注入值。 + """ + _fake_sources( + monkeypatch, + [{"name": "sdk", "display_name": "SDK", "datasets": ["adj_factor"], + "available": True, "status": "ok"}], + ) + # 日K走 tickflow, 除权显式走 sdk → 互不影响 (TickFlow none 档下插件照常可用) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, adj_factor_provider="sdk"), tickflow_tier="none", + ), + ) + adj = caps["adj_factor"] + assert adj["current"] == adj["effective"] == "sdk" + assert adj["usable"] is True + assert caps["daily"]["effective"] == "tickflow" + assert caps["daily"]["usable"] is True + + +def test_depth5_capability_semantics(monkeypatch): + """五档: pro+ 档 TickFlow 可供 (usable); 档位不足时不可用且无候选。 + + 插件数据集白名单未开放 depth5, 假插件即使声明其他数据集也不进五档候选; + 未来契约开放后声明 depth5 的源会自然成为候选 (candidates 按 datasets 过滤)。 + """ + _fake_sources( + monkeypatch, + [{"name": "fuyao", "display_name": "fuyao", "datasets": ["realtime"], + "available": True, "status": "ok"}], + ) + # pro 档: TickFlow 进候选, 默认路由 tickflow → usable + cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="pro"))["depth5"] + assert cap["tf_available"] is True + assert [c["name"] for c in cap["candidates"]] == ["tickflow"] + assert cap["usable"] is True + # starter 档: 档位不足 → 无候选, usable False (连板梯队封单缺数据) + cap = _by_id(build_capability_matrix(dict(DEFAULT_CURRENT), tickflow_tier="starter"))["depth5"] + assert cap["tf_available"] is False + assert cap["candidates"] == [] + assert cap["usable"] is False + + +def test_unknown_current_display_falls_back_to_name(monkeypatch): + """偏好指向未注册源 (正常经 getters 校验不会发生): 展示回退为原始名, 不抛异常。""" + _fake_sources(monkeypatch, []) + caps = _by_id( + build_capability_matrix( + dict(DEFAULT_CURRENT, realtime_data_provider="ghost"), tickflow_tier="expert", + ), + ) + assert caps["realtime"]["current"] == "ghost" + assert caps["realtime"]["current_display"] == "ghost" + assert caps["realtime"]["effective_display"] == "ghost" diff --git a/backend/tests/test_fuyao_provider.py b/backend/tests/test_fuyao_provider.py index 6bfdfed..7e158d0 100644 --- a/backend/tests/test_fuyao_provider.py +++ b/backend/tests/test_fuyao_provider.py @@ -369,13 +369,52 @@ def test_manifest_declares_realtime_dataset(): def test_hidden_plugin_not_registered(): - """hidden: true 的插件不注册、不在数据源页展示 (优化完成前隐藏 fuyao)。""" + """fuyao 已取消隐藏 (plugin.yaml 不再声明 hidden); hidden 机制本身仍生效。 + + 用合成清单验证: hidden: true 的插件不注册、不在数据源页展示。 + """ from app.data_providers.custom import loader manifest = loader.plugin_manifest("fuyao") - assert manifest.get("hidden") is True + assert manifest is not None and not manifest.get("hidden"), ( + "fuyao 应保持可见; 如需重新隐藏请在 plugin.yaml 声明 hidden 并更新本测试" + ) loader._register_one_plugin(manifest) - assert "fuyao" not in loader._PLUGIN_STATUS - assert "fuyao" not in loader._PROVIDERS + assert "fuyao" in loader._PLUGIN_STATUS + + hidden_manifest = dict(manifest, name="hidden_probe", hidden=True) + loader._register_one_plugin(hidden_manifest) + assert "hidden_probe" not in loader._PLUGIN_STATUS + assert "hidden_probe" not in loader._PROVIDERS + + +# ---- 插件 Key 脱敏展示 (与 TickFlow Key 契约一致) ---- + +def test_plugin_key_masked_from_secrets_then_env(monkeypatch): + """api_key_masked 随插件状态返回: secrets.json 优先, .env 兜底, 未配置为空。 + + 完整 Key 不出后端, 只出 mask() 结果 — 与 settings API 的 + tickflow_api_key_masked 同一展示契约。 + """ + from app import secrets_store + from app.data_providers.custom import loader + + # 未声明 api_key_env / 未配置 Key → 空 + assert loader._plugin_key_masked("x", "") == "" + monkeypatch.setattr(secrets_store, "load", lambda: {}) + monkeypatch.delenv(fp.API_KEY_ENV, raising=False) + assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "" + + # .env 兜底 + monkeypatch.setenv(fp.API_KEY_ENV, "env-secret-key-123456") + assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "env-••••••3456" + + # secrets.json 优先于 .env + monkeypatch.setattr(secrets_store, "load", lambda: {"fuyao_api_key": "stored-secret-key-999"}) + assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "stor••••••-999" + + # 注册进插件状态: 数据源列表接口据此常驻展示 (而非仅保存后瞬时显示) + loader._register_one_plugin(loader.plugin_manifest("fuyao")) + assert loader._PLUGIN_STATUS["fuyao"]["api_key_masked"] == "stor••••••-999" # ---- 设置页试拉 ---- diff --git a/backend/tests/test_quote_interval_min.py b/backend/tests/test_quote_interval_min.py new file mode 100644 index 0000000..99efac3 --- /dev/null +++ b/backend/tests/test_quote_interval_min.py @@ -0,0 +1,48 @@ +"""实时行情轮询间隔下限契约测试。 + +下限语义 (中立能力原则): +- 实时源路由到插件/自定义源时, TickFlow 档位限速保护不适用 → 通用下限 1s; +- 实时源为 tickflow 时, 仍按当前订阅档位查表 (none/free=6s, starter=6s, pro=3s, expert=1s); +- 默认间隔 6s 不因路由变化而改变 (只放宽下限, 不动存量偏好)。 +""" +from __future__ import annotations + +from app.services.quote_service import QuoteService + + +def _route(monkeypatch, provider: str, tier: str) -> None: + from app.services import preferences + monkeypatch.setattr(preferences, "get_realtime_data_provider", lambda: provider) + monkeypatch.setattr(QuoteService, "_current_tier", classmethod(lambda cls: tier)) + + +def _bare() -> QuoteService: + """绕过 __init__ (单例/线程副作用), 只用无状态方法。""" + return QuoteService.__new__(QuoteService) + + +def test_custom_provider_min_interval_1s(monkeypatch): + """插件/自定义源: 不受 TickFlow 档位保护, 下限放宽到 1s。""" + _route(monkeypatch, "fuyao", "none") + assert _bare().get_min_interval() == 1.0 + + +def test_custom_provider_clamp_allows_1s(monkeypatch): + """fuyao 路由下设置 1s 不再被抬到 6s; 超过上限仍被压回。""" + _route(monkeypatch, "fuyao", "none") + qs = _bare() + assert qs._clamp_interval(1.0) == 1.0 + assert qs._clamp_interval(0.5) == 1.0 + assert qs._clamp_interval(120.0) == QuoteService.MAX_INTERVAL + + +def test_tickflow_tier_floor_unchanged(monkeypatch): + """TickFlow 路由: 档位查表行为不变 (none/free/starter=6s, pro=3s, expert=1s)。""" + for tier, expect in (("none", 6.0), ("free", 6.0), ("starter", 6.0), ("pro", 3.0), ("expert", 1.0)): + _route(monkeypatch, "tickflow", tier) + assert _bare().get_min_interval() == expect, tier + + +def test_default_interval_unchanged(): + """默认间隔仍是 6s — 放宽的只是下限, 不是默认值。""" + assert QuoteService.DEFAULT_INTERVAL == 6.0 From 6fb46b100a9ff83d995cfe3912af8a0d02767989 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:22 +0800 Subject: [PATCH 22/51] =?UTF-8?q?feat(ui):=20=E8=83=BD=E5=8A=9B=E5=BE=BD?= =?UTF-8?q?=E7=AB=A0=E4=B8=8E=E6=95=B0=E6=8D=AE=E6=BA=90=E9=A1=B5=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E2=80=94=E2=80=94=E8=B7=AF=E7=94=B1=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=E3=80=81=E4=B8=AD=E7=AB=8B=E6=96=87=E6=A1=88=E3=80=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=8D=A1=E9=87=8D=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏数据源徽章: 六能力方格(按注册序) + 悬浮路由卡(逐格同色对应, fixed 定位逃逸侧栏裁剪, 渲染后按实际高度钳制视口防遮挡) - 各页能力门控切换为矩阵 usable 视角: Data/Financials/Monitor 缺能力时 提示并引导数据源配置; 门控判定不再绑定 TickFlow 套餐 - 设置页数据源区: 能力路由卡(点标签切换, 乐观更新)、TickFlow 详情改为 左 API Key 右能力档位表、检测档位收进头部徽章集群(+/重检测)、 可用功能收进悬停图标、订阅档位面板移除 - 插件详情与 TickFlow 同构: 能力适配表(服务中/已适配/未就绪/—) + Key 区申请话术嵌官网链接(manifest homepage) - 能力层文案中立化: 通用界面不出现档位/订阅词汇, 能力不可用警示按 usable 判定(TickFlow 档位不足与源未就绪同待遇) - API Key 输入框与保存按钮改为一行左右布局 --- frontend/src/components/Layout.tsx | 224 +-- .../components/data/ExtendHistoryPanel.tsx | 7 +- .../src/components/data/MinuteSyncConfig.tsx | 5 +- .../src/components/data/RepairDailyPanel.tsx | 7 +- frontend/src/components/data/StatCard.tsx | 12 +- frontend/src/lib/api.ts | 50 +- frontend/src/lib/capability-labels.tsx | 25 + frontend/src/lib/queryKeys.ts | 1 + frontend/src/lib/useSharedQueries.ts | 13 + frontend/src/pages/Data.tsx | 88 +- frontend/src/pages/Financials.tsx | 18 +- frontend/src/pages/Monitor.tsx | 22 +- frontend/src/pages/settings/DataSources.tsx | 1321 ++++++++++------- frontend/src/pages/settings/Keys.tsx | 451 ++---- 14 files changed, 1239 insertions(+), 1005 deletions(-) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bab4ac8..f9adbdb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, Suspense } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, Suspense } from 'react' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' @@ -10,7 +10,7 @@ import { AiReportBubble } from '@/components/financials/AiReportBubble' import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost' import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble' import { - useCapabilities, + useCapabilityMatrix, useSettings, usePreferences, useQuoteStatus, @@ -53,7 +53,7 @@ import { PanelLeftOpen, } from 'lucide-react' import { Logo } from './Logo' -import { api, type IndexQuote } from '@/lib/api' +import { api, type CapabilityMatrix, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats' @@ -171,80 +171,140 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i ) } -// ===== 档位卡片 ===== -function TierBadge({ label, hasKey, providerName, isTickflow }: { label: string; hasKey?: boolean; providerName: string; isTickflow: boolean }) { - const base = label.split(' ')[0].split('+')[0].toLowerCase() - const isNone = base === 'none' +// ===== 数据源能力健康卡 ===== +// 能力路由架构下的侧栏状态: 不再展示「主数据源 + TickFlow 档位」(单源时代遗留 — +// 五个能力各自路由, 拿日K的源代表全局是随意的), 改为回答「各能力当前是否都有源在供」。 +// 档位/订阅信息归设置页 TickFlow 介绍卡 (档位词仅出现在 TickFlow 专属界面的设计规则)。 +// 单能力方格: 可用=绿 / 日K缺失=红 / 其他缺失=琥珀 (与悬浮卡中同色, 一眼对应) +function capSquareCls(c: { id: string; usable: boolean }) { + return c.usable ? 'bg-accent' : c.id === 'daily' ? 'bg-danger' : 'bg-warning/80' +} - const tierConfig: Record = { - none: { - desc: '未配置 Key · 仅历史日K', - dotStyle: { background: '#52525b' }, - tagBg: { background: 'rgba(113,113,122,0.15)' }, - labelTextStyle: { color: '#71717a' }, - }, - free: { - desc: '基础日K · 自选实时', - dotStyle: { background: '#71717a' }, - tagBg: { background: 'rgba(113,113,122,0.3)' }, - labelTextStyle: { color: '#a1a1aa' }, - }, - starter: { - desc: '批量同步 · 行情池', - dotStyle: { background: '#3b82f6' }, - tagBg: { background: 'rgba(59,130,246,0.2)' }, - labelTextStyle: { color: '#60a5fa' }, - }, - pro: { - desc: '分钟K · 实时行情 · 盘口', - dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, - tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, - labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, - }, - expert: { - desc: 'WebSocket · 财务数据', - dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, - tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, - labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, - }, +function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefined }) { + const caps = matrix?.capabilities ?? [] + const loading = caps.length === 0 + const usableCount = caps.filter(c => c.usable).length + const down = caps.filter(c => !c.usable) + // 日K是核心能力 (其他一切派生于它): 挂了用危险色; 一般缺项琥珀; 全可用绿 + const level = loading + ? 'loading' + : down.length === 0 ? 'ok' : down.some(c => c.id === 'daily') ? 'danger' : 'warn' + const countCls = level === 'ok' ? 'text-accent/80' + : level === 'danger' ? 'text-danger' + : level === 'warn' ? 'text-warning' + : 'text-muted' + + // 悬浮卡: 侧栏 aside 是 overflow-hidden, 用 fixed 定位逃逸裁剪 (坐标取自徽标实时位置)。 + // 徽标靠近屏幕顶部时居中定位会把卡片上半截推出视口 → 渲染后按实际高度钳制进视口。 + const linkRef = useRef(null) + const popRef = useRef(null) + const closeTimer = useRef(undefined) + const [popPos, setPopPos] = useState<{ left: number; top: number } | null>(null) + const openPop = () => { + window.clearTimeout(closeTimer.current) + const rect = linkRef.current?.getBoundingClientRect() + if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 }) } - - const t = tierConfig[base] || tierConfig.none - const displayLabel = isNone ? 'None' : (label || 'None') - const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc + const closePop = () => { + closeTimer.current = window.setTimeout(() => setPopPos(null), 80) + } + useEffect(() => () => window.clearTimeout(closeTimer.current), []) + useLayoutEffect(() => { + if (!popPos || !popRef.current) return + const h = popRef.current.offsetHeight + const margin = 8 + const minCenter = margin + h / 2 + const maxCenter = window.innerHeight - margin - h / 2 + const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top)) + if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped }) + }, [popPos]) return ( - - - - - {providerName || '数据源'} - - - {isTickflow && ( - - {displayLabel} + <> + { if (e.key === 'Escape') setPopPos(null) }} + className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70" + > + + + {/* 能力方格 (按注册顺序: 实时/日K/分钟/除权/财务), 与悬浮卡逐格同色对应 */} + + {loading + ? Array.from({ length: 5 }, (_, i) => ( + + )) + : caps.map(c => ( + + ))} + {!loading && ( + + {usableCount}/{caps.length} + + )} + + {popPos && ( +
window.clearTimeout(closeTimer.current)} + onMouseLeave={closePop} + > + +
+ + + 数据源能力 + + + {loading ? '获取中…' : `${usableCount}/${caps.length} 可用`} + +
+
+ {loading ? ( +
正在获取能力路由状态…
+ ) : caps.map(c => ( +
+ + {c.label} + + {c.usable ? ( + <> + {c.effective_display} + + + ) : ( + 未接入 + )} + +
+ ))} +
+ {/* 分时有分钟K功能替身 (intraday_monitor_support 三路可达), 不单独占能力格, 在此备注 */} +
+ 分时信号监控可由分钟 K 数据驱动,不单独设能力格 +
+
+ 点击前往数据源配置 + +
+
+
)} -
+ ) } @@ -275,8 +335,8 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st export function Layout() { // ===== 共享 hooks (替代内联 useQuery) ===== - const { data: caps } = useCapabilities() const { data: settingsState } = useSettings() + const { data: matrix } = useCapabilityMatrix() const { data: versionData } = useVersion() const { data: prefs } = usePreferences() // 数据源列表 (用于实时行情状态显示当前数据源名称) @@ -446,13 +506,6 @@ export function Layout() { ? '关闭实时行情' : '开启实时行情' - // 当前主数据源 (用于侧边栏数据源状态卡) - const activeProvider = prefs?.daily_data_provider || 'tickflow' - const activeProviderName = activeProvider === 'tickflow' - ? 'TickFlow' - : (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider) - const isCustomActive = activeProvider !== 'tickflow' - // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询) const alertsTotalQuery = useQuery({ queryKey: ['alerts-total'], @@ -572,15 +625,10 @@ export function Layout() {
- {/* 状态卡 — 收起时隐藏 */} - {!navCollapsed && ( -
- + {/* 状态卡 — 收起时隐藏 */} + {!navCollapsed && ( +
+