Files
quant_engine/src/quant_engine/data_adapter.py
T
2026-08-26 20:55:09 +08:00

604 lines
22 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.
"""数据适配层 —— 桥接 qtdb_pro(Tushare 长表)与新量化模块(alpha158 / execution)。
背景(v1.2.0 数据对齐核查):
- 现有 47 项目统一用**长表** `(stock_code, trade_date, close_price)`(qtdb_pro.hq_* 表)
- alpha_factors.py 需要**单股 Series**(close / open / high / low / volume / vwap)
- execution.py 需要**宽表**(date × stock_code)prices / volumes
- Tushare 字段命名:`ts_code / vol(手) / amount(千元) / pct_chg`,且**无 vwap 字段**
本模块提供可组合的数据适配函数,让新模块直接吃 qtdb_pro 真实数据:
1. `long_to_wide()` — 长表 → 宽表(date × stock_code)
2. `wide_to_long()` — 宽表 → 长表
3. `rename_tushare_columns()` — 列名映射(ts_code→stock_code, vol→volume 等)
4. `add_vwap_proxy()` — vwap 代理(Tushare 无 vwap 字段)
5. `apply_adj_factor()` — 复权(hq_daily × hq_adj_factor 前复权)
6. `prepare_stock_series()` — 单股提取(alpha_factors 输入)
7. `prepare_asset_return_snapshot()` — 带稳定 lineage 的资产日收益
8. `prepare_execution_inputs()` — execution 输入(prices + volumes 宽表)
9. `load_qtdb_daily()` — 便捷加载(qtdb_pro.hq_daily + 可选复权)
全部纯 pandas/numpy,零新依赖,mypy strict 兼容。
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date
from typing import Any
import numpy as np
import pandas as pd
from quant_engine.logging import get_logger
logger = get_logger(__name__)
__all__ = [
"AssetReturnSnapshot",
"long_to_wide",
"wide_to_long",
"rename_tushare_columns",
"add_vwap_proxy",
"apply_adj_factor",
"prepare_stock_series",
"prepare_asset_return_snapshot",
"prepare_execution_inputs",
"load_qtdb_daily",
]
# Tushare hq_daily 字段 → 统一命名
TUSHARE_RENAME: dict[str, str] = {
"ts_code": "stock_code",
"trade_date": "trade_date",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"pre_close": "pre_close",
"pct_chg": "pct_chg",
"vol": "volume", # 手
"amount": "amount", # 千元
}
@dataclass(frozen=True, slots=True, init=False, eq=False)
class AssetReturnSnapshot:
"""Immutable-by-interface daily return matrix with reproducible lineage."""
data_snapshot_id: str
source: str
source_snapshot_id: str
price_field: str
adjustment: str
return_method: str
start_date: date
end_date: date
sessions: int
assets: tuple[str, ...]
_returns: pd.DataFrame
def __init__(
self,
*,
data_snapshot_id: str,
source: str,
source_snapshot_id: str,
price_field: str,
adjustment: str,
return_method: str,
start_date: date,
end_date: date,
assets: tuple[str, ...],
returns: pd.DataFrame,
) -> None:
for value, name in (
(data_snapshot_id, "data_snapshot_id"),
(source, "source"),
(source_snapshot_id, "source_snapshot_id"),
(price_field, "price_field"),
(adjustment, "adjustment"),
(return_method, "return_method"),
):
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{name} must be non-empty")
if returns.empty or not isinstance(returns.index, pd.DatetimeIndex):
raise ValueError("returns must contain a DatetimeIndex and at least one session")
if tuple(returns.columns) != assets:
raise ValueError("assets must match returns columns")
if start_date > end_date:
raise ValueError("start_date must not be after end_date")
object.__setattr__(self, "data_snapshot_id", data_snapshot_id.strip())
object.__setattr__(self, "source", source.strip())
object.__setattr__(self, "source_snapshot_id", source_snapshot_id.strip())
object.__setattr__(self, "price_field", price_field.strip())
object.__setattr__(self, "adjustment", adjustment.strip())
object.__setattr__(self, "return_method", return_method.strip())
object.__setattr__(self, "start_date", start_date)
object.__setattr__(self, "end_date", end_date)
object.__setattr__(self, "sessions", len(returns))
object.__setattr__(self, "assets", assets)
object.__setattr__(self, "_returns", returns.copy(deep=True))
@property
def returns(self) -> pd.DataFrame:
"""Return an isolated copy so callers cannot mutate the snapshot."""
return self._returns.copy(deep=True)
def _non_empty(value: str, name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{name} must be non-empty")
return value.strip()
def _asset_return_snapshot_id(
prices: pd.DataFrame,
*,
source: str,
source_snapshot_id: str,
price_field: str,
adjustment: str,
) -> str:
values = prices.to_numpy(dtype=float, copy=True)
missing = np.isnan(values)
normalized = np.where(missing, 0.0, values).astype("<f8", copy=False)
metadata = {
"adjustment": adjustment,
"assets": [str(asset) for asset in prices.columns],
"price_field": price_field,
"return_method": "simple",
"schema": "asset-returns-v1",
"sessions": [timestamp.date().isoformat() for timestamp in prices.index],
"shape": list(values.shape),
"source": source,
"source_snapshot_id": source_snapshot_id,
}
digest = hashlib.sha256(
json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8")
)
digest.update(missing.astype(np.uint8, copy=False).tobytes(order="C"))
digest.update(normalized.tobytes(order="C"))
return f"asset-returns-v1:{digest.hexdigest()}"
def long_to_wide(
df: pd.DataFrame,
value_col: str = "close",
date_col: str = "trade_date",
stock_col: str = "stock_code",
) -> pd.DataFrame:
"""长表 → 宽表(date × stock_code)。
Args:
df: 长表 DataFrame(含 date_col / stock_col / value_col 三列)
value_col: 值列名
date_col: 日期列名
stock_col: 股票代码列名
Returns:
宽表:index=trade_date(升序),columns=stock_code,值=value_col
Examples:
>>> df = pd.DataFrame({
... "stock_code": ["A", "A", "B", "B"],
... "trade_date": ["2024-01-01", "2024-01-02", "2024-01-01", "2024-01-02"],
... "close": [10.0, 11.0, 20.0, 21.0],
... })
>>> wide = long_to_wide(df)
>>> wide.shape
(2, 2)
"""
if df.empty:
return pd.DataFrame()
if date_col not in df.columns or stock_col not in df.columns or value_col not in df.columns:
raise ValueError(
f"long_to_wide: 缺少列 date_col={date_col} / stock_col={stock_col} / "
f"value_col={value_col},实际列={list(df.columns)}"
)
wide = df.pivot(index=date_col, columns=stock_col, values=value_col)
wide.index = pd.to_datetime(wide.index)
return wide.sort_index()
def wide_to_long(
wide: pd.DataFrame,
value_name: str = "close",
date_col: str = "trade_date",
stock_col: str = "stock_code",
) -> pd.DataFrame:
"""宽表 → 长表(date × stock_code → 长表)。
Args:
wide: 宽表 DataFrame(index=date,columns=stock_code)
value_name: 值列名(如 'close' / 'volume')
date_col: 日期列名(输出)
stock_col: 股票代码列名(输出)
Returns:
长表 DataFrame(date_col / stock_col / value_name 三列)
"""
if wide.empty:
return pd.DataFrame(columns=[date_col, stock_col, value_name])
long_df = wide.reset_index().melt(
id_vars=wide.index.name or "index",
var_name=stock_col,
value_name=value_name,
)
long_df = long_df.rename(columns={wide.index.name or "index": date_col})
return long_df.dropna(subset=[value_name])
def rename_tushare_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Tushare 列名 → 统一命名(只重命名存在的列)。
Args:
df: qtdb_pro 查询结果 DataFrame
Returns:
rename 后的 DataFrame
Examples:
>>> df = pd.DataFrame({"ts_code": ["000001.SZ"], "vol": [100.0]})
>>> rename_tushare_columns(df).columns.tolist()
['stock_code', 'volume']
"""
rename_map = {k: v for k, v in TUSHARE_RENAME.items() if k in df.columns}
return df.rename(columns=rename_map)
def add_vwap_proxy(
df: pd.DataFrame,
method: str = "amount_vol",
) -> pd.DataFrame:
"""vwap 代理(Tushare hq_daily 无 vwap 字段)。
Args:
df: 含 open/high/low/close(必),volume/amount(method=amount_vol 时)
method:
- 'amount_vol': vwap = amount(千元) / volume(手) 换算为元/股
vwap = amount*1000 / (volume*100) = amount * 10 / volume
(Tushare amount 单位千元,vol 单位手=100股)
- 'typical': vwap = (high + low + close) / 3
- 'close': vwap = close
Returns:
新增 'vwap' 列的 DataFrame(原列保留)
Note:
amount_vol 更接近真实 VWAP(成交额/成交量);
typical / close 为兜底(缺 amount 或 volume 时)。
"""
out = df.copy()
if method == "amount_vol" and {"amount", "volume"}.issubset(out.columns):
out["vwap"] = out["amount"] * 10.0 / out["volume"].replace(0, np.nan)
elif method == "typical" and {"high", "low", "close"}.issubset(out.columns):
out["vwap"] = (out["high"] + out["low"] + out["close"]) / 3.0
elif {"close"}.issubset(out.columns):
out["vwap"] = out["close"]
else:
raise ValueError(f"add_vwap_proxy: 列不足。method={method},实际列={list(out.columns)}")
return out
def apply_adj_factor(
daily: pd.DataFrame,
adj_factor: pd.DataFrame,
adj_col: str = "adj_factor",
date_col: str = "trade_date",
stock_col: str = "stock_code",
mode: str = "qfq",
) -> pd.DataFrame:
"""复权:前复权(qfq)/ 后复权(hfq)。
Tushare 复权因子(hq_adj_factor.adj_factor):
- 后复权价 = 原始价 × adj_factor
- 前复权价 = 原始价 × adj_factor / 最新 adj_factor(按股票)
Args:
daily: 原始行情(含 open/high/low/close/volume 等列 + date_col/stock_col)
adj_factor: 复权因子表(含 stock_col/date_col/adj_col 三列)
adj_col: 因子列名
date_col: 日期列名
stock_col: 股票代码列名
mode: 'qfq'(前复权,默认)/ 'hfq'(后复权)
Returns:
复权后的行情 DataFrame(新增 adj_factor 列;复权仅作用于 OHLC)
volume / amount 不复权(成交量本身不受价格复权影响)
Note:
- 只对 open/high/low/close/pre_close 复权
- 无 adj_factor 的日期保持原值(不 drop)
"""
if daily.empty:
return daily
if adj_factor.empty:
logger.warning("apply_adj_factor: adj_factor 为空,返回原始行情")
return daily
out = daily.copy()
adj = adj_factor[[stock_col, date_col, adj_col]].copy()
# 合并
merged = out.merge(adj, on=[stock_col, date_col], how="left")
# 每股"最新日期"因子(前复权基准:最后交易日 adj_factor)
# 先按日期排序,再取每股最后一条
merged = merged.sort_values([stock_col, date_col])
latest_map = merged.groupby(stock_col)[adj_col].last()
latest = merged[stock_col].map(latest_map)
if mode == "qfq":
ratio = merged[adj_col] / latest
elif mode == "hfq":
ratio = merged[adj_col]
else:
raise ValueError(f"apply_adj_factor: mode={mode} 不支持(qfq/hfq)")
ratio = ratio.fillna(1.0) # 无因子日期保持原值
for col in ("open", "high", "low", "close", "pre_close"):
if col in merged.columns:
merged[col] = merged[col] * ratio
return merged
def prepare_stock_series(
df: pd.DataFrame,
stock_code: str,
stock_col: str = "stock_code",
date_col: str = "trade_date",
) -> dict[str, pd.Series]:
"""单股提取 → alpha_factors 输入(close/open/high/low/volume/vwap Series)。
Args:
df: 长表行情(Tushare rename 后)
stock_code: 目标股票代码
stock_col: 股票代码列名
date_col: 日期列名
Returns:
dict[str, pd.Series]:{'close': Series, 'open': Series, ...}
index=trade_date(datetime),按日期升序
Note:
- 只返回存在的列(如无 volume 则不含 'volume' key)
- 自动补 vwap(若缺):先尝试 amount/volume,否则 typical price
"""
if df.empty:
return {}
sub = df[df[stock_col] == stock_code].sort_values(date_col)
if sub.empty:
return {}
series_map: dict[str, pd.Series] = {}
for col in ("open", "high", "low", "close", "pre_close", "volume", "amount"):
if col in sub.columns:
s = sub.set_index(date_col)[col]
s.index = pd.to_datetime(s.index)
series_map[col] = s.astype(float)
# vwap 代理
if "vwap" not in series_map:
if {"amount", "volume"}.issubset(series_map):
series_map["vwap"] = (
series_map["amount"] * 10.0 / series_map["volume"].replace(0, np.nan)
)
elif {"high", "low", "close"}.issubset(series_map):
series_map["vwap"] = (
series_map["high"] + series_map["low"] + series_map["close"]
) / 3.0
elif "close" in series_map:
series_map["vwap"] = series_map["close"]
return series_map
def prepare_asset_return_snapshot(
df: pd.DataFrame,
*,
source: str,
source_snapshot_id: str,
price_col: str = "close",
adjustment: str = "none",
stock_col: str = "stock_code",
date_col: str = "trade_date",
) -> AssetReturnSnapshot:
"""Build deterministic simple daily returns from a long market-price table.
``source_snapshot_id`` must identify the upstream ingestion snapshot. The
resulting ID additionally fingerprints canonical price values and their
missing mask, so changed contents cannot retain the same downstream identity.
Missing prices are never forward-filled.
"""
normalized_source = _non_empty(source, "source")
normalized_source_snapshot_id = _non_empty(
source_snapshot_id,
"source_snapshot_id",
)
normalized_price_col = _non_empty(price_col, "price_col")
normalized_adjustment = _non_empty(adjustment, "adjustment")
if not isinstance(df, pd.DataFrame):
raise TypeError("df must be a pandas DataFrame")
if df.empty:
raise ValueError("df must contain market prices")
required = {date_col, stock_col, normalized_price_col}
missing_columns = sorted(required.difference(df.columns))
if missing_columns:
raise ValueError(f"prepare_asset_return_snapshot: missing columns={missing_columns}")
market = df[[date_col, stock_col, normalized_price_col]].copy()
if any(not isinstance(asset, str) or not asset.strip() for asset in market[stock_col]):
raise ValueError("asset labels must be non-empty strings")
market[stock_col] = market[stock_col].str.strip()
try:
normalized_dates = pd.to_datetime(market[date_col], errors="raise")
except (TypeError, ValueError) as error:
raise ValueError("trade dates must be valid dates") from error
if normalized_dates.isna().any():
raise ValueError("trade dates must be valid dates")
market[date_col] = normalized_dates.dt.normalize()
if market.duplicated(subset=[date_col, stock_col]).any():
raise ValueError("duplicate asset/session prices are not allowed")
try:
market[normalized_price_col] = pd.to_numeric(
market[normalized_price_col],
errors="raise",
)
except (TypeError, ValueError) as error:
raise ValueError("prices must be numeric") from error
observed_prices = market[normalized_price_col].dropna().to_numpy(dtype=float)
if observed_prices.size == 0 or not np.isfinite(observed_prices).all():
raise ValueError("prices must contain positive finite observations")
if (observed_prices <= 0.0).any():
raise ValueError("prices must contain positive finite observations")
prices = market.pivot(
index=date_col,
columns=stock_col,
values=normalized_price_col,
).sort_index()
prices = prices.reindex(sorted(str(asset) for asset in prices.columns), axis="columns")
prices = prices.astype(float)
if len(prices) < 2:
raise ValueError("market prices must contain at least two sessions")
returns = prices.pct_change(fill_method=None)
assets = tuple(str(asset) for asset in prices.columns)
snapshot_id = _asset_return_snapshot_id(
prices,
source=normalized_source,
source_snapshot_id=normalized_source_snapshot_id,
price_field=normalized_price_col,
adjustment=normalized_adjustment,
)
return AssetReturnSnapshot(
data_snapshot_id=snapshot_id,
source=normalized_source,
source_snapshot_id=normalized_source_snapshot_id,
price_field=normalized_price_col,
adjustment=normalized_adjustment,
return_method="simple",
start_date=prices.index[0].date(),
end_date=prices.index[-1].date(),
assets=assets,
returns=returns,
)
def prepare_execution_inputs(
df: pd.DataFrame,
stock_col: str = "stock_code",
date_col: str = "trade_date",
*,
price_col: str = "close",
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""长表行情 → execution 输入(prices + volumes 宽表)。
Args:
df: 长表行情(含 close / volume 列,Tushare rename 后)
stock_col: 股票代码列名
date_col: 日期列名
price_col: 执行价字段,默认 close;防前视研究可显式选择下一交易日 open
Returns:
(prices_wide, volumes_wide):
- prices_wide: date × stock_code,值=price_col
- volumes_wide: date × stock_code,值=volume(若无 volume 列则全 1.0)
Examples:
>>> df = pd.DataFrame({
... "stock_code": ["A", "A", "B", "B"],
... "trade_date": ["2024-01-01", "2024-01-02"] * 2,
... "close": [10.0, 11.0, 20.0, 21.0],
... "volume": [100.0, 200.0, 300.0, 400.0],
... })
>>> prices, volumes = prepare_execution_inputs(df)
>>> prices.shape
(2, 2)
"""
if df.empty:
return pd.DataFrame(), pd.DataFrame()
if price_col not in df.columns:
raise ValueError(f"prepare_execution_inputs: 缺 {price_col} 列,实际列={list(df.columns)}")
prices = long_to_wide(df, value_col=price_col, date_col=date_col, stock_col=stock_col)
if "volume" in df.columns:
volumes = long_to_wide(df, value_col="volume", date_col=date_col, stock_col=stock_col)
else:
logger.warning("prepare_execution_inputs: 无 volume 列,volumes 全 1.0")
volumes = pd.DataFrame(1.0, index=prices.index, columns=prices.columns)
return prices, volumes
def load_qtdb_daily(
stock_codes: Sequence[str],
begin_date: str,
end_date: str | None = None,
with_adj: bool = False,
vwap_method: str = "amount_vol",
) -> pd.DataFrame:
"""便捷加载:qtdb_pro.hq_daily 长表行情(Tushare rename + vwap + 可选复权)。
Args:
stock_codes: 股票代码列表
begin_date: 起始日期(YYYY-MM-DD)
end_date: 结束日期(可选)
with_adj: 是否 join hq_adj_factor 前复权
vwap_method: vwap 代理方法(amount_vol / typical / close)
Returns:
长表 DataFrame:
stock_code / trade_date / open / high / low / close / pre_close /
pct_chg / volume / amount / vwap(+ adj_factor 若 with_adj)
Note:
- 需要真实 ClickHouse 连接(.env 配置 CLICKHOUSE_QTDB_*)
- 无连接时返回空 DataFrame + 告警
"""
try:
from src.data.clickhouse import qtdb_query
except ImportError:
logger.warning("load_qtdb_daily: src.data.clickhouse 不可用,返回空表")
return pd.DataFrame()
where = "ts_code IN %(codes)s AND trade_date >= %(begin)s"
params: dict[str, Any] = {"codes": tuple(stock_codes), "begin": begin_date}
if end_date:
where += " AND trade_date <= %(end)s"
params["end"] = end_date
sql = (
"SELECT ts_code, trade_date, open, high, low, close, pre_close, "
"pct_chg, vol, amount "
f"FROM qtdb_pro.hq_daily WHERE {where} ORDER BY stock_code, trade_date"
)
try:
result = qtdb_query(sql, params)
if hasattr(result, "to_df"):
df = result.to_df()
else:
df = pd.DataFrame(result)
except Exception as e:
logger.warning("load_qtdb_daily: 查询失败(返回空表): %s", e)
return pd.DataFrame()
if df.empty:
return df
df = rename_tushare_columns(df)
df = add_vwap_proxy(df, method=vwap_method)
if with_adj:
try:
adj_sql = (
"SELECT ts_code, trade_date, adj_factor "
f"FROM qtdb_pro.hq_adj_factor WHERE {where} ORDER BY stock_code, trade_date"
)
adj_result = qtdb_query(adj_sql, params)
adj_df = (
adj_result.to_df() if hasattr(adj_result, "to_df") else pd.DataFrame(adj_result)
)
if not adj_df.empty:
adj_df = adj_df.rename(columns={"ts_code": "stock_code"})
df = apply_adj_factor(df, adj_df, mode="qfq")
except Exception as e:
logger.warning("load_qtdb_daily: 复权失败(返回未复权): %s", e)
return df