Files
xiaobai-review/xiaobai-datahub/datahub/normalize.py
T
3498dd7a4b feat(HEL-382): 搭建 datahub 底座和盘后正式数据链路
新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 12:05:26 +08:00

202 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Canonical field normalization for Tushare-native rows.
Units (architecture §7.1):
- price: 4 decimal REAL
- pct_chg: percent, 4 decimal REAL
- volume: shares (Tushare daily/index vol is 手 → ×100)
- amount: yuan (Tushare daily/index amount is 千元 → ×1000)
- moneyflow amounts: yuan (Tushare is 万元 → ×1e4)
- daily_basic total_mv / circ_mv: yuan (Tushare is 万元 → ×1e4)
- stk_auction.amount is already yuan in Tushare; volume 手 → ×100
Existing xiaobai-review stores Tushare native units and converts at display time.
Hub converts once at ingest. Golden tests compare hub output against applying
these same factors to review-native rows.
"""
from __future__ import annotations
from typing import Any
from datahub.numbers import finite_number, round4
AMOUNT_THOUSAND_YUAN = 1000.0
AMOUNT_WAN_YUAN = 10000.0
VOLUME_LOT = 100.0
DAILY_FIELDS = ("ts_code", "trade_date", "open", "high", "low", "close", "pct_chg", "vol", "amount")
VALUATION_FIELDS = (
"ts_code", "trade_date", "turnover_rate", "volume_ratio",
"total_mv", "circ_mv", "pe_ttm", "pb", "ps_ttm", "dv_ttm",
)
MONEYFLOW_FIELDS = (
"ts_code", "trade_date",
"buy_sm_amount", "sell_sm_amount", "buy_md_amount", "sell_md_amount",
"buy_lg_amount", "sell_lg_amount", "buy_elg_amount", "sell_elg_amount",
"net_mf_amount",
)
AUCTION_FIELDS = (
"ts_code", "trade_date", "vol", "price", "amount", "pre_close",
"turnover_rate", "volume_ratio", "float_share",
)
INDEX_FIELDS = ("ts_code", "trade_date", "open", "high", "low", "close", "pct_chg", "vol", "amount")
CALENDAR_FIELDS = ("exchange", "cal_date", "is_open", "pretrade_date")
STOCK_FIELDS = ("ts_code", "symbol", "name", "area", "industry", "market", "list_status", "list_date")
def _code(value: Any) -> str:
return str(value or "").strip().upper()
def _date(value: Any) -> str:
return str(value or "").replace("-", "")[:8]
def review_daily_to_canonical(row: dict[str, Any]) -> dict[str, Any]:
"""Convert a review-stored daily row (Tushare native units) to hub canonical."""
return normalize_daily(row)
def normalize_daily(row: dict[str, Any], adj_factor: float | None = None) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"open": round4(finite_number(row.get("open"))),
"high": round4(finite_number(row.get("high"))),
"low": round4(finite_number(row.get("low"))),
"close": round4(finite_number(row.get("close"))),
"pct_chg": round4(finite_number(row.get("pct_chg"))),
"volume": round4(_scale(row.get("vol"), VOLUME_LOT)),
"amount": round4(_scale(row.get("amount"), AMOUNT_THOUSAND_YUAN)),
"adj_factor": round4(finite_number(adj_factor if adj_factor is not None else row.get("adj_factor"))),
}
def normalize_valuation(row: dict[str, Any]) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"turnover_rate": round4(finite_number(row.get("turnover_rate"))),
"volume_ratio": round4(finite_number(row.get("volume_ratio"))),
"total_mv": round4(_scale(row.get("total_mv"), AMOUNT_WAN_YUAN)),
"circ_mv": round4(_scale(row.get("circ_mv"), AMOUNT_WAN_YUAN)),
"pe_ttm": round4(finite_number(row.get("pe_ttm"))),
"pb": round4(finite_number(row.get("pb"))),
"ps_ttm": round4(finite_number(row.get("ps_ttm"))),
"dv_ttm": round4(finite_number(row.get("dv_ttm"))),
}
def normalize_moneyflow(row: dict[str, Any]) -> dict[str, Any]:
converted = {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
}
for field in MONEYFLOW_FIELDS[2:]:
converted[field] = round4(_scale(row.get(field), AMOUNT_WAN_YUAN))
return converted
def normalize_auction(row: dict[str, Any]) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"volume": round4(_scale(row.get("vol") if row.get("vol") is not None else row.get("volume"), VOLUME_LOT)),
"price": round4(finite_number(row.get("price"))),
"amount": round4(finite_number(row.get("amount"))),
"pre_close": round4(finite_number(row.get("pre_close"))),
"turnover_rate": round4(finite_number(row.get("turnover_rate"))),
"volume_ratio": round4(finite_number(row.get("volume_ratio"))),
"float_share": round4(_scale(row.get("float_share"), AMOUNT_WAN_YUAN) if row.get("float_share") is not None else None),
}
def normalize_index_daily(row: dict[str, Any]) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"open": round4(finite_number(row.get("open"))),
"high": round4(finite_number(row.get("high"))),
"low": round4(finite_number(row.get("low"))),
"close": round4(finite_number(row.get("close"))),
"pct_chg": round4(finite_number(row.get("pct_chg"))),
"volume": round4(_scale(row.get("vol"), VOLUME_LOT)),
"amount": round4(_scale(row.get("amount"), AMOUNT_THOUSAND_YUAN)),
}
def normalize_calendar(row: dict[str, Any]) -> dict[str, Any]:
is_open = row.get("is_open")
if is_open in (True, "1", 1, "Y", "y"):
open_flag = 1
elif is_open in (False, "0", 0, "N", "n", None, ""):
open_flag = 0
else:
open_flag = int(is_open)
return {
"exchange": str(row.get("exchange") or "SSE"),
"cal_date": _date(row.get("cal_date") or row.get("calDate")),
"is_open": open_flag,
"pretrade_date": _date(row.get("pretrade_date")) or None,
}
def normalize_stock(row: dict[str, Any]) -> dict[str, Any]:
ts_code = _code(row.get("ts_code"))
symbol = str(row.get("symbol") or "").strip() or (ts_code.split(".")[0] if ts_code else "")
return {
"ts_code": ts_code,
"symbol": symbol,
"name": str(row.get("name") or "").strip(),
"area": str(row.get("area") or "").strip() or None,
"industry": str(row.get("industry") or "").strip() or None,
"market": str(row.get("market") or "").strip() or None,
"list_status": str(row.get("list_status") or "L").strip() or "L",
"list_date": _date(row.get("list_date")) or None,
}
def apply_qfq(price: float | None, factor: float | None, latest_factor: float | None) -> float | None:
if price is None:
return None
current = factor if factor not in (None, 0) else 1.0
latest = latest_factor if latest_factor not in (None, 0) else current
return round4(price * current / latest)
def qfq_bar(row: dict[str, Any], latest_factor: float | None) -> dict[str, Any]:
factor = finite_number(row.get("adj_factor"), 1.0) or 1.0
out = dict(row)
for field in ("open", "high", "low", "close"):
out[field] = apply_qfq(finite_number(row.get(field)), factor, latest_factor)
return out
NORMALIZERS = {
"daily": normalize_daily,
"valuation": normalize_valuation,
"daily_basic": normalize_valuation,
"moneyflow": normalize_moneyflow,
"auction": normalize_auction,
"stk_auction": normalize_auction,
"index_daily": normalize_index_daily,
"trade_cal": normalize_calendar,
"calendar": normalize_calendar,
"stock_basic": normalize_stock,
"stocks": normalize_stock,
}
def normalize_rows(dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
fn = NORMALIZERS.get(dataset)
if fn is None:
raise ValueError(f"unknown dataset: {dataset}")
return [fn(row) for row in rows]
def _scale(value: Any, factor: float) -> float | None:
number = finite_number(value)
if number is None:
return None
return number * factor