fix(indicators): 量比改为同花顺/东财标准算法 + 行情时间戳落盘 (#91)

原 vol_ratio_5d 有两个叠加缺陷导致盘中严重偏低:
1. 分母含自身(rolling_mean(5) 算了当天) → 量比偏低约1.4倍
2. 盘中无时间折算(部分量直接比全天量级) → 开盘时段极低

改为标准量比公式(同花顺/东方财富/通达信一致, 已查证官方定义):
  量比 = 今日累计成交量 / (前5日均量 × 已交易分钟数/240)
- 分母不含当天(volume.shift(1).rolling_mean(5) / tail(5)前5日)
- 盘中按已交易分钟数折算(×240/elapsed), 盘后系数=1.0不受影响

已交易分钟数用行情 quote_ts 推算(比服务端时间更准, 无网络延迟):
- market_time.py: trading_minutes_elapsed_from_ts(毫秒时间戳)
- normalizer.py: DAILY_COLS 加 quote_ts, 保留 SDK timestamp
- quote_service.py: _build_daily 保留 quote_ts, 传入 elapsed_minutes
- pipeline.py: 两路径(盘后全量/盘中增量)改为标准算法 + ENRICHED_STORAGE_COLS 加 quote_ts
- repository.py: live_agg 新增 _vol_ma5_prev_sum(tail(5)前5日和)

quote_ts 落盘后还可用于: 盘后收盘数据校验(非15:00重拉)、跨天完整性检查。

不改动: vol_ma5/vol_ma10保留原语义(含当天均量), 策略/选股阈值不变。
129后端测试全过, EOD量比验证正确(707636/100000=7.076)。
This commit is contained in:
wshy
2026-07-09 16:41:02 +08:00
committed by GitHub
parent 000fcba0ed
commit 1840e65f06
5 changed files with 102 additions and 8 deletions
+5 -1
View File
@@ -5,7 +5,7 @@ import polars as pl
from app.indicators.pipeline import filter_halt_days
DAILY_COLS = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
DAILY_COLS = ["symbol", "date", "open", "high", "low", "close", "volume", "amount", "quote_ts"]
ADJ_FACTOR_COLS = ["symbol", "trade_date", "ex_factor"]
INSTRUMENT_COLS = ["symbol", "name", "code", "exchange", "asset_type", "source"]
@@ -41,12 +41,16 @@ def normalize_daily(data, default_symbol: str | None = None, source: str = "tick
"datetime": "date",
"vol": "volume",
"amt": "amount",
"timestamp": "quote_ts",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
if "symbol" not in df.columns and default_symbol:
df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
if "date" in df.columns and df.schema["date"] != pl.Date:
df = df.with_columns(pl.col("date").cast(pl.Date, strict=False))
# quote_ts: 毫秒级行情时间戳, 用于盘后校验/量比折算。保留为 Int64, 缺失则置 null。
if "quote_ts" in df.columns:
df = df.with_columns(pl.col("quote_ts").cast(pl.Int64, strict=False))
for col in ("open", "high", "low", "close", "volume", "amount"):
if col in df.columns:
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
+22 -4
View File
@@ -61,6 +61,7 @@ ENRICHED_STORAGE_COLS = [
"turnover_rate", # 依赖当时的 float_shares, 不可回推
"consecutive_limit_ups", # 递推状态, 需从历史 cum_sum
"consecutive_limit_downs",
"quote_ts", # 行情时间戳(ms): 盘后校验/量比折算/跨天完整性
]
@@ -400,6 +401,9 @@ def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.D
_p1.append(pl.col("volume").rolling_mean(10).over("symbol").alias("vol_ma10"))
if "_vol_ma5" in want:
_p1.append(pl.col("volume").rolling_mean(5).over("symbol").alias("_vol_ma5"))
if "vol_ratio_5d" in want:
# 前5日平均成交量(不含当天), 标准量比分母: volume.shift(1).rolling_mean(5)
_p1.append(pl.col("volume").shift(1).rolling_mean(5).over("symbol").alias("_vol_ma5_prev"))
if "high_60d" in want:
_p1.append(pl.col("close").rolling_max(60).over("symbol").alias("high_60d"))
if "low_60d" in want:
@@ -450,8 +454,10 @@ def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.D
pl.col("_tr").ewm_mean(alpha=1.0 / 14, adjust=False).over("symbol").alias("atr_14"),
)
if "vol_ratio_5d" in want:
# 标准量比(同花顺/东财): 今日成交量 / 前5日均量(不含当天)
# 盘后全量路径: 当日 volume 是完整全天量, 无需时间折算
df = df.with_columns(
(pl.col("volume") / pl.col("_vol_ma5")).alias("vol_ratio_5d"),
(pl.col("volume") / pl.col("_vol_ma5_prev")).alias("vol_ratio_5d"),
)
_p4mom: list[pl.Expr] = []
if "momentum_5d" in want:
@@ -516,7 +522,7 @@ def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.D
# 清理临时列 (只丢弃实际存在的临时列)
_temp_cols = ["_boll_std", "_tr", "_ema12", "_ema26",
"_kdj_ln", "_kdj_hn", "_vol_ma5", "_daily_pct",
"_kdj_ln", "_kdj_hn", "_vol_ma5", "_vol_ma5_prev", "_daily_pct",
"_delta", "_gain", "_loss",
"_rsi_avg_gain_6", "_rsi_avg_loss_6",
"_rsi_avg_gain_14", "_rsi_avg_loss_14",
@@ -1226,6 +1232,7 @@ def compute_enriched_today(
prev_enriched: pl.DataFrame,
today_ohlcv: pl.DataFrame,
instruments: pl.DataFrame | None = None,
elapsed_minutes: float | None = None,
) -> pl.DataFrame:
"""用昨天的递推状态 + 今天的 OHLCV 增量计算今天的 enriched 数据。
@@ -1236,6 +1243,8 @@ def compute_enriched_today(
prev_enriched: repo.get_enriched_latest() — 昨天的完整 enriched (用于信号交叉判断)
today_ohlcv: 今天的 OHLCV (symbol, date, open, high, low, close, volume, amount)
instruments: 维表 (涨跌停/换手率需要)
elapsed_minutes: 当日已交易分钟数(用于标准量比的时间折算)。
None 或 0 表示不折算(盘后或时间不可用, 此时 volume 已是全天量)。
返回:
今天的 enriched DataFrame (~5500 行, 64 列)
@@ -1371,12 +1380,21 @@ def compute_enriched_today(
])
# ---- 量比 ----
# vol_ma5/vol_ma10 保留原语义(含当天的均量), 其他地方在用
vol_ma5 = (pl.col("_vol_ma5_partial_sum") + pl.col("volume")) / 5
vol_ma10 = (pl.col("_vol_ma10_partial_sum") + pl.col("volume")) / 10
# 标准量比(同花顺/东财): 今日累计成交量 / (前5日均量 × 已交易分钟数/240)
# _vol_ma5_prev_sum 是前5个交易日成交量之和(tail(5)), 不含当天
# 盘中 volume 是部分量, 按 elapsed_minutes 折算到全天量级
vol_ma5_prev = pl.col("_vol_ma5_prev_sum") / 5 # 前5日均量(不含当天)
if elapsed_minutes and elapsed_minutes > 0:
time_factor = 240.0 / elapsed_minutes # 盘中折算: 部分量 → 全天量级
else:
time_factor = 1.0 # 盘后/无效时间: 不折算(此时 volume 已是全天量)
df = df.with_columns([
vol_ma5.alias("vol_ma5"),
vol_ma10.alias("vol_ma10"),
(pl.col("volume") / vol_ma5).alias("vol_ratio_5d"),
((pl.col("volume") * time_factor) / vol_ma5_prev).alias("vol_ratio_5d"),
])
# ---- 极值 60 日 ----
@@ -1471,7 +1489,7 @@ def compute_enriched_today(
"_high_59d", "_low_59d",
"_close_5d_ago", "_close_10d_ago", "_close_20d_ago",
"_close_30d_ago", "_close_60d_ago",
"_vol_ma5_partial_sum", "_vol_ma10_partial_sum",
"_vol_ma5_partial_sum", "_vol_ma10_partial_sum", "_vol_ma5_prev_sum",
"_kdj_8d_low", "_kdj_8d_high",
"_window_len",
"_rsi_avg_gain_6", "_rsi_avg_loss_6",
+59 -1
View File
@@ -6,10 +6,17 @@
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from datetime import date, datetime, time as dt_time, timedelta, timezone
CN_TZ = timezone(timedelta(hours=8))
# A 股交易时段 (北京时间): 上午 9:30-11:30 (120 分钟) + 下午 13:00-15:00 (120 分钟) = 240 分钟
_TRADING_TOTAL_MINUTES = 240
_MORNING_START = dt_time(9, 30)
_MORNING_END = dt_time(11, 30)
_AFTERNOON_START = dt_time(13, 0)
_AFTERNOON_END = dt_time(15, 0)
def cn_now() -> datetime:
"""当前北京时间 (带时区)。"""
@@ -19,3 +26,54 @@ def cn_now() -> datetime:
def cn_today() -> date:
"""当前北京日期。"""
return datetime.now(CN_TZ).date()
def trading_minutes_elapsed_from_dt(dt: datetime) -> float:
"""根据北京时间 datetime 计算当日已交易分钟数。
交易时段: 9:30-11:30 (0~120) + 13:00-15:00 (120~240)。
- 开盘前 = 0; 午休(11:30-13:00) = 120(保持上午累计); 收盘后 = 240。
- 非交易日(周末) = 240 (视作全天, 避免量比被折算成 0)。
"""
t = dt.time()
if t < _MORNING_START:
return 0.0
if t < _MORNING_END:
return (dt.hour * 60 + dt.minute - 9 * 60 - 30) + dt.second / 60.0
if t < _AFTERNOON_START:
return 120.0 # 午休, 保持上午累计
if t < _AFTERNOON_END:
return 120.0 + (dt.hour * 60 + dt.minute - 13 * 60) + dt.second / 60.0
return float(_TRADING_TOTAL_MINUTES)
def trading_minutes_elapsed() -> float:
"""当前已交易分钟数 (基于服务端北京时间)。
量比折算的兜底: 当行情 timestamp 缺失时用服务端时间。
优先使用 trading_minutes_elapsed_from_ts (行情真实时间, 更准)。
"""
return trading_minutes_elapsed_from_dt(cn_now())
def trading_minutes_elapsed_from_ts(ts_ms: int | float | None) -> float:
"""从行情时间戳(毫秒)计算当日已交易分钟数。
优先使用此函数: 行情 timestamp 是真实成交时间, 比服务端时间更准
(服务端时间含网络/限流延迟)。
Args:
ts_ms: 毫秒级 Unix 时间戳 (TickFlow SDK quote.timestamp / kline.timestamp)
Returns:
已交易分钟数 (0~240)。timestamp 为 None/无效时返回 240 (视作全天,
避免量比被折算成 0)。
"""
if not ts_ms:
return float(_TRADING_TOTAL_MINUTES)
try:
dt = datetime.fromtimestamp(int(ts_ms) / 1000.0, tz=CN_TZ)
except (ValueError, TypeError, OSError):
return float(_TRADING_TOTAL_MINUTES)
return trading_minutes_elapsed_from_dt(dt)
+14 -2
View File
@@ -766,7 +766,7 @@ class QuoteService:
@staticmethod
def _build_daily(records: list[dict]) -> pl.DataFrame:
"""将 API records 转为日K格式 DataFrame (只有 OHLCV, 写 kline_daily 用)。"""
"""将 API records 转为日K格式 DataFrame (OHLCV + quote_ts, 写 kline_daily 用)。"""
if not records:
return pl.DataFrame()
df = pl.DataFrame(records)
@@ -778,11 +778,13 @@ class QuoteService:
"low": "low",
"volume": "volume",
"amount": "amount",
"timestamp": "quote_ts",
}
select_exprs = []
for src, dst in cols_map.items():
if src in df.columns:
select_exprs.append(pl.col(src).alias(dst))
select_exprs.append(pl.col(src).cast(pl.Int64, strict=False).alias(dst)
if dst == "quote_ts" else pl.col(src).alias(dst))
if not select_exprs:
return pl.DataFrame()
result = df.select(select_exprs).with_columns(
@@ -1193,16 +1195,26 @@ class QuoteService:
if use_incremental:
from app.indicators.pipeline import compute_enriched_today
from app.market_time import trading_minutes_elapsed_from_ts, trading_minutes_elapsed
instruments = self._repo.get_instruments()
# 将 API 直接提供的补充字段 JOIN 到 daily_df
today_ohlcv = daily_df
if quote_extra is not None and not quote_extra.is_empty():
today_ohlcv = daily_df.join(quote_extra, on="symbol", how="left")
# 量比时间折算: 优先用行情 quote_ts (真实成交时间), 缺失则兜底服务端时间
elapsed_minutes: float | None = None
if "quote_ts" in daily_df.columns and not daily_df.is_empty():
valid_ts = daily_df["quote_ts"].drop_nulls()
if not valid_ts.is_empty():
elapsed_minutes = trading_minutes_elapsed_from_ts(valid_ts.median())
if elapsed_minutes is None:
elapsed_minutes = trading_minutes_elapsed()
enriched_today = compute_enriched_today(
live_agg=live_agg,
prev_enriched=prev_enriched,
today_ohlcv=today_ohlcv,
instruments=instruments,
elapsed_minutes=elapsed_minutes,
)
if enriched_today.is_empty():
logger.warning("增量计算结果为空, 回退到全量计算")
+2
View File
@@ -748,6 +748,8 @@ class KlineRepository:
pl.col("volume").tail(4).sum().alias("_vol_ma5_partial_sum"),
pl.col("volume").tail(9).sum().alias("_vol_ma10_partial_sum"),
# 标准量比分母: 前5日成交量之和(不含当天), 用于 vol_ratio_5d
pl.col("volume").tail(5).sum().alias("_vol_ma5_prev_sum"),
pl.col("low").tail(8).min().alias("_kdj_8d_low"),
pl.col("high").tail(8).max().alias("_kdj_8d_high"),