From d29f9c0ea82db39139751e32484fecf27211abe1 Mon Sep 17 00:00:00 2001
From: shy3130 <415333856@qq.com>
Date: Sun, 30 Aug 2026 22:29:33 +0800
Subject: [PATCH] =?UTF-8?q?perf(data):=20TickFlow=20=E5=8F=96=E6=95=B0?=
=?UTF-8?q?=E5=88=87=E6=8D=A2=20as=5Fdataframe=3DFalse=20=E5=88=97?=
=?UTF-8?q?=E5=BC=8F=E7=9B=B4=E8=BD=AC;=20=E4=BF=AE=E5=A4=8D=E9=99=A4?=
=?UTF-8?q?=E6=9D=83=E5=9B=A0=E5=AD=90=E6=97=A5=E6=9C=9F=20UTC=20=E5=81=8F?=
=?UTF-8?q?=E7=A7=BB=E4=B8=80=E5=A4=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 全部 K 线取数点改走 CompactKlineData 列式直转 polars (无 pandas 中转,
全市场单轮本地转换 ~1.1s → ~0.1s), 时区口径统一 _timestamp_to_beijing_datetime
- 除权因子 trade_date 原取 UTC 日期, 北京零点事件整体早一天 → 转北京墙钟后取日期
- 盘中分钟增量间隔上限 300s → 120s (universe 仅回最新 3 根, 超限必留缺口)
---
backend/app/data_providers/normalizer.py | 9 +-
.../app/data_providers/tickflow_provider.py | 25 ++-
backend/app/services/kline_sync.py | 145 +++++++++++-------
backend/app/services/minute_refresh.py | 6 +-
backend/app/services/preferences.py | 6 +-
.../test_intraday_burst_fault_isolation.py | 7 +-
.../tests/test_intraday_monitor_signals.py | 17 +-
backend/tests/test_minute_refresh.py | 6 +-
frontend/src/pages/settings/Monitoring.tsx | 6 +-
9 files changed, 139 insertions(+), 88 deletions(-)
diff --git a/backend/app/data_providers/normalizer.py b/backend/app/data_providers/normalizer.py
index a43e716..38e15c6 100644
--- a/backend/app/data_providers/normalizer.py
+++ b/backend/app/data_providers/normalizer.py
@@ -71,8 +71,15 @@ def normalize_adj_factors(data, source: str = "tickflow") -> pl.DataFrame: # no
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
+ # 毫秒时间戳 → 北京墙钟日期 (直接 from_epoch().dt.date() 是 UTC 日期,
+ # 除权事件时间戳为北京零点 = UTC 前一日 16:00, 会整体早一天)。
df = df.with_columns(
- pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
+ pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms")
+ .dt.replace_time_zone("UTC")
+ .dt.convert_time_zone("Asia/Shanghai")
+ .dt.replace_time_zone(None)
+ .dt.date()
+ .alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
diff --git a/backend/app/data_providers/tickflow_provider.py b/backend/app/data_providers/tickflow_provider.py
index c69bd73..100d649 100644
--- a/backend/app/data_providers/tickflow_provider.py
+++ b/backend/app/data_providers/tickflow_provider.py
@@ -53,25 +53,24 @@ class TickFlowProvider:
"period": "1d",
"adjust": "none",
"count": 10000 if start_time and end_time else 250,
- "as_dataframe": True,
+ "as_dataframe": False,
"show_progress": False,
}
if start_time and end_time:
- from app.services.kline_sync import _datetime_to_ms
+ from app.services.kline_sync import _compact_klines_to_df, _datetime_to_ms, _timestamp_to_beijing_datetime
kwargs["start_time"] = _datetime_to_ms(start_time)
kwargs["end_time"] = _datetime_to_ms(end_time)
- raw = tf.klines.batch(symbols, **kwargs)
- frames: list[pl.DataFrame] = []
- if isinstance(raw, dict):
- for sym, sub in raw.items():
- normalized = normalize_daily(sub, default_symbol=sym, source=self.name)
- if not normalized.is_empty():
- frames.append(normalized)
else:
- normalized = normalize_daily(raw, source=self.name)
- if not normalized.is_empty():
- frames.append(normalized)
- return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
+ from app.services.kline_sync import _compact_klines_to_df, _timestamp_to_beijing_datetime
+ raw = tf.klines.batch(symbols, **kwargs)
+ # False 直转: 列数组→polars (无 pandas 中转), 加北京墙钟 datetime 列
+ # (normalize_daily 映射为 date); 保留 timestamp 原列 — normalize_daily
+ # 会把它改名为 quote_ts (盘后校验/量比折算用), 不能像 kline_sync 路径那样丢弃。
+ seg = _compact_klines_to_df(raw)
+ if seg.is_empty():
+ return pl.DataFrame()
+ seg = seg.with_columns(_timestamp_to_beijing_datetime(pl.col("timestamp")).alias("datetime"))
+ return normalize_daily(seg, source=self.name)
def get_adj_factors(
self,
diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py
index a0ec361..aa04fa4 100644
--- a/backend/app/services/kline_sync.py
+++ b/backend/app/services/kline_sync.py
@@ -118,25 +118,24 @@ def sync_daily_batch(symbols: list[str],
start_time=_datetime_to_ms(start_time),
end_time=_datetime_to_ms(end_time),
count=10000,
- as_dataframe=True, show_progress=False,
+ as_dataframe=False, show_progress=False,
)
else:
raw = tf.klines.batch(chunk, period="1d", count=count or 250, adjust="none",
- as_dataframe=True, show_progress=False)
+ as_dataframe=False, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("batch fetch failed for %d symbols (chunk %d/%d): %s",
len(chunk), i + 1, len(chunks), e)
failed_syms.extend(chunk)
continue
- # 兼容两种形态:dict[sym → df] 和扁平 df
- if isinstance(raw, dict):
- for sym, sub in raw.items():
- if sub is None or len(sub) == 0:
- continue
- out.append(_normalize_daily(sub, default_symbol=sym))
- elif raw is not None and len(raw) > 0:
- out.append(_normalize_daily(raw))
+ # False 直转: timestamp(UTC 毫秒) → 北京墙钟 datetime 列,
+ # _normalize_daily 将 datetime 映射为 date — 与 SDK True 路径的
+ # trade_date 字符串列同口径 (fromtimestamp(ts/1000, Asia/Shanghai))。
+ seg = _compact_klines_to_df(raw)
+ if not seg.is_empty():
+ seg = seg.with_columns(_timestamp_to_beijing_datetime(pl.col("timestamp")).alias("datetime")).drop("timestamp")
+ out.append(_normalize_daily(seg))
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
@@ -308,8 +307,11 @@ def _normalize_adj_factor(raw) -> pl.DataFrame:
df = df.rename(rename_map)
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
+ # 毫秒时间戳 → 北京墙钟日期。不能直接 from_epoch().dt.date():
+ # 那是 UTC 日期, 除权事件时间戳为北京零点 (= UTC 前一日 16:00),
+ # 会整体早一天 (与 SDK True 路径 fromtimestamp(ts/1000, Asia/Shanghai) 不一致)。
df = df.with_columns(
- pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
+ _timestamp_to_beijing_datetime(pl.col("trade_date")).dt.date().alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
@@ -377,8 +379,8 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
default_rpm_when_unset=False,
)
- # 构建 SDK 参数
- sdk_kwargs: dict = {"as_dataframe": True, "batch_size": limit.batch, "show_progress": False}
+ # 构建 SDK 参数 (False: _normalize_adj_factor 的 dict 分支原生支持)
+ sdk_kwargs: dict = {"as_dataframe": False, "batch_size": limit.batch, "show_progress": False}
if start_time:
sdk_kwargs["start_time"] = _datetime_to_ms(start_time)
if end_time:
@@ -579,6 +581,53 @@ def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame:
return df.select(keep)
+def _compact_klines_to_df(raw, default_symbol: str | None = None) -> pl.DataFrame:
+ """SDK as_dataframe=False 的 K 线原始数据 (CompactKlineData, 列式) → polars 直转。
+
+ raw 两种形态: {symbol: Compact} (batch/universe) 或顶层 Compact (单标的)。
+ 列数组本身就是 polars 的原生形状 — 无 pandas 中转, 也无 SDK True 路径对
+ 每根 K 做的 datetime.fromtimestamp + trade_date/trade_time 字符串拼接。
+ 这里只搬运原始列 (timestamp 毫秒 Int64 + OHLCV), datetime/类型收口交给
+ _normalize_minute / _normalize_daily — 与 True 路径同一契约。
+ """
+ if isinstance(raw, dict) and "timestamp" in raw:
+ items: list[tuple[str | None, dict]] = [(None, raw)]
+ elif isinstance(raw, dict):
+ items = list(raw.items())
+ else:
+ return pl.DataFrame()
+ frames: list[pl.DataFrame] = []
+ for sym, kd in items:
+ if not isinstance(kd, dict):
+ continue
+ ts = kd.get("timestamp") or []
+ if not ts:
+ continue
+ key = sym if sym is not None else default_symbol
+ cols: dict[str, object] = {"timestamp": ts}
+ if key:
+ cols["symbol"] = [key] * len(ts)
+ for field in ("open", "high", "low", "close", "volume", "amount"):
+ values = kd.get(field)
+ if values is not None:
+ cols[field] = values
+ frames.append(pl.DataFrame(cols))
+ return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
+
+
+def _timestamp_to_beijing_datetime(col: pl.Expr) -> pl.Expr:
+ """timestamp (UTC 毫秒) → 北京墙钟 naive datetime 表达式。
+
+ 与 SDK True 路径 trade_date 列同口径: datetime.fromtimestamp(ts/1000, Asia/Shanghai)。
+ """
+ return (
+ pl.from_epoch(col.cast(pl.Int64), time_unit="ms")
+ .dt.replace_time_zone("UTC")
+ .dt.convert_time_zone("Asia/Shanghai")
+ .dt.replace_time_zone(None)
+ )
+
+
def _datetime_to_ms(dt: datetime) -> int:
"""datetime → 毫秒时间戳 (供 SDK start_time / end_time 使用)。"""
return int(dt.timestamp() * 1000)
@@ -786,23 +835,19 @@ def sync_minute_batch(
end_time=_datetime_to_ms(cur_end),
count=10000,
adjust="forward",
- as_dataframe=True, show_progress=False,
+ as_dataframe=False, show_progress=False,
)
else:
raw = tf.klines.batch(chunk, period="1m", count=count or 1200,
adjust="forward",
- as_dataframe=True, show_progress=False)
+ as_dataframe=False, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("minute batch fetch failed for %d symbols: %s", len(chunk), e)
continue
- if isinstance(raw, dict):
- for sym, sub in raw.items():
- if sub is None or len(sub) == 0:
- continue
- seg_out.append(_normalize_minute(sub, default_symbol=sym))
- elif raw is not None and len(raw) > 0:
- seg_out.append(_normalize_minute(raw))
+ seg = _normalize_minute(_compact_klines_to_df(raw))
+ if not seg.is_empty():
+ seg_out.append(seg)
if on_chunk_done:
on_chunk_done(step, total_steps, seg_label)
@@ -862,17 +907,6 @@ def intraday_monitor_support(capset: CapabilitySet | None) -> dict[str, object]:
}
-def _normalize_intraday_raw(raw, default_symbol: str | None = None) -> list[pl.DataFrame]:
- frames: list[pl.DataFrame] = []
- if isinstance(raw, dict):
- for symbol, sub in raw.items():
- if sub is not None and len(sub) > 0:
- frames.append(_normalize_minute(sub, default_symbol=str(symbol)))
- elif raw is not None and len(raw) > 0:
- frames.append(_normalize_minute(raw, default_symbol=default_symbol))
- return [frame for frame in frames if not frame.is_empty()]
-
-
def fetch_intraday_monitor_batch(
symbols: list[str], capset: CapabilitySet | None, *, now: datetime | None = None,
) -> pl.DataFrame:
@@ -900,16 +934,20 @@ def fetch_intraday_monitor_batch(
if source == "intraday_batch":
limits = capset.limits(Cap.INTRADAY_BATCH) if capset else None
raw = tf.klines.intraday_batch(
- symbols, count=300, as_dataframe=True, show_progress=False,
+ symbols, count=300, as_dataframe=False, show_progress=False,
batch_size=limits.batch if limits and limits.batch else 100,
)
- frames.extend(_normalize_intraday_raw(raw))
+ df = _normalize_minute(_compact_klines_to_df(raw))
elif source == "intraday_single":
- raw = tf.klines.intraday(symbols[0], count=300, as_dataframe=True)
- frames.extend(_normalize_intraday_raw(raw, default_symbol=symbols[0]))
+ raw = tf.klines.intraday(symbols[0], count=300, as_dataframe=False)
+ df = _normalize_minute(_compact_klines_to_df(raw, default_symbol=symbols[0]))
elif source == "minute_single":
- raw = tf.klines.get(symbols[0], period="1m", count=300, as_dataframe=True)
- frames.extend(_normalize_intraday_raw(raw, default_symbol=symbols[0]))
+ raw = tf.klines.get(symbols[0], period="1m", count=300, as_dataframe=False)
+ df = _normalize_minute(_compact_klines_to_df(raw, default_symbol=symbols[0]))
+ else:
+ df = pl.DataFrame()
+ if not df.is_empty():
+ frames.append(df)
except Exception as e: # noqa: BLE001
logger.warning("intraday monitor fetch failed (%s, %d symbols): %s", source, len(symbols), e)
return pl.DataFrame()
@@ -954,10 +992,11 @@ def fetch_intraday_full_market_burst(
# 已成功的数据一起拖垮 (pool.map 迭代中抛异常会废弃全部已收 frames)
try:
raw = tf.klines.intraday_batch(
- chunk, count=count, as_dataframe=True, show_progress=False,
+ chunk, count=count, as_dataframe=False, show_progress=False,
batch_size=len(chunk),
)
- return (_normalize_intraday_raw(raw), None)
+ seg = _normalize_minute(_compact_klines_to_df(raw))
+ return ([seg] if not seg.is_empty() else [], None)
except Exception as e:
return ([], e)
@@ -1007,17 +1046,22 @@ def fetch_intraday_universe_increment(
的 intraday.batch 脉冲 (请求量 28→1, 传输量 ~40 倍降)。缺口回补
(冷启动/长时间断档/全天修复) 仍走 fetch_intraday_full_market_burst。
返回 (增量分钟K, 请求数); 拉取失败返回空 df 由调用方按失败轮处理。
+
+ as_dataframe=False: raw 的 CompactKlineData 已按列组织 (字段→数组),
+ 直接用列数组建 polars 帧 — 无 pandas 中转、无逐行转换、无 _resolve_names
+ 名称解析, 全市场单轮本地转换 ~1.1s → ~0.1s。时区/dtype/列序契约
+ 复用 _normalize_minute (timestamp→北京墙钟 datetime, 输出 canonical 8 列)。
"""
tf = get_client()
try:
- raw = tf.klines.intraday_universe(universe, count=count, as_dataframe=True)
+ raw = tf.klines.intraday_universe(universe, count=count, as_dataframe=False)
except Exception as e:
logger.warning("intraday universe fetch failed (%s): %s", universe, e)
return (pl.DataFrame(), 0)
- frames = _normalize_intraday_raw(raw)
- if not frames:
+ seg = _compact_klines_to_df(raw)
+ if seg.is_empty():
return (pl.DataFrame(), 0)
- return (pl.concat(frames, how="diagonal_relaxed"), 1)
+ return (_normalize_minute(seg), 1)
def fetch_minute_single(
@@ -1050,18 +1094,13 @@ def fetch_minute_single(
end_time=_datetime_to_ms(end_time),
count=10000,
adjust="forward",
- as_dataframe=True, show_progress=False,
+ as_dataframe=False, show_progress=False,
)
except Exception as e:
logger.warning("fetch_minute_single(%s, %s) failed: %s", symbol, trade_date, e)
return pl.DataFrame()
- if isinstance(raw, dict):
- sub = raw.get(symbol)
- return _normalize_minute(sub) if sub is not None and len(sub) > 0 else pl.DataFrame()
- if raw is not None and len(raw) > 0:
- return _normalize_minute(raw)
- return pl.DataFrame()
+ return _normalize_minute(_compact_klines_to_df(raw, default_symbol=symbol))
def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
@@ -1072,7 +1111,7 @@ def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
"""
tf = get_client()
try:
- raw = tf.klines.ex_factors([symbol], as_dataframe=True, show_progress=False)
+ raw = tf.klines.ex_factors([symbol], as_dataframe=False, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("fetch_adj_factor_single(%s) failed: %s", symbol, e)
return pl.DataFrame()
diff --git a/backend/app/services/minute_refresh.py b/backend/app/services/minute_refresh.py
index 3b21cae..8c43a23 100644
--- a/backend/app/services/minute_refresh.py
+++ b/backend/app/services/minute_refresh.py
@@ -44,9 +44,11 @@ import polars as pl
from app.market_time import cn_now, cn_today, in_continuous_session
from app.services import preferences
-# 轮询间隔允许范围 (秒): 稳态轮单请求无并发脉冲, 下限 3s; 上限防误配。
+# 轮询间隔允许范围 (秒): 稳态轮单请求无并发脉冲, 下限 3s;
+# 上限 120s — universe 端点每标的只回最新 3 根, 间隔超过 3 分钟必留缺口,
+# 每轮都会触发修复轮, 稳态设计失效, 故不允许配到 120s 以上。
REFRESH_INTERVAL_MIN = 3
-REFRESH_INTERVAL_MAX = 300
+REFRESH_INTERVAL_MAX = 120
# 等待步长 (秒): 循环小步睡眠, 便于快速停止与偏好热生效。
_LOOP_STEP_S = 2.0
# 当日覆盖滞后超过该分钟数 (≈ universe 单请求 3 根余量) → 触发全天修复轮。
diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py
index 13e06ab..be062e5 100644
--- a/backend/app/services/preferences.py
+++ b/backend/app/services/preferences.py
@@ -197,7 +197,7 @@ def get_minute_sync_segment_days() -> int:
# 全天修复轮 (intraday.batch 28 块爆发) 的 rpm 安全与间隔无关, 由轮次
# 调度 max(间隔, 单轮完成) 天然防重叠。
_MINUTE_REFRESH_INTERVAL_MIN = 3
-_MINUTE_REFRESH_INTERVAL_MAX = 300
+_MINUTE_REFRESH_INTERVAL_MAX = 120
def get_minute_refresh_enabled() -> bool:
@@ -206,7 +206,7 @@ def get_minute_refresh_enabled() -> bool:
def get_minute_refresh_interval() -> int:
- """盘中分钟增量刷新间隔(秒)。默认 6,范围 [3, 300]。"""
+ """盘中分钟增量刷新间隔(秒)。默认 6,范围 [3, 120]。"""
return max(
_MINUTE_REFRESH_INTERVAL_MIN,
min(_MINUTE_REFRESH_INTERVAL_MAX, int(load().get("minute_refresh_interval", 6))),
@@ -926,7 +926,7 @@ def set_realtime_monitor_config(cfg: dict) -> dict:
if "minute_refresh_enabled" in cfg:
updates["minute_refresh_enabled"] = bool(cfg["minute_refresh_enabled"])
if "minute_refresh_interval" in cfg:
- # clamp 到 [3, 300], 与 getter 一致, 防前端传越界值
+ # clamp 到 [3, 120], 与 getter 一致, 防前端传越界值
updates["minute_refresh_interval"] = max(
_MINUTE_REFRESH_INTERVAL_MIN,
min(_MINUTE_REFRESH_INTERVAL_MAX, int(cfg["minute_refresh_interval"])))
diff --git a/backend/tests/test_intraday_burst_fault_isolation.py b/backend/tests/test_intraday_burst_fault_isolation.py
index a4e9aac..241963a 100644
--- a/backend/tests/test_intraday_burst_fault_isolation.py
+++ b/backend/tests/test_intraday_burst_fault_isolation.py
@@ -16,17 +16,18 @@ def _capset(batch: int = 2) -> CapabilitySet:
return CapabilitySet({Cap.INTRADAY_BATCH: CapabilityLimits(rpm=60, batch=batch)})
-def _frame() -> pl.DataFrame:
+def _frame() -> dict:
# _normalize_minute 的最小输入: 毫秒 timestamp → 北京墙钟 datetime。
# 时间必须落在交易时段 (时区契约守卫会拒绝非交易小时的脏数据)
from datetime import datetime
from zoneinfo import ZoneInfo
ts = int(datetime(2026, 8, 28, 9, 31, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp() * 1000)
- return pl.DataFrame({
+ # as_dataframe=False 的最小 CompactKlineData (字段 → 列数组)
+ return {
"timestamp": [ts],
"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0],
"volume": [100.0], "amount": [100.0],
- })
+ }
class _FakeKlines:
diff --git a/backend/tests/test_intraday_monitor_signals.py b/backend/tests/test_intraday_monitor_signals.py
index efd9a11..35b7ec0 100644
--- a/backend/tests/test_intraday_monitor_signals.py
+++ b/backend/tests/test_intraday_monitor_signals.py
@@ -167,15 +167,18 @@ def test_intraday_batch_provider_is_normalized_without_network(monkeypatch):
def intraday_batch(self, symbols, count, as_dataframe, show_progress, batch_size):
assert symbols == ["600000.SH"]
assert count == 300
- assert as_dataframe is True
+ assert as_dataframe is False
assert show_progress is False
assert batch_size == 20
- return pl.DataFrame({
- "symbol": symbols,
- "datetime": [datetime(2026, 7, 17, 9, 30)],
- "open": [10.0], "high": [10.1], "low": [9.9], "close": [10.0],
- "volume": [1.0], "amount": [1000.0],
- })
+ # as_dataframe=False: dict[symbol → CompactKlineData (字段→列数组)]
+ ts = int(datetime(2026, 7, 17, 9, 30, tzinfo=CN_TZ).timestamp() * 1000)
+ return {
+ "600000.SH": {
+ "timestamp": [ts],
+ "open": [10.0], "high": [10.1], "low": [9.9], "close": [10.0],
+ "volume": [1.0], "amount": [1000.0],
+ },
+ }
class FakeClient:
klines = FakeKlines()
diff --git a/backend/tests/test_minute_refresh.py b/backend/tests/test_minute_refresh.py
index bbe38da..7aada3e 100644
--- a/backend/tests/test_minute_refresh.py
+++ b/backend/tests/test_minute_refresh.py
@@ -272,12 +272,12 @@ def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch):
preferences.save({"minute_refresh_interval": 1})
assert preferences.get_minute_refresh_interval() == 3 # 下限
preferences.save({"minute_refresh_interval": 999})
- assert preferences.get_minute_refresh_interval() == 300 # 上限
+ assert preferences.get_minute_refresh_interval() == 120 # 上限
preferences.save({"minute_refresh_interval": 15})
assert preferences.get_minute_refresh_interval() == 15
def test_realtime_monitor_config_owns_refresh_keys(tmp_path, monkeypatch):
- """盘中增量配置归属实时监控端点 (set_realtime_monitor_config), 并 clamp 到 [3,300]。"""
+ """盘中增量配置归属实时监控端点 (set_realtime_monitor_config), 并 clamp 到 [3,120]。"""
_isolated_prefs(tmp_path, monkeypatch)
saved = preferences.set_realtime_monitor_config({
"minute_refresh_enabled": True,
@@ -286,7 +286,7 @@ def test_realtime_monitor_config_owns_refresh_keys(tmp_path, monkeypatch):
assert saved["minute_refresh_enabled"] is True
assert saved["minute_refresh_interval"] == 3
saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 400})
- assert saved["minute_refresh_interval"] == 300
+ assert saved["minute_refresh_interval"] == 120
saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 6})
assert saved["minute_refresh_interval"] == 6
diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx
index 44b6b80..1e1bc43 100644
--- a/frontend/src/pages/settings/Monitoring.tsx
+++ b/frontend/src/pages/settings/Monitoring.tsx
@@ -56,7 +56,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6
// 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致)
const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval)
- // 盘中分钟增量 (Expert 专有): 间隔 (秒), 与后端 [3,300] clamp 对齐; 默认 6
+ // 盘中分钟增量 (Expert 专有): 间隔 (秒), 与后端 [3,120] clamp 对齐; 默认 6
const minuteRefreshInterval = prefs?.minute_refresh_interval ?? 6
const [minuteRefreshIntervalDraft, setMinuteRefreshIntervalDraft] = useState(minuteRefreshInterval)
// 盘中增量服务状态 (15s 轮询; 无服务时 available=false)
@@ -487,7 +487,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
- {minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '3s — 300s'}
+ {minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '3s — 120s'}
{rs?.available && rs.rounds != null && rs.rounds > 0 && (