Files
tick-stock-panel/backend/app/data_providers/normalizer.py
T
wshy 1840e65f06 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)。
2026-07-09 16:41:02 +08:00

104 lines
4.0 KiB
Python

"""Normalize provider responses into internal Polars schemas."""
from __future__ import annotations
import polars as pl
from app.indicators.pipeline import filter_halt_days
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"]
def to_polars(data) -> pl.DataFrame:
if data is None:
return pl.DataFrame()
if isinstance(data, pl.DataFrame):
return data
if isinstance(data, dict):
rows: list[dict] = []
for sym, values in data.items():
for item in values or []:
row = dict(item or {})
row.setdefault("symbol", sym)
rows.append(row)
return pl.DataFrame(rows) if rows else pl.DataFrame()
if hasattr(data, "reset_index"):
return pl.from_pandas(data.reset_index())
try:
return pl.DataFrame(data)
except Exception: # noqa: BLE001
return pl.DataFrame()
def normalize_daily(data, default_symbol: str | None = None, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001
df = to_polars(data)
if df.is_empty():
return df
rename_map = {
"ts_code": "symbol",
"trade_date": "date",
"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))
df = filter_halt_days(df)
keep = [c for c in DAILY_COLS if c in df.columns]
return df.select(keep) if keep else pl.DataFrame()
def normalize_adj_factors(data, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001
df = to_polars(data)
if df.is_empty():
return df
rename_map = {
"timestamp": "trade_date",
"date": "trade_date",
"adj_factor": "ex_factor",
}
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}:
df = df.with_columns(
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
if "ex_factor" in df.columns:
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
keep = [c for c in ADJ_FACTOR_COLS if c in df.columns]
return df.select(keep).drop_nulls() if len(keep) == len(ADJ_FACTOR_COLS) else pl.DataFrame()
def normalize_instruments(rows: list[dict], asset_type: str, source: str = "tickflow") -> pl.DataFrame:
if not rows:
return pl.DataFrame()
out: list[dict] = []
for item in rows:
symbol = item.get("symbol")
if not symbol:
continue
out.append({
"symbol": str(symbol),
"name": item.get("name") or str(symbol),
"code": item.get("code") or str(symbol).split(".")[0],
"exchange": item.get("exchange"),
"asset_type": asset_type,
"source": source,
})
if not out:
return pl.DataFrame()
return pl.DataFrame(out).select(INSTRUMENT_COLS).unique(subset=["symbol"], keep="last").sort("symbol")