feat: 量化引擎加固 — 新增测试 + 数据/因子/回测层优化
- 新增 finance/tests/ 6 个测试套件(agents/backtest/dao_upsert/factors/features/fundamental_lookahead) - 数据层: data_manager / dao 优化,新增 upsert 逻辑 - 因子层: 基本面因子抽象定位 _mapping、ROE/PE/PB 重构 - 回测层: vectorbt/engine 大改动(251 行),report 增强 - ML 层: features/backtest_integration 特征工程与回测优化 - CLI: agent_cli 重构 - config/settings 扩充配置项
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
财务数据 → 日线映射(负责消除披露时点前视偏差)。
|
||||
|
||||
A 股季报的披露日远晚于报告期末:
|
||||
- 一季报 / 年报 最迟约 4/30
|
||||
- 中报 最迟约 8/31
|
||||
- 三季报 最迟约 10/31
|
||||
|
||||
若直接把报告期 end_date 当日就"看到"本期财务结果,会引入前视偏差。
|
||||
本模块统一在 end_date 上叠加一个保守的披露滞后:
|
||||
1) 财务帧中若带 ann_date(实际披露日),则优先用 ann_date 作为可用日;
|
||||
2) 否则按报告期月份推断法定披露时点,作为保守可用日。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _disclosure_available_date(end_date_ts: pd.Timestamp) -> pd.Timestamp:
|
||||
"""根据报告期末推断法定披露可用日(无 ann_date 时的保守近似)。
|
||||
|
||||
一季度(0331)→4/30;中报(0630)→8/31;三季报(0930)→10/31;年报(1231)→次年4/30。
|
||||
"""
|
||||
if end_date_ts.month == 3 and end_date_ts.day == 31:
|
||||
return pd.Timestamp(year=end_date_ts.year, month=4, day=30)
|
||||
if end_date_ts.month == 6 and end_date_ts.day == 30:
|
||||
return pd.Timestamp(year=end_date_ts.year, month=8, day=31)
|
||||
if end_date_ts.month == 9 and end_date_ts.day == 30:
|
||||
return pd.Timestamp(year=end_date_ts.year, month=10, day=31)
|
||||
# 年报 12/31 → 次年 4/30
|
||||
return pd.Timestamp(year=end_date_ts.year + 1, month=4, day=30)
|
||||
|
||||
|
||||
def _eff_available_dates(fina_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""计算每期财务的"可用日"(取最小滞后:有 ann_date 用它,否则法定截止)。"""
|
||||
fina = fina_df.copy()
|
||||
# 数值型 ann_date(YYYYMMDD)→ datetime;缺失用报告期末推断
|
||||
if "ann_date" in fina.columns:
|
||||
ann = pd.to_datetime(fina["ann_date"].astype(str), format="%Y%m%d", errors="coerce")
|
||||
else:
|
||||
ann = pd.Series(pd.NaT, index=fina.index)
|
||||
|
||||
end = pd.to_datetime(
|
||||
fina["end_date"].astype(str), format="%Y%m%d", errors="coerce")
|
||||
|
||||
avail = ann.fillna(pd.Series(
|
||||
[_disclosure_available_date(x) if not pd.isna(x) else pd.NaT for x in end],
|
||||
index=end.index,
|
||||
))
|
||||
# 极少数 ann_date 早于报告期末(脏数据)时兜底用期末
|
||||
avail = avail.where(avail >= end, end)
|
||||
fina["_avail"] = avail
|
||||
return fina
|
||||
|
||||
|
||||
def effective_available_dates(fina_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
(公开) 返回带 _avail(披露可用日)的财务帧,供同比/环比等因子复用。
|
||||
要求 fina_df 至少含 'end_date';可选 'ann_date'。
|
||||
"""
|
||||
return _eff_available_dates(fina_df)
|
||||
|
||||
|
||||
def map_fundamental_to_daily(
|
||||
daily_df: pd.DataFrame,
|
||||
fina_df: pd.DataFrame,
|
||||
column: str,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
将季度财务数据按披露可用日映射到日线索引(前值填充)。
|
||||
|
||||
- 优先按 ann_date(实际披露日)对齐;
|
||||
- 无 ann_date 时按报告期末的法定披露截止日保守对齐;
|
||||
- 从而避免"财报在披露日之前就被回测看到"的前视偏差。
|
||||
"""
|
||||
if column not in fina_df.columns or "end_date" not in fina_df.columns:
|
||||
return pd.Series(float("nan"), index=daily_df.index)
|
||||
|
||||
cols = ["end_date", column] + (["ann_date"] if "ann_date" in fina_df.columns else [])
|
||||
fina = fina_df[cols].dropna(subset=["end_date", column]).copy()
|
||||
if fina.empty:
|
||||
return pd.Series(float("nan"), index=daily_df.index)
|
||||
|
||||
fina = _eff_available_dates(fina)
|
||||
fina = fina.sort_values("_avail")
|
||||
|
||||
dates = pd.to_datetime(daily_df.index, format="%Y%m%d", errors="coerce")
|
||||
result = pd.Series(float("nan"), index=daily_df.index)
|
||||
avail_dates = fina["_avail"].values
|
||||
|
||||
for i, avail_dt in enumerate(avail_dates):
|
||||
if pd.isna(avail_dt):
|
||||
continue
|
||||
mask = dates >= avail_dt
|
||||
if i + 1 < len(avail_dates) and not pd.isna(avail_dates[i + 1]):
|
||||
mask &= dates < avail_dates[i + 1]
|
||||
result[mask] = fina[column].iloc[i]
|
||||
|
||||
return result.astype("float64")
|
||||
@@ -7,6 +7,7 @@ PE / PB 估值因子。
|
||||
import pandas as pd
|
||||
|
||||
from factors.base import BaseFactor
|
||||
from factors.fundamental._mapping import map_fundamental_to_daily
|
||||
|
||||
|
||||
class PEFactor(BaseFactor):
|
||||
@@ -31,7 +32,7 @@ class PEFactor(BaseFactor):
|
||||
if self._financial_df is None or self._financial_df.empty:
|
||||
return pd.Series(float("nan"), index=df.index)
|
||||
|
||||
eps_series = _map_to_daily(df, self._financial_df, "eps")
|
||||
eps_series = map_fundamental_to_daily(df, self._financial_df, "eps")
|
||||
close = df["close"]
|
||||
return close / eps_series.replace(0, float("nan"))
|
||||
|
||||
@@ -56,7 +57,7 @@ class PBFactor(BaseFactor):
|
||||
if self._financial_df is None or self._financial_df.empty:
|
||||
return pd.Series(float("nan"), index=df.index)
|
||||
|
||||
bvps_series = _map_to_daily(df, self._financial_df, "bvps")
|
||||
bvps_series = map_fundamental_to_daily(df, self._financial_df, "bvps")
|
||||
return df["close"] / bvps_series.replace(0, float("nan"))
|
||||
|
||||
def get_required_columns(self) -> list[str]:
|
||||
@@ -80,34 +81,8 @@ class EPFactor(BaseFactor):
|
||||
if self._financial_df is None or self._financial_df.empty:
|
||||
return pd.Series(float("nan"), index=df.index)
|
||||
|
||||
eps_series = _map_to_daily(df, self._financial_df, "eps")
|
||||
eps_series = map_fundamental_to_daily(df, self._financial_df, "eps")
|
||||
return eps_series / df["close"].replace(0, float("nan")) * 100
|
||||
|
||||
def get_required_columns(self) -> list[str]:
|
||||
return ["close"]
|
||||
|
||||
|
||||
def _map_to_daily(
|
||||
daily_df: pd.DataFrame,
|
||||
fina_df: pd.DataFrame,
|
||||
column: str,
|
||||
) -> pd.Series:
|
||||
"""将季度财务数据填充到日线索引(前值填充)。"""
|
||||
fina = fina_df[["end_date", column]].dropna().copy()
|
||||
fina["end_date"] = fina["end_date"].astype(str)
|
||||
fina = fina.sort_values("end_date")
|
||||
|
||||
result = pd.Series(float("nan"), index=daily_df.index)
|
||||
if fina.empty:
|
||||
return result
|
||||
|
||||
dates = pd.to_datetime(daily_df.index, format="%Y%m%d", errors="coerce")
|
||||
fina_dates = pd.to_datetime(fina["end_date"], format="%Y%m%d", errors="coerce")
|
||||
|
||||
for i, fina_date in enumerate(fina_dates):
|
||||
mask = dates >= fina_date
|
||||
if i + 1 < len(fina_dates):
|
||||
mask &= dates < fina_dates.iloc[i + 1]
|
||||
result[mask] = fina[column].iloc[i]
|
||||
|
||||
return result.astype("float64")
|
||||
|
||||
@@ -5,6 +5,7 @@ ROE 因子。
|
||||
import pandas as pd
|
||||
|
||||
from factors.base import BaseFactor
|
||||
from factors.fundamental._mapping import map_fundamental_to_daily, effective_available_dates
|
||||
|
||||
|
||||
class ROEFactor(BaseFactor):
|
||||
@@ -45,28 +46,8 @@ class ROEFactor(BaseFactor):
|
||||
fina_df: pd.DataFrame,
|
||||
column: str,
|
||||
) -> pd.Series:
|
||||
"""将财务数据(季度)映射到日线索引。"""
|
||||
fina = fina_df[["end_date", column]].dropna().copy()
|
||||
fina["end_date"] = fina["end_date"].astype(str)
|
||||
fina = fina.sort_values("end_date")
|
||||
|
||||
result = pd.Series(float("nan"), index=daily_df.index)
|
||||
|
||||
if fina.empty:
|
||||
return result
|
||||
|
||||
dates = pd.to_datetime(daily_df.index, format="%Y%m%d", errors="coerce")
|
||||
fina_dates = pd.to_datetime(fina["end_date"], format="%Y%m%d", errors="coerce")
|
||||
|
||||
for i, fina_date in enumerate(fina_dates):
|
||||
mask = dates >= fina_date
|
||||
if i + 1 < len(fina_dates):
|
||||
mask &= dates < fina_dates.iloc[i + 1]
|
||||
else:
|
||||
pass # 最新一期覆盖所有后续日期
|
||||
result[mask] = fina[column].iloc[i]
|
||||
|
||||
return result.astype("float64")
|
||||
"""将财务数据(季度)映射到日线索引,按披露可用日消除前视。"""
|
||||
return map_fundamental_to_daily(daily_df, fina_df, column)
|
||||
|
||||
|
||||
class ROETTMDeltaFactor(BaseFactor):
|
||||
@@ -82,23 +63,36 @@ class ROETTMDeltaFactor(BaseFactor):
|
||||
if self._financial_df is None or self._financial_df.empty:
|
||||
return pd.Series(float("nan"), index=df.index)
|
||||
|
||||
fina = self._financial_df[["end_date", "roe"]].dropna().copy()
|
||||
fina["end_date"] = fina["end_date"].astype(str)
|
||||
fina["year"] = fina["end_date"].str[:4].astype(int)
|
||||
fina = fina.sort_values("end_date")
|
||||
if "roe" not in self._financial_df.columns or "end_date" not in self._financial_df.columns:
|
||||
return pd.Series(float("nan"), index=df.index)
|
||||
|
||||
# 基于财务帧计算披露可用日(含 ann_date 优先/法定滞后兜底)
|
||||
fina = effective_available_dates(self._financial_df)
|
||||
fina = fina[["_avail", "end_date", "roe"]].dropna(subset=["_avail", "roe"]).copy()
|
||||
if fina.empty:
|
||||
return pd.Series(float("nan"), index=df.index)
|
||||
|
||||
# 报告期月份(A 股季末:03/06/09/12)
|
||||
end_dt = pd.to_datetime(fina["end_date"].astype(str), format="%Y%m%d", errors="coerce")
|
||||
fina["report_month"] = end_dt.dt.month
|
||||
fina["report_year"] = end_dt.dt.year
|
||||
# 本年取数映射:{ (year, month): roe }
|
||||
cur_map = dict(zip(zip(fina["report_year"], fina["report_month"]), fina["roe"]))
|
||||
# 按披露可用日排序,逐期覆盖区间
|
||||
fina = fina.sort_values("_avail")
|
||||
|
||||
# 按年分组计算 YoY 差值
|
||||
roe_delta = pd.Series(float("nan"), index=df.index)
|
||||
dates = pd.to_datetime(df.index, format="%Y%m%d", errors="coerce")
|
||||
|
||||
for _, row in fina.iterrows():
|
||||
this_year = row["year"]
|
||||
prev_row = fina[fina["year"] == this_year - 1]
|
||||
if prev_row.empty:
|
||||
avail_dt = row["_avail"]
|
||||
if pd.isna(avail_dt):
|
||||
continue
|
||||
delta = row["roe"] - prev_row["roe"].iloc[-1]
|
||||
f_date = pd.to_datetime(row["end_date"], format="%Y%m%d")
|
||||
mask = dates >= f_date
|
||||
prev_roe = cur_map.get((row["report_year"] - 1, row["report_month"]))
|
||||
if prev_roe is None:
|
||||
continue
|
||||
delta = row["roe"] - prev_roe
|
||||
mask = dates >= avail_dt
|
||||
roe_delta[mask] = delta
|
||||
|
||||
return roe_delta.astype("float64")
|
||||
|
||||
@@ -101,6 +101,10 @@ def get_factor(name: str, **overrides) -> BaseFactor:
|
||||
|
||||
返回:
|
||||
BaseFactor 实例
|
||||
|
||||
说明: 返回实例的 .name 恒等于注册键 name,即使构造器默认生成的
|
||||
.name 与注册键不同(如 boll 的构造器默认 .name='boll_20')——
|
||||
注册键是列名的唯一事实源,避免 FactorEngine.compute 的列名漂移。
|
||||
"""
|
||||
if name not in _BUILTIN_FACTORIES:
|
||||
raise KeyError(f"未知因子: '{name}'。可用: {list(_BUILTIN_FACTORIES)}")
|
||||
@@ -109,9 +113,9 @@ def get_factor(name: str, **overrides) -> BaseFactor:
|
||||
for k, v in overrides.items():
|
||||
if hasattr(factor, k):
|
||||
setattr(factor, k, v)
|
||||
# 更新 factor.name
|
||||
if hasattr(factor, "name"):
|
||||
factor.name = name
|
||||
# 无论是否覆盖参数,都强制 .name = 注册键,保证与分类/策略的引用一致
|
||||
if hasattr(factor, "name"):
|
||||
factor.name = name
|
||||
return factor
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -21,6 +22,8 @@ import requests
|
||||
# 确保 .env 已加载
|
||||
import config.settings # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NewsSource:
|
||||
"""
|
||||
@@ -87,7 +90,7 @@ class NewsSource:
|
||||
frames.append(df)
|
||||
time.sleep(self.request_delay)
|
||||
except Exception as e:
|
||||
print(" [WARN] AkShare 新闻获取失败 ({}): {}".format(ts_code, e))
|
||||
logger.warning("[news_source] AkShare 新闻获取失败 (%s): %s", ts_code, e)
|
||||
|
||||
if self.use_xwlb:
|
||||
try:
|
||||
@@ -99,7 +102,7 @@ class NewsSource:
|
||||
if not df.empty:
|
||||
frames.append(df)
|
||||
except Exception as e:
|
||||
print(" [WARN] xwlb 新闻获取失败: {}".format(e))
|
||||
logger.warning("[news_source] xwlb 新闻获取失败: %s", e)
|
||||
|
||||
if self.use_mcp:
|
||||
try:
|
||||
@@ -107,7 +110,7 @@ class NewsSource:
|
||||
if not df.empty:
|
||||
frames.append(df)
|
||||
except Exception as e:
|
||||
print(" [WARN] MCP 新闻获取失败: {}".format(e))
|
||||
logger.warning("[news_source] MCP 新闻获取失败: %s", e)
|
||||
|
||||
if not frames:
|
||||
return pd.DataFrame(columns=["date", "title", "content", "source", "url"])
|
||||
@@ -133,7 +136,8 @@ class NewsSource:
|
||||
symbol = ts_code.replace(".SZ", "").replace(".SH", "").replace(".BJ", "")
|
||||
try:
|
||||
df = ak.stock_news_em(symbol=symbol.zfill(6))
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning("[news_source] AkShare 新闻接口失败 (%s): %s", ts_code, e)
|
||||
return pd.DataFrame()
|
||||
|
||||
if df is None or df.empty:
|
||||
@@ -204,7 +208,8 @@ class NewsSource:
|
||||
df["url"] = ""
|
||||
return df[["date", "title", "content", "source", "url"]]
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning("[news_source] xwlb 新闻获取失败: %s", e)
|
||||
return pd.DataFrame()
|
||||
|
||||
# ── MCP 数据源 ────────────────────────────────────────
|
||||
@@ -232,7 +237,8 @@ class NewsSource:
|
||||
df = pd.DataFrame(records)
|
||||
df["source"] = "mcp_trendradar"
|
||||
return df[["date", "title", "content", "source", "url"]]
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning("[news_source] MCP 新闻获取失败: %s", e)
|
||||
return pd.DataFrame()
|
||||
|
||||
def _mcp_initialize(self) -> str | None:
|
||||
@@ -267,10 +273,10 @@ class NewsSource:
|
||||
if session_id:
|
||||
self._mcp_session_id = session_id
|
||||
else:
|
||||
print(" [WARN] MCP initialize 未返回 session-id")
|
||||
logger.warning("[news_source] MCP initialize 未返回 session-id")
|
||||
return session_id
|
||||
except Exception as e:
|
||||
print(" [WARN] MCP 连接失败: {}".format(e))
|
||||
logger.warning("[news_source] MCP 连接失败: %s", e)
|
||||
return None
|
||||
|
||||
def _mcp_call_tool(
|
||||
@@ -299,7 +305,8 @@ class NewsSource:
|
||||
data = json.loads(line[5:].strip())
|
||||
return data.get("result", {})
|
||||
return None
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning("[news_source] MCP call_tool 失败: %s", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -128,11 +128,12 @@ class SentimentEngine:
|
||||
# 5. Qwen 情绪分析(有 API key 时才执行)
|
||||
sentiment_df = self._analyze_news(news_df)
|
||||
|
||||
# 6. 计算因子
|
||||
# 6. 计算因子(对齐注册表,产出全部 4 个注册情绪因子,含 news_sent_20)
|
||||
factor_dfs = {}
|
||||
if not sentiment_df.empty:
|
||||
for factor_cls, kwargs in [
|
||||
(NewsSentimentFactor, {"window": 5, "sentiment_df": sentiment_df}),
|
||||
(NewsSentimentFactor, {"window": 20, "sentiment_df": sentiment_df}),
|
||||
(SentimentConfidenceFactor, {"window": 5, "sentiment_df": sentiment_df}),
|
||||
(SentimentMomentumFactor, {"period": 5, "sentiment_df": sentiment_df}),
|
||||
]:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
RSI 相对强弱因子。
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from factors.base import BaseFactor
|
||||
@@ -22,8 +23,13 @@ class RSIFactor(BaseFactor):
|
||||
loss = (-delta).clip(lower=0)
|
||||
avg_gain = gain.ewm(span=self.period, min_periods=self.period).mean()
|
||||
avg_loss = loss.ewm(span=self.period, min_periods=self.period).mean()
|
||||
rs = avg_gain / avg_loss.replace(0, float("nan"))
|
||||
return 100 - 100 / (1 + rs)
|
||||
# 标准 Wilder RSI:avg_loss==0 时 RSI 应 = 100,而非 NaN。
|
||||
# 用 where 显式处理除零,避免 replace(0, nan) 把上涨趋势判为缺失。
|
||||
rs = avg_gain / avg_loss.where(avg_loss != 0, np.nan)
|
||||
rsi = 100 - 100 / (1 + rs)
|
||||
# 上涨且无下跌的高位情形补 100(无 prior-loss 的窗口仍留 NaN 由上游填充)
|
||||
rsi = rsi.where(avg_loss != 0, 100.0)
|
||||
return rsi
|
||||
|
||||
def get_required_columns(self) -> list[str]:
|
||||
return ["close"]
|
||||
|
||||
Reference in New Issue
Block a user