代码审核报告(2026-07-31,quanxiel 全量审核) #1

Open
opened 2026-08-01 08:21:41 +08:00 by shellway · 0 comments
Owner

Gitea 代码审核报告 (quanxiel)

审核模型: kimi-k2.7-code

审核时间: 2026-07-31 23:54


📄 alpha/config.py

alpha/config.py 的审核如下:


🔴 严重

1. 数据库密码明文硬编码(第 19 行)

db_password: str = "postgres"
  • 风险:敏感信息随源码提交,存在泄露风险;部署环境不同还需改代码。
  • 建议:从环境变量或密钥管理服务读取。
import os

db_password: str = field(default_factory=lambda: os.getenv("DB_PASSWORD", ""))

同时建议 db_hostdb_user 等也支持环境变量覆盖。


2. end_date 为未来日期(第 24 行)

end_date: str = "2025-12-31"
  • 风险:若当前系统时间早于该日期,回测可能拉取到“未来数据”或空数据,引发 未来函数(look-ahead bias) 或运行时异常。
  • 建议
    • 默认使用当前日期;
    • 或加载数据时强制校验 end_date <= today
from datetime import date
end_date: str = field(default_factory=lambda: date.today().isoformat())

🟡 建议

3. 日期字段使用 str 而非日期类型(第 22–24 行)

start_date: str = "2020-01-01"
end_date: str = "2025-12-31"
  • 风险:格式错误只能在运行时发现,且与 pandas/trading calendar 对齐时易出错。
  • 建议:使用 datetime.date 或在 dataclass 初始化后校验格式。
from datetime import date

start_date: date = date(2020, 1, 1)
end_date: date = date.today()

4. output_dir 使用相对路径(第 36 行)

output_dir: str = "./output"
  • 风险:工作目录不确定时输出位置混乱;目录不存在时写入失败。
  • 建议:使用 pathlib.Path,并提供创建目录逻辑。
from pathlib import Path

output_dir: Path = Path("./output")

# 使用处确保创建
self.output_dir.mkdir(parents=True, exist_ok=True)

5. 交易成本语义不明确(第 27–29 行)

commission_rate: float = 0.0003
slippage: float = 0.001
stamp_tax: float = 0.001  # 仅卖出
  • 风险:印花税“仅卖出”这一规则无法仅靠字段名表达,容易在回测引擎侧误用为双边。
  • 建议:拆分为买入/卖出费率,或增加注释明确由回测引擎负责方向判断。
buy_commission_rate: float = 0.0003
sell_commission_rate: float = 0.0003
sell_stamp_tax: float = 0.001
slippage: float = 0.001

6. max_turnover 缺少计算口径说明(第 33 行)

max_turnover: float = 0.20
  • 风险:换手率定义多样(是成交额/总资产,还是单边/双边),策略与回测实现可能不一致。
  • 建议:增加注释或拆分为 max_daily_turnover_pct,并在回测文档中明确公式。

🟢 优化

7. 内部 IP 默认写入配置(第 15 行)

db_host: str = "192.168.27.15"
  • 虽不直接泄露到公网,但降低了环境可移植性。
  • 建议:通过环境变量覆盖,默认改为 localhost 或空字符串强制配置。
db_host: str = field(default_factory=lambda: os.getenv("DB_HOST", "localhost"))

8. 缺少配置校验方法

  • 当前 dataclass 仅定义字段,没有统一校验入口。
  • 建议:增加 __post_init__ 进行基础校验。
from datetime import date

def __post_init__(self):
    if self.start_date >= self.end_date:
        raise ValueError("start_date 必须早于 end_date")
    if self.max_position_pct <= 0 or self.max_position_pct > 1:
        raise ValueError("max_position_pct 应在 (0, 1] 之间")
    if self.commission_rate < 0 or self.slippage < 0:
        raise ValueError("交易成本不能为负")

9. 因子窗口命名可更精确(第 31 行)

factor_windows: List[int] = field(default_factory=lambda: [5, 10, 20, 60])
  • 建议命名为 factor_lookback_windows,避免与“滚动窗口函数”混淆。

总结

行号 问题 严重级别
19 数据库密码明文硬编码 🔴 严重
24 结束日期为未来日期 🔴 严重
22–24 日期类型为 str 🟡 建议
36 相对路径输出目录 🟡 建议
27–29 交易成本语义不清 🟡 建议
33 max_turnover 口径不明 🟡 建议
15 内网 IP 写死 🟢 优化
整体 缺少配置校验 🟢 优化
31 命名可更清晰 🟢 优化

📄 alpha/__init__.py

alpha/__init__.py 的审核结果如下:


🔴 严重

该文件作为包的公共 API 入口,结构清晰,未发现正确性、安全性或量化回测逻辑上的严重问题。


🟡 建议

1. 行 7-15 — 循环导入风险

from .config import AlphaConfig
from .factors import FactorRegistry, BaseFactor, ...

所有子模块在包导入时即被全部加载。若 .strategy.backtest.factors 等子模块之间存在相互引用,容易引发 ImportError / 循环导入。

建议:确保各子模块间为单向依赖,或通过类型注解字符串(from __future__ import annotations)延迟类型解析。


2. 行 4 — 包名一致性需确认

"""
quanxiel.alpha — 阿尔法策略研究与回测模块
"""

docstring 中使用的包名为 quanxiel,请确认与项目实际根包名一致。


🟢 优化

1. 行 7-15 — 延迟导入(可选)

如果 .backtest.evaluation 等子模块较重(例如加载大量历史数据、依赖 pandas/numpy heavy 初始化),全量 eager import 会拖慢 import alpha 的启动时间。

建议(可选):

import importlib

_SUBMODULES = {
    "AlphaConfig": ".config",
    "FactorRegistry": ".factors",
    # ...
}

def __getattr__(name: str):
    if name in _SUBMODULES:
        module = importlib.import_module(_SUBMODULES[name], __package__)
        return getattr(module, name)
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

2. 模块元数据补充

可在文件顶部或 __all__ 上方增加:

__version__ = "0.1.0"

便于版本管理与依赖约束。


审核小结

维度 结论
正确性 良好,__all__ 与导入项一致
安全性 无敏感信息、无路径操作
性能 当前无低效逻辑,子模块重时可考虑懒加载
可维护性 命名规范、结构清晰
量化特有风险 本文件仅为入口,回测/因子逻辑需在对应子模块中重点审计

总体评价:该 __init__.py 是一份合格的包入口文件,主要风险在于子模块间可能存在的循环依赖,建议在后续子模块审核中重点关注。


📄 alpha/factors.py

以下是对 alpha/factors.py 的专业代码审核,按严重程度分级列出。


🔴 严重

1. 技术面/基本面因子使用全局标准化,存在未来函数风险

位置TechnicalFactor.compute(第 121–125 行)、FundamentalFactor.compute(第 151–154 行)

mom.mean()/mom.std()series.mean()/series.std() 是在全时间序列+全截面上计算。若 data 为面板数据(MultiIndex [date, code]),则当前因子值用到了未来日期和其他股票的信息,构成 look-ahead bias

修改建议:因子标准化应使用截面标准化(按日期分组):

def _xs_zscore(s: pd.Series, eps: float = 1e-12) -> pd.Series:
    return s.groupby(level=0).transform(lambda x: (x - x.mean()) / (x.std() + eps))

2. quantile_returns 未按日期分组分箱,分层结果无意义

位置FactorAnalyzer.quantile_returns(第 227–238 行)

pd.qcut(df["factor"], n_quantiles) 直接对整个因子序列分箱。若输入为面板数据,会混合不同日期的股票,导致"分组"跨期混合,分层收益不可解释。

修改建议:按日期 groupby 后分箱:

def quantile_returns(self, n_quantiles: int = 5) -> pd.DataFrame:
    df = pd.DataFrame({"factor": self.factor, "fwd": self.forward}).dropna()
    df["quantile"] = (
        df.groupby(level=0)["factor"]
        .transform(lambda x: pd.qcut(x.rank(method="first"), n_quantiles, labels=False) + 1)
    )
    return df.groupby(level=0).apply(
        lambda g: g.groupby("quantile")["fwd"].mean()
    ).groupby("quantile").mean()

3. turnover 为未完成的占位代码,返回自相关系数并非换手率

位置FactorAnalyzer.turnover(第 240–257 行)

函数体内有 pass # 此处需按股票计算 --- 简化处理,实际返回的是因子自相关系数,不是量化意义上的组合换手率(相邻期持仓变化比例)。该函数若被调用会产生误导性结果。

修改建议:实现为真实换手率,例如 Top-K 组合的日度换手率:

def turnover(self, n_quantiles: int = 5, top_quantile: int = 5) -> pd.Series:
    df = pd.DataFrame({"factor": self.factor}).dropna()
    df["quantile"] = df.groupby(level=0)["factor"].transform(
        lambda x: pd.qcut(x.rank(method="first"), n_quantiles, labels=False) + 1
    )
    holdings = df[df["quantile"] == top_quantile].reset_index().groupby(level=0)["code"].apply(set)
    turnover = holdings.diff().map(lambda x: len(x) / len(holdings.shift(1).loc[x.index]) if holdings.shift(1).loc[x.index] else np.nan)
    return turnover

4. ic_decay 混淆全样本相关性与截面 IC 衰减

位置FactorAnalyzer.ic_decay(第 214–225 行)

当前实现用 stats.spearmanr 在整个因子/收益对上计算一个相关系数,这混合了时间序列与截面信息。IC 衰减的标准定义是:每个截面的 Rank IC 在不同前瞻期上的均值衰减

修改建议:逐期计算 IC 后取均值:

def ic_decay(self, forward_returns: Dict[int, pd.Series]) -> pd.Series:
    decay = {}
    for horizon, fwd in forward_returns.items():
        aligned = fwd.reindex(self.factor.index)
        df = pd.DataFrame({"f": self.factor, "r": aligned}).dropna()
        ics = df.groupby(level=0).apply(
            lambda g: g["f"].corr(g["r"], method="spearman")
        )
        decay[horizon] = ics.mean()
    return pd.Series(decay, name="IC_Decay")

🟡 建议

5. 因子标准化存在除零风险

位置:第 121–125 行、第 151–154 行

series.std() 可能为 0(例如全部相同值或仅 1 个样本),会导致 inf/NaN

修改建议:分母加 eps

z = (series - series.mean()) / (series.std() + 1e-12)

6. neutralize 未处理共线性、缺失值与截距项

位置BaseFactor.neutralize(第 75–100 行)

  • 行业虚拟变量包含全部类别时,与常数列存在完全共线性(dummy variable trap)。当前无截距项,虽可估计,但对异常行业组(如单样本组)不稳定。
  • X.dropna().index 可能因市值或行业缺失而过度剔除样本。
  • 未处理某一日期所有股票属于同一行业的退化情况,此时 np.linalg.lstsq 矩阵不满秩。

修改建议

df = pd.DataFrame({"factor": factor, "log_mcap": np.log(market_cap.clip(lower=1)), "group": group})
df = df.dropna()
X = pd.get_dummies(df["group"], prefix="group", drop_first=True)
X = pd.concat([df["log_mcap"], X], axis=1)
X = sm.add_constant(X)  # 或使用 np.column_stack([np.ones(len(X)), X.values])
beta = np.linalg.lstsq(X.values, df["factor"].values, rcond=None)[0]
residual = df["factor"].values - X.values @ beta
return pd.Series(residual, index=df.index, name=factor.name)

7. pd.qcut 遇到重复边界可能报错

位置FactorAnalyzer.quantile_returns(第 232 行)

因子值常存在大量相同值(如停牌、一字板),pd.qcut 可能抛出 Bin edges must be unique

修改建议:使用 rank(method="first") 先转秩再分箱,或设置 duplicates="drop"(但后者会改变分组数)。

8. quantile_returns 使用 cumsum 模拟累计收益不准确

位置:第 237 行

result["avg_return"].cumsum() 是算术累计,未考虑复利。若 avg_return 是单期收益率,应使用几何累计:

result["cum_return"] = (1 + result["avg_return"]).cumprod() - 1

9. FactorRegistry.register 静默覆盖同名因子

位置:第 30–32 行

重复注册不会报错,可能覆盖已有因子,导致运行时行为不一致。

修改建议

if name in cls._factors:
    raise ValueError(f"因子 '{name}' 已注册")

10. compute_ic 单截面分支空数据行为异常

位置:第 191–198 行

self.factor 为空时,返回 index=[0] 的 Series,索引语义错误。

修改建议

if len(self.factor) == 0:
    return pd.Series(dtype=float)

🟢 优化

11. FactorRegistry._factors 类级可变字典存在继承污染

位置:第 25 行

子类会共享同一个 _factors。若未来有按策略/市场的多个注册表需求,会产生冲突。

修改建议

class FactorRegistry:
    _factors: Dict[str, type] = field(default_factory=dict)  # 或实例化时初始化

12. 标准化逻辑重复

位置TechnicalFactorFundamentalFactor

两处都实现了类似的 z-score 计算。建议提取公共工具函数 _zscore(series, inverse=False)

13. 高基数行业虚拟变量内存开销大

位置BaseFactor.neutralize(第 87–88 行)

pd.get_dummies 在行业数量多时会产生稠密矩阵。可考虑:

  • 使用 pd.Categorical 编码后用 scipy.sparse 拟合;
  • 或使用 statsmodelsOLS 直接传入分类变量。

14. FactorAnalyzer 输入格式假设不统一

位置__init__ic_decay

__init__ 接收 pd.Series,而 ic_decay 接收 Dict[int, pd.Series],且各方法对单截面/面板数据的分支处理分散,易出错。

修改建议:在 __init__ 中统一要求 MultiIndex [date, code],若不是则转换为该格式或显式报错。


安全性

本文件为纯计算逻辑模块,未发现 API key 硬编码、文件路径操作、SQL/命令注入等安全风险。


总结

该模块结构清晰,注册表与基类设计合理,但在量化正确性方面存在多个关键问题:全局标准化导致未来函数、分层未按日期分组、turnover 未完成、ic_decay 计算方法错误。建议优先修复 🔴 严重级别问题后再投入回测使用。


📄 alpha/strategy.py


📄 alpha/backtest.py


📄 alpha/evaluation.py


📄 quantitative_data/config.py

代码审核报告:quantitative_data/config.py

总体评价

这是一个结构清晰的配置模块,环境变量管理思路正确。但存在量化投资特有的未来函数风险、默认值安全隐患以及生产环境健壮性问题。


🔴 严重问题

1. 默认 END_DATE 为未来日期,存在未来函数风险

行号:61

END_DATE = os.environ.get("QUANT_END_DATE", "2025-12-31")  # 数据结束日期
  • 问题:默认结束日期为 2025-12-31,若当前日期早于该值,回测/数据拉取可能请求到未来数据,导致 look-ahead bias
  • 建议:默认使用当前日期,并由业务层根据具体研究需求显式扩展。

2. 敏感/关键配置使用空字符串默认值,失败点后置

行号:42、56

_PASSWORD = os.environ.get("QUANT_DB_PASSWORD", "")
TUSHARE_TOKEN = os.environ.get("TUSHARE_TOKEN", "")
  • 问题:未设置时静默返回空字符串,数据库连接或 Tushare 调用会在运行时才失败,增加排查难度;空密码还可能触发数据库的 trust 认证策略,造成非预期连接。
  • 建议:对必要凭证使用 raise ValueErrorpydantic 校验,在启动期即失败。

3. load_dotenv(override=True) 可能覆盖系统环境变量

行号:32

load_dotenv(dotenv_path=env_path, override=True)
  • 问题:在服务器/容器环境中,系统环境变量通常是最终部署配置;.env 反而可能是开发环境残留,override 会导致生产配置被本地配置覆盖。
  • 建议:默认 override=False,或根据 ENVIRONMENT 变量决定是否覆盖。

🟡 建议问题

4. _LOADED 标志定义后未使用

行号:20

_LOADED = False
  • 问题:后续虽然会赋值为 True,但没有任何模块读取该变量,属于无效代码。
  • 建议:移除,或暴露给调用方判断 .env 是否被加载。

5. int() 转换缺少错误处理

行号:47、59

"port": int(os.environ.get("QUANT_DB_PORT", "5438")),
BATCH_SIZE = int(os.environ.get("QUANT_BATCH_SIZE", "5000"))
  • 问题:环境变量值非法时会抛出 ValueError,但错误信息不直观,难以定位是配置问题。
  • 建议:封装 int 转换函数,给出包含变量名的错误提示。

6. .env 路径查找逻辑冗余且存在误加载风险

行号:25-30

env_path = Path(__file__).resolve().parent / ".env"
if not env_path.exists():
    cwd_path = Path.cwd() / ".env"
    if cwd_path.exists():
        env_path = cwd_path
  • 问题
    • Path(__file__).resolve() 已经是绝对路径,注释中的顾虑不成立。
    • 回退到 Path.cwd() 可能在 Notebook 中加载错误目录下的 .env,造成配置串扰。
  • 建议:固定为模块同级目录,移除 cwd 回退逻辑;若需多环境支持,通过 ENVIRONMENT 变量显式指定文件。

7. 硬编码内网 IP、端口及数据库名

行号:44-49

"host": os.environ.get("QUANT_DB_HOST", "192.168.27.11"),
"port": int(os.environ.get("QUANT_DB_PORT", "5438")),
"database": os.environ.get("QUANT_DB_NAME", "quant_db"),
"user": os.environ.get("QUANT_DB_USER", "postgres"),
  • 问题:默认值泄露了内部网络拓扑和数据库命名约定;若在未配置环境变量的机器上运行,会尝试连接一个可能存在的内网服务。
  • 建议:敏感/环境相关默认值改为 localhost/5432,或不提供默认值强制外部传入。

8. 使用 print 输出配置加载状态

行号:33、35、37、39

  • 问题:配置加载信息通过 print 输出,不利于日志分级、持久化和生产环境排查。
  • 建议:改用 logging 模块。

9. 日期格式与范围未校验

行号:60-61

  • 问题START_DATEEND_DATE 仅作为字符串,无法保证格式正确、起始不晚于结束。
  • 建议:使用 datetime.date.fromisoformat 校验,并断言 start <= end

10. .env 文件有被误提交到版本控制的风险

  • 建议:在 .gitignore 中加入 .env,并提供 .env.example 模板。

🟢 优化建议

  1. 增加类型注解,便于静态检查和 IDE 提示。
  2. 提供 SQLAlchemy URI 构建函数,避免各模块重复拼接连接串。
  3. 默认 END_DATEdate.today(),结合业务需求允许显式覆盖。
  4. ** survivors bias 提醒**:START_DATE 固定为 2010-01-01 时,需确保下游代码处理退市/已消失标的,避免仅保留存活股票导致幸存者偏差。

重构建议

"""
量化数据库配置文件

敏感信息通过环境变量管理:
  - QUANT_DB_PASSWORD : 数据库密码
  - TUSHARE_TOKEN     : Tushare API Token

也可创建 .env 文件(参考 .env.example),注意将 .env 加入 .gitignore。
"""
import logging
import os
from datetime import date
from pathlib import Path
from urllib.parse import quote_plus

logger = logging.getLogger(__name__)

try:
    from dotenv import load_dotenv
except ImportError:
    load_dotenv = None


def _load_dotenv() -> None:
    """加载模块同级目录下的 .env,默认不覆盖系统环境变量。"""
    if load_dotenv is None:
        logger.warning("python-dotenv 未安装,跳过 .env 自动加载")
        return

    env_path = Path(__file__).resolve().parent / ".env"
    if env_path.exists():
        load_dotenv(dotenv_path=env_path, override=False)
        logger.info("已加载环境变量文件: %s", env_path)
    else:
        logger.debug("未找到 .env 文件,使用系统环境变量")


def _getenv_int(name: str, default: str) -> int:
    value = os.environ.get(name, default)
    try:
        return int(value)
    except ValueError as exc:
        raise ValueError(f"环境变量 {name} 必须是整数,当前值: {value!r}") from exc


def _getenv_required(name: str) -> str:
    value = os.environ.get(name, "")
    if not value:
        raise ValueError(f"环境变量 {name} 未设置或为空")
    return value


_load_dotenv()

# 数据库配置(不再硬编码内网地址)
_PASSWORD = _getenv_required("QUANT_DB_PASSWORD")

DB_CONFIG: dict[str, object] = {
    "host": os.environ.get("QUANT_DB_HOST", "localhost"),
    "port": _getenv_int("QUANT_DB_PORT", "5432"),
    "database": os.environ.get("QUANT_DB_NAME", "quant_db"),
    "user": os.environ.get("QUANT_DB_USER", "postgres"),
    "password": _PASSWORD,
}

PASSWORD_ENCODED = quote_plus(_PASSWORD)

# Tushare API Token
TUSHARE_TOKEN = _getenv_required("TUSHARE_TOKEN")

# 批量导入参数
BATCH_SIZE = _getenv_int("QUANT_BATCH_SIZE", "5000")

# 日期范围(默认起始日期固定,结束日期不超过今天以避免未来函数)
START_DATE = os.environ.get("QUANT_START_DATE", "2010-01-01")
END_DATE = os.environ.get("QUANT_END_DATE", str(date.today()))

try:
    _start = date.fromisoformat(START_DATE)
    _end = date.fromisoformat(END_DATE)
except ValueError as exc:
    raise ValueError("START_DATE/END_DATE 必须是 ISO 格式 (YYYY-MM-DD)") from exc

if _start > _end:
    raise ValueError(f"START_DATE ({START_DATE}) 不能晚于 END_DATE ({END_DATE})")

if _end > date.today():
    logger.warning(
        "END_DATE (%s) 晚于当前日期,存在未来函数风险,请确认回测逻辑",
        END_DATE,
    )


def get_database_url(driver: str = "postgresql+psycopg2") -> str:
    """构建 SQLAlchemy 数据库连接串。"""
    return (
        f"{driver}://{DB_CONFIG['user']}:{PASSWORD_ENCODED}"
        f"@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}"
    )

核心修改点总结:消除未来函数风险、启动期校验必要凭证、避免 .env 覆盖系统环境变量、用日志替代 print、提供统一的数据库 URL 构建入口。


📄 quantitative_data/importer.py


📄 quantitative_data/schema.sql



Gitea 代码审核报告 Part2

📄 alpha/strategy.py

调用失败: The read operation timed out

📄 alpha/backtest.py

📄 alpha/evaluation.py

总体评价

alpha/evaluation.py 结构清晰、指标覆盖较全,适合作为回测后绩效统计模块。但存在 量化指标计算语义歧义边界/输入校验不足 的问题,尤其 return 列的处理和 NAV 起点假设可能在真实数据中导致结果完全错误。建议在接入生产环境前修复 🔴 问题,并补充单元测试。


🔴 严重

行号 问题 修改建议
L35-36 'return' 列被强制 diff(),默认当作累计收益;若用户传入的是日收益率,则所有指标都会错算(变成一阶差分)。 明确约定列语义;优先识别 daily_return 列;若只有 return,建议增加 return_type 参数或统一要求传入 nav/daily_return
L49-50 total_returnnav 列上假设初始净值恒为 1.0nav.iloc[-1] - 1.0。若 NAV 从 1000 或任意非 1 起点开始,累计收益会完全错误。 改为 nav.iloc[-1] / nav.iloc[0] - 1.0,并断言/处理 nav.iloc[0] <= 0 的情况。

🟡 建议

行号 问题 修改建议
L36, L38 首个收益率用 fillna(0) 强填,会引入一个“零收益”观测,低估波动、影响年化起点。 首行保留为 NaN,或在计算指标时从第二个有效值开始;文档中明确说明。
L53-57 annual_return 基于 len(self.equity) / 252,未考虑实际日历跨度、缺失交易日或非日频数据;当 total_return < -1 时几何年化可能出现异常值/复数。 self.equity.index 计算实际年化天数;对 total_return <= -1 返回 NaN 或做保护。
L59-61 annual_volatility 在样本数 < 2std() 返回 NaN,未做边界处理。 观测数不足时返回 NaN 并打印警告。
L65-70 max_drawdown 未处理 running_max <= 0,若传入未归一化的净值或价格为 0,会导致除零或错误回撤。 进入计算前校验 nav > 0;或处理异常值。
L73-86 max_drawdown_duration 统计的是“连续低于历史高点的天数”,不是从峰值到再创新高的完整回撤周期,语义可能与业务预期不一致。 文档中明确语义,或改为计算 underwater 期(peak → recovery)。
L90-95 sharpe_ratio1e-12 硬编码兜底,波动接近 0 时会返回巨大数值;且未处理全零/单样本。 excess.std() == 0 或样本不足时返回 NaN/0 并警告,不要依赖 magic epsilon。
L102-108 sortino_ratio 实现的是“负收益的标准差”,不是标准的 target downside deviation(超过目标收益率部分计为 0)。 使用目标收益 target = self.rf / periods_per_year,计算 np.sqrt(np.mean(np.minimum(returns - target, 0) ** 2)) * sqrt(252)
L115-120 profit_loss_ratio 在无盈利或无亏损时会因空序列平均产生 NaN/Inf 分别判断 avg_win/avg_loss 是否为空,返回 NaN 并提示。
L124-157 alpha/beta/information_ratio 均依赖 _align_benchmark(),但未校验基准类型、索引是否重叠、样本量是否 ≥2;benchmark_returns 若传入 DataFrame 还会导致列名赋值失败。 _align_benchmark()

📄 quantitative_data/importer.py

总体评估

该模块结构清晰、职责单一,能满足基础数据落地需求。但在**数据正确性(NaT/NaN、DDL 错误隐藏)、量化回测有效性(幸存者偏差)、安全性(SQL 标识符未转义)**三类问题上存在较严重隐患,建议优先修复。其余多为可维护性与性能优化项。


🔴 严重问题

1. 幸存者偏差:仅导入当前上市股票

  • 位置get_stock_codes_from_db() 约第 993–1010 行;full_import() 约第 1050 行附近
  • 问题WHERE list_status = 'L' 只取上市股票,退市/暂停股票的历史日线、财务数据不会被导入。若后续用于回测,策略只会在“当前仍存活”的股票上测试,显著高估收益。
  • 建议
    • 默认导入 L/D/P 全部状态,或至少把 D(退市)纳入日线与财务数据导入范围;
    • full_import() 增加 include_delisted: bool = True 参数,并在文档中明确说明。
    • get_stock_codes_from_db() 改为:
      def get_stock_codes_from_db(conn=None, status_list=("L", "D", "P")) -> List[str]:
          ...
          cursor.execute(
              "SELECT ts_code FROM stock_basic WHERE list_status = ANY(%s) ORDER BY ts_code",
              (list(status_list),),
          )
      

2. batch_insertpd.NaT / NaN 直接入库

  • 位置batch_insert() 约第 109–110
  • 问题df.itertuples(index=False) 会保留 pd.NaTfloat('nan')psycopg2 无法识别 NaT,且 NaN 写入数值列会产生 PostgreSQL NaN,导致后续计算/比较异常。
  • 建议:入库前统一替换为 None
    columns = list(df.columns)
    df = df.where(pd.notna(df), None)          # NaN/NaT -> None
    rows = [tuple(row) for row in df.itertuples(index=False, name=None)]
    

3. batch_insertconflict_columns 未做 SQL 标识符转义

  • 位置:约第 113124137
  • 问题conflict_str = ", ".join(conflict_columns) 后直接 sql.SQL(conflict_str) 拼入 SQL。虽然当前由内部常量传入,但属于可被外部参数影响的函数接口,存在 SQL 注入/语法错误风险。
  • 建议
    conflict_sql = sql.SQL(", ").join(map(sql.Identifier, conflict_columns))
    # 使用 conflict=conflict_sql
    

4. init_database() 静默吞掉 DDL 错误

  • 位置:约第 915–930 行(cursor.execute(stmt)except Exception 只记 logger.debug
  • 问题:建表/索引失败被静默忽略,模块运行到后续 batch_insert 时才报错,调试成本高;重跑时难以判断 schema 是否完整。
  • 建议:改为 logger.error 并重新抛出,或至少收集错误后统一抛出:
    except Exception as e:
        logger.error(f"Schema 执行失败: {stmt[:200]}... 错误: {e}")
        raise
    

🟡 建议改进

5. 类型注解错误:str = None

  • 位置:多处,如 import_trade_cal(start_date: str = None, ...)import_daily_batch(...)import_adj_factor(...)
  • 问题None 默认值与 str 类型注解冲突,mypy 会报错。
  • 建议:统一改为 Optional[str] = None

6. import_daily_by_year() 计数逻辑错误且未聚合失败列表

  • 位置:约第 445–470
  • 问题total_imported += 1 按“年”计数,而非实际记录数;每年 fail_list 被丢弃,无法整体重试。
  • 建议
    total_imported = 0
    all_failures = set()
    for year in range(...):
        fail_list = import_daily_batch(...)
        all_failures.update(fail_list)
        # total_imported 由 import_daily_batch 返回累计,或新增返回值
    

7. import_financial_statements() 异常只记 debug,失败不可见

  • 位置:约第 775–780
  • 问题:单只股票单张报表失败仅在 debug 级别输出,生产环境默认 INFO 时完全不可见,容易漏掉大量缺失数据。
  • 建议:改为 logger.warninglogger.error,并记录 (ts_code, table_name)

8. 财务数据变量名具有误导性

  • 位置import_financial_statements() 约第 735–745
  • 问题:变量命名为 period_start/period_end,但 Tushare 的 income/balancesheet/cashflow/fina_indicator 接口中 start_date/end_date 实际代表公告日期范围,不是报告期。
  • 建议:重命名为 ann_start/ann_end,并在 docstring 中说明,避免下游按报告期理解。

9. import_daily_basic_by_date() 没有重试机制

  • 位置:约第 550–595
  • 问题:直接调用 pro.daily_basic(trade_date=td),遇到 Tushare 偶发超时/限流即单日记丢失。
  • 建议:复用 fetch_with_retry()
    def fetch():
        return pro.daily_basic(trade_date=td)
    df = fetch_with_retry(fetch, max_retries=3)
    

10. 数据库密码未做 URL 编码

  • 位置get_sqlalchemy_engine() 约第 57–60
  • 问题PASSWORD_ENCODED 直接拼入连接串,若含 @/# 等字符会解析失败。
  • 建议
    from urllib.parse import quote_plus
    password = quote_plus(PASSWORD_ENCODED)
    db_url = f"postgresql://{DB_CONFIG['user']}:{password}@{host}:{port}/{database}"
    

11. schema.sql 按分号切分过于脆弱

  • 位置init_database() 约第 905–915
  • 问题:若 DDL 中包含函数体、字符串常量、触发器里的分号,会被错误拆分;注释过滤也不彻底(仅判断 s.strip().startswith("--"))。
  • 建议
    • 使用 sqlparse.split(ddl_sql)
    • 或在部署流程中直接通过 psql 执行 schema 文件,而不是在 Python 里做简单 split。

12. import_daily_for_stock() 强插列可能引发 schema 不匹配

  • 位置:约第 335–340
  • 问题:代码把 turnover_ratema5 等列强制设为 None;若目标表无这些列,batch_insert 会直接报错。
  • 建议:从 information_schema.columns 读取目标表实际列,或仅在 schema 保证包含这些列时保留该逻辑。

13. safe_float() 已定义但未使用

  • 位置:约第 91–98
  • 问题:说明原本希望把 NaN 转成 None,但实际未应用,导致第 2 条风险。
  • 建议:要么在数值列处理中统一使用(向量化方式),要么删除。

🟢 优化项

14. 模块导入时即配置全局日志

  • 位置:约第 23–31
  • 建议:作为可能被其他模块 import 的库,不应在顶层调用 logging.basicConfig()。建议封装成 setup_logging(),仅在 if __name__ == "__main__": 中调用。

15. 使用 with 上下文管理连接与游标

  • 位置:贯穿全文件
  • 建议get_pg_connection()conn.cursor() 建议用上下文管理器,避免异常时连接泄漏。例如:
    with get_pg_connection() as conn:
        with conn.cursor() as cur:
            ...
    

16. 单线程串行抓取,全量导入极慢

  • 位置import_daily_batch()import_adj_factor_batch()import_financial_statements()
  • 建议:在遵守 Tushare 限流前提下,使用 ThreadPoolExecutor/asyncio + 令牌桶限流器并发抓取;写入数据库仍建议批量事务,避免单条提交。

17. 按年导入日线效率低

  • 位置import_daily_by_year() 约第 430–470
  • 问题:Tushare daily 接口单次最多返回约 6000 条,A 股 15 年日线通常不到 4000 条,完全可以一次取完整区间,按年调用浪费 API 额度。
  • 建议:默认按完整区间取;仅当返回提示超过限制时再按年/分段 fallback。

18. itertuples 可替换为更快的转换

  • 位置batch_insert() 约第 110
  • 建议
    rows = df.to_numpy(dtype=object).tolist()
    
    itertuples 更快,且配合 df.where(pd.notna(df), None) 后元素类型更可控。

19. 去除冗余/未使用代码

  • timedelta 已导入未使用;
  • get_sqlalchemy_engine() 当前未在文件内使用;
  • init_database() 内局部 import os as _os 可改为模块顶部 import ospathlib.Path

量化投资特有风险汇总

风险点 位置 说明
幸存者偏差 get_stock_codes_from_db / full_import 仅取 list_status='L',历史回测漏掉退市股
前视偏差(Look-ahead bias) income/balancesheet/cashflow/fina_indicator end_date 作为冲突键,未以 ann_date/f_ann_date 作为实际可用时点;下游若直接按报告期末使用数据,将引入未来信息
基本面数据时态问题 stock_basic 只保存最新行业/地区/list_status,未保留历史变更,行业中性/分层回测可能使用未来状态
复权因子对齐 daily + adj_factor 分别导入 未校验两表日期是否一一对应,缺失复权因子会导致复权价格错误

建议在后端查询层增加 ann_date <= 当前交易日 的点-in-time 视图,并在回测框架中显式处理前视偏差与幸存者偏差。

# Gitea 代码审核报告 (quanxiel) 审核模型: kimi-k2.7-code 审核时间: 2026-07-31 23:54 --- ## 📄 alpha/config.py 对 `alpha/config.py` 的审核如下: --- ## 🔴 严重 ### 1. 数据库密码明文硬编码(第 19 行) ```python db_password: str = "postgres" ``` - **风险**:敏感信息随源码提交,存在泄露风险;部署环境不同还需改代码。 - **建议**:从环境变量或密钥管理服务读取。 ```python import os db_password: str = field(default_factory=lambda: os.getenv("DB_PASSWORD", "")) ``` 同时建议 `db_host`、`db_user` 等也支持环境变量覆盖。 --- ### 2. `end_date` 为未来日期(第 24 行) ```python end_date: str = "2025-12-31" ``` - **风险**:若当前系统时间早于该日期,回测可能拉取到“未来数据”或空数据,引发 **未来函数(look-ahead bias)** 或运行时异常。 - **建议**: - 默认使用当前日期; - 或加载数据时强制校验 `end_date <= today`。 ```python from datetime import date end_date: str = field(default_factory=lambda: date.today().isoformat()) ``` --- ## 🟡 建议 ### 3. 日期字段使用 `str` 而非日期类型(第 22–24 行) ```python start_date: str = "2020-01-01" end_date: str = "2025-12-31" ``` - **风险**:格式错误只能在运行时发现,且与 pandas/trading calendar 对齐时易出错。 - **建议**:使用 `datetime.date` 或在 dataclass 初始化后校验格式。 ```python from datetime import date start_date: date = date(2020, 1, 1) end_date: date = date.today() ``` --- ### 4. `output_dir` 使用相对路径(第 36 行) ```python output_dir: str = "./output" ``` - **风险**:工作目录不确定时输出位置混乱;目录不存在时写入失败。 - **建议**:使用 `pathlib.Path`,并提供创建目录逻辑。 ```python from pathlib import Path output_dir: Path = Path("./output") # 使用处确保创建 self.output_dir.mkdir(parents=True, exist_ok=True) ``` --- ### 5. 交易成本语义不明确(第 27–29 行) ```python commission_rate: float = 0.0003 slippage: float = 0.001 stamp_tax: float = 0.001 # 仅卖出 ``` - **风险**:印花税“仅卖出”这一规则无法仅靠字段名表达,容易在回测引擎侧误用为双边。 - **建议**:拆分为买入/卖出费率,或增加注释明确由回测引擎负责方向判断。 ```python buy_commission_rate: float = 0.0003 sell_commission_rate: float = 0.0003 sell_stamp_tax: float = 0.001 slippage: float = 0.001 ``` --- ### 6. `max_turnover` 缺少计算口径说明(第 33 行) ```python max_turnover: float = 0.20 ``` - **风险**:换手率定义多样(是成交额/总资产,还是单边/双边),策略与回测实现可能不一致。 - **建议**:增加注释或拆分为 `max_daily_turnover_pct`,并在回测文档中明确公式。 --- ## 🟢 优化 ### 7. 内部 IP 默认写入配置(第 15 行) ```python db_host: str = "192.168.27.15" ``` - 虽不直接泄露到公网,但降低了环境可移植性。 - **建议**:通过环境变量覆盖,默认改为 `localhost` 或空字符串强制配置。 ```python db_host: str = field(default_factory=lambda: os.getenv("DB_HOST", "localhost")) ``` --- ### 8. 缺少配置校验方法 - 当前 dataclass 仅定义字段,没有统一校验入口。 - **建议**:增加 `__post_init__` 进行基础校验。 ```python from datetime import date def __post_init__(self): if self.start_date >= self.end_date: raise ValueError("start_date 必须早于 end_date") if self.max_position_pct <= 0 or self.max_position_pct > 1: raise ValueError("max_position_pct 应在 (0, 1] 之间") if self.commission_rate < 0 or self.slippage < 0: raise ValueError("交易成本不能为负") ``` --- ### 9. 因子窗口命名可更精确(第 31 行) ```python factor_windows: List[int] = field(default_factory=lambda: [5, 10, 20, 60]) ``` - 建议命名为 `factor_lookback_windows`,避免与“滚动窗口函数”混淆。 --- ## 总结 | 行号 | 问题 | 严重级别 | |------|------|----------| | 19 | 数据库密码明文硬编码 | 🔴 严重 | | 24 | 结束日期为未来日期 | 🔴 严重 | | 22–24 | 日期类型为 `str` | 🟡 建议 | | 36 | 相对路径输出目录 | 🟡 建议 | | 27–29 | 交易成本语义不清 | 🟡 建议 | | 33 | `max_turnover` 口径不明 | 🟡 建议 | | 15 | 内网 IP 写死 | 🟢 优化 | | 整体 | 缺少配置校验 | 🟢 优化 | | 31 | 命名可更清晰 | 🟢 优化 | --- ## 📄 alpha/__init__.py 对 `alpha/__init__.py` 的审核结果如下: --- ## 🔴 严重 **无** 该文件作为包的公共 API 入口,结构清晰,未发现正确性、安全性或量化回测逻辑上的严重问题。 --- ## 🟡 建议 ### 1. 行 7-15 — 循环导入风险 ```python from .config import AlphaConfig from .factors import FactorRegistry, BaseFactor, ... ``` 所有子模块在包导入时即被全部加载。若 `.strategy`、`.backtest`、`.factors` 等子模块之间存在相互引用,容易引发 `ImportError` / 循环导入。 **建议**:确保各子模块间为单向依赖,或通过类型注解字符串(`from __future__ import annotations`)延迟类型解析。 --- ### 2. 行 4 — 包名一致性需确认 ```python """ quanxiel.alpha — 阿尔法策略研究与回测模块 """ ``` docstring 中使用的包名为 `quanxiel`,请确认与项目实际根包名一致。 --- ## 🟢 优化 ### 1. 行 7-15 — 延迟导入(可选) 如果 `.backtest`、`.evaluation` 等子模块较重(例如加载大量历史数据、依赖 pandas/numpy heavy 初始化),全量 eager import 会拖慢 `import alpha` 的启动时间。 **建议**(可选): ```python import importlib _SUBMODULES = { "AlphaConfig": ".config", "FactorRegistry": ".factors", # ... } def __getattr__(name: str): if name in _SUBMODULES: module = importlib.import_module(_SUBMODULES[name], __package__) return getattr(module, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ``` ### 2. 模块元数据补充 可在文件顶部或 `__all__` 上方增加: ```python __version__ = "0.1.0" ``` 便于版本管理与依赖约束。 --- ## 审核小结 | 维度 | 结论 | |------|------| | 正确性 | ✅ 良好,`__all__` 与导入项一致 | | 安全性 | ✅ 无敏感信息、无路径操作 | | 性能 | ✅ 当前无低效逻辑,子模块重时可考虑懒加载 | | 可维护性 | ✅ 命名规范、结构清晰 | | 量化特有风险 | ⚪ 本文件仅为入口,回测/因子逻辑需在对应子模块中重点审计 | **总体评价**:该 `__init__.py` 是一份合格的包入口文件,主要风险在于子模块间可能存在的循环依赖,建议在后续子模块审核中重点关注。 --- ## 📄 alpha/factors.py 以下是对 `alpha/factors.py` 的专业代码审核,按严重程度分级列出。 --- ## 🔴 严重 ### 1. 技术面/基本面因子使用全局标准化,存在未来函数风险 **位置**:`TechnicalFactor.compute`(第 121–125 行)、`FundamentalFactor.compute`(第 151–154 行) `mom.mean()/mom.std()` 与 `series.mean()/series.std()` 是在**全时间序列+全截面**上计算。若 `data` 为面板数据(`MultiIndex [date, code]`),则当前因子值用到了未来日期和其他股票的信息,构成 **look-ahead bias**。 **修改建议**:因子标准化应使用**截面标准化**(按日期分组): ```python def _xs_zscore(s: pd.Series, eps: float = 1e-12) -> pd.Series: return s.groupby(level=0).transform(lambda x: (x - x.mean()) / (x.std() + eps)) ``` ### 2. `quantile_returns` 未按日期分组分箱,分层结果无意义 **位置**:`FactorAnalyzer.quantile_returns`(第 227–238 行) `pd.qcut(df["factor"], n_quantiles)` 直接对整个因子序列分箱。若输入为面板数据,会混合不同日期的股票,导致"分组"跨期混合,分层收益不可解释。 **修改建议**:按日期 groupby 后分箱: ```python def quantile_returns(self, n_quantiles: int = 5) -> pd.DataFrame: df = pd.DataFrame({"factor": self.factor, "fwd": self.forward}).dropna() df["quantile"] = ( df.groupby(level=0)["factor"] .transform(lambda x: pd.qcut(x.rank(method="first"), n_quantiles, labels=False) + 1) ) return df.groupby(level=0).apply( lambda g: g.groupby("quantile")["fwd"].mean() ).groupby("quantile").mean() ``` ### 3. `turnover` 为未完成的占位代码,返回自相关系数并非换手率 **位置**:`FactorAnalyzer.turnover`(第 240–257 行) 函数体内有 `pass # 此处需按股票计算 --- 简化处理`,实际返回的是因子自相关系数,不是量化意义上的**组合换手率**(相邻期持仓变化比例)。该函数若被调用会产生误导性结果。 **修改建议**:实现为真实换手率,例如 Top-K 组合的日度换手率: ```python def turnover(self, n_quantiles: int = 5, top_quantile: int = 5) -> pd.Series: df = pd.DataFrame({"factor": self.factor}).dropna() df["quantile"] = df.groupby(level=0)["factor"].transform( lambda x: pd.qcut(x.rank(method="first"), n_quantiles, labels=False) + 1 ) holdings = df[df["quantile"] == top_quantile].reset_index().groupby(level=0)["code"].apply(set) turnover = holdings.diff().map(lambda x: len(x) / len(holdings.shift(1).loc[x.index]) if holdings.shift(1).loc[x.index] else np.nan) return turnover ``` ### 4. `ic_decay` 混淆全样本相关性与截面 IC 衰减 **位置**:`FactorAnalyzer.ic_decay`(第 214–225 行) 当前实现用 `stats.spearmanr` 在整个因子/收益对上计算一个相关系数,这混合了时间序列与截面信息。IC 衰减的标准定义是:**每个截面的 Rank IC 在不同前瞻期上的均值衰减**。 **修改建议**:逐期计算 IC 后取均值: ```python def ic_decay(self, forward_returns: Dict[int, pd.Series]) -> pd.Series: decay = {} for horizon, fwd in forward_returns.items(): aligned = fwd.reindex(self.factor.index) df = pd.DataFrame({"f": self.factor, "r": aligned}).dropna() ics = df.groupby(level=0).apply( lambda g: g["f"].corr(g["r"], method="spearman") ) decay[horizon] = ics.mean() return pd.Series(decay, name="IC_Decay") ``` --- ## 🟡 建议 ### 5. 因子标准化存在除零风险 **位置**:第 121–125 行、第 151–154 行 `series.std()` 可能为 0(例如全部相同值或仅 1 个样本),会导致 `inf/NaN`。 **修改建议**:分母加 `eps`: ```python z = (series - series.mean()) / (series.std() + 1e-12) ``` ### 6. `neutralize` 未处理共线性、缺失值与截距项 **位置**:`BaseFactor.neutralize`(第 75–100 行) - 行业虚拟变量包含全部类别时,与常数列存在完全共线性(dummy variable trap)。当前无截距项,虽可估计,但对异常行业组(如单样本组)不稳定。 - `X.dropna().index` 可能因市值或行业缺失而过度剔除样本。 - 未处理某一日期所有股票属于同一行业的退化情况,此时 `np.linalg.lstsq` 矩阵不满秩。 **修改建议**: ```python df = pd.DataFrame({"factor": factor, "log_mcap": np.log(market_cap.clip(lower=1)), "group": group}) df = df.dropna() X = pd.get_dummies(df["group"], prefix="group", drop_first=True) X = pd.concat([df["log_mcap"], X], axis=1) X = sm.add_constant(X) # 或使用 np.column_stack([np.ones(len(X)), X.values]) beta = np.linalg.lstsq(X.values, df["factor"].values, rcond=None)[0] residual = df["factor"].values - X.values @ beta return pd.Series(residual, index=df.index, name=factor.name) ``` ### 7. `pd.qcut` 遇到重复边界可能报错 **位置**:`FactorAnalyzer.quantile_returns`(第 232 行) 因子值常存在大量相同值(如停牌、一字板),`pd.qcut` 可能抛出 `Bin edges must be unique`。 **修改建议**:使用 `rank(method="first")` 先转秩再分箱,或设置 `duplicates="drop"`(但后者会改变分组数)。 ### 8. `quantile_returns` 使用 cumsum 模拟累计收益不准确 **位置**:第 237 行 `result["avg_return"].cumsum()` 是算术累计,未考虑复利。若 avg_return 是单期收益率,应使用几何累计: ```python result["cum_return"] = (1 + result["avg_return"]).cumprod() - 1 ``` ### 9. `FactorRegistry.register` 静默覆盖同名因子 **位置**:第 30–32 行 重复注册不会报错,可能覆盖已有因子,导致运行时行为不一致。 **修改建议**: ```python if name in cls._factors: raise ValueError(f"因子 '{name}' 已注册") ``` ### 10. `compute_ic` 单截面分支空数据行为异常 **位置**:第 191–198 行 当 `self.factor` 为空时,返回 `index=[0]` 的 Series,索引语义错误。 **修改建议**: ```python if len(self.factor) == 0: return pd.Series(dtype=float) ``` --- ## 🟢 优化 ### 11. `FactorRegistry._factors` 类级可变字典存在继承污染 **位置**:第 25 行 子类会共享同一个 `_factors`。若未来有按策略/市场的多个注册表需求,会产生冲突。 **修改建议**: ```python class FactorRegistry: _factors: Dict[str, type] = field(default_factory=dict) # 或实例化时初始化 ``` ### 12. 标准化逻辑重复 **位置**:`TechnicalFactor` 与 `FundamentalFactor` 两处都实现了类似的 z-score 计算。建议提取公共工具函数 `_zscore(series, inverse=False)`。 ### 13. 高基数行业虚拟变量内存开销大 **位置**:`BaseFactor.neutralize`(第 87–88 行) `pd.get_dummies` 在行业数量多时会产生稠密矩阵。可考虑: - 使用 `pd.Categorical` 编码后用 `scipy.sparse` 拟合; - 或使用 `statsmodels` 的 `OLS` 直接传入分类变量。 ### 14. `FactorAnalyzer` 输入格式假设不统一 **位置**:`__init__` 与 `ic_decay` `__init__` 接收 `pd.Series`,而 `ic_decay` 接收 `Dict[int, pd.Series]`,且各方法对单截面/面板数据的分支处理分散,易出错。 **修改建议**:在 `__init__` 中统一要求 `MultiIndex [date, code]`,若不是则转换为该格式或显式报错。 --- ## 安全性 本文件为纯计算逻辑模块,**未发现** API key 硬编码、文件路径操作、SQL/命令注入等安全风险。 --- ## 总结 该模块结构清晰,注册表与基类设计合理,但在**量化正确性**方面存在多个关键问题:全局标准化导致未来函数、分层未按日期分组、`turnover` 未完成、`ic_decay` 计算方法错误。建议优先修复 🔴 严重级别问题后再投入回测使用。 --- ## 📄 alpha/strategy.py --- ## 📄 alpha/backtest.py --- ## 📄 alpha/evaluation.py --- ## 📄 quantitative_data/config.py # 代码审核报告:`quantitative_data/config.py` ## 总体评价 这是一个结构清晰的配置模块,环境变量管理思路正确。但存在**量化投资特有的未来函数风险**、默认值安全隐患以及生产环境健壮性问题。 --- ## 🔴 严重问题 ### 1. 默认 `END_DATE` 为未来日期,存在未来函数风险 **行号:61** ```python END_DATE = os.environ.get("QUANT_END_DATE", "2025-12-31") # 数据结束日期 ``` - **问题**:默认结束日期为 2025-12-31,若当前日期早于该值,回测/数据拉取可能请求到未来数据,导致 **look-ahead bias**。 - **建议**:默认使用当前日期,并由业务层根据具体研究需求显式扩展。 ### 2. 敏感/关键配置使用空字符串默认值,失败点后置 **行号:42、56** ```python _PASSWORD = os.environ.get("QUANT_DB_PASSWORD", "") TUSHARE_TOKEN = os.environ.get("TUSHARE_TOKEN", "") ``` - **问题**:未设置时静默返回空字符串,数据库连接或 Tushare 调用会在运行时才失败,增加排查难度;空密码还可能触发数据库的 trust 认证策略,造成非预期连接。 - **建议**:对必要凭证使用 `raise ValueError` 或 `pydantic` 校验,在启动期即失败。 ### 3. `load_dotenv(override=True)` 可能覆盖系统环境变量 **行号:32** ```python load_dotenv(dotenv_path=env_path, override=True) ``` - **问题**:在服务器/容器环境中,系统环境变量通常是最终部署配置;`.env` 反而可能是开发环境残留,override 会导致生产配置被本地配置覆盖。 - **建议**:默认 `override=False`,或根据 `ENVIRONMENT` 变量决定是否覆盖。 --- ## 🟡 建议问题 ### 4. `_LOADED` 标志定义后未使用 **行号:20** ```python _LOADED = False ``` - **问题**:后续虽然会赋值为 `True`,但没有任何模块读取该变量,属于无效代码。 - **建议**:移除,或暴露给调用方判断 `.env` 是否被加载。 ### 5. `int()` 转换缺少错误处理 **行号:47、59** ```python "port": int(os.environ.get("QUANT_DB_PORT", "5438")), BATCH_SIZE = int(os.environ.get("QUANT_BATCH_SIZE", "5000")) ``` - **问题**:环境变量值非法时会抛出 `ValueError`,但错误信息不直观,难以定位是配置问题。 - **建议**:封装 `int` 转换函数,给出包含变量名的错误提示。 ### 6. `.env` 路径查找逻辑冗余且存在误加载风险 **行号:25-30** ```python env_path = Path(__file__).resolve().parent / ".env" if not env_path.exists(): cwd_path = Path.cwd() / ".env" if cwd_path.exists(): env_path = cwd_path ``` - **问题**: - `Path(__file__).resolve()` 已经是绝对路径,注释中的顾虑不成立。 - 回退到 `Path.cwd()` 可能在 Notebook 中加载错误目录下的 `.env`,造成配置串扰。 - **建议**:固定为模块同级目录,移除 `cwd` 回退逻辑;若需多环境支持,通过 `ENVIRONMENT` 变量显式指定文件。 ### 7. 硬编码内网 IP、端口及数据库名 **行号:44-49** ```python "host": os.environ.get("QUANT_DB_HOST", "192.168.27.11"), "port": int(os.environ.get("QUANT_DB_PORT", "5438")), "database": os.environ.get("QUANT_DB_NAME", "quant_db"), "user": os.environ.get("QUANT_DB_USER", "postgres"), ``` - **问题**:默认值泄露了内部网络拓扑和数据库命名约定;若在未配置环境变量的机器上运行,会尝试连接一个可能存在的内网服务。 - **建议**:敏感/环境相关默认值改为 `localhost`/`5432`,或不提供默认值强制外部传入。 ### 8. 使用 `print` 输出配置加载状态 **行号:33、35、37、39** - **问题**:配置加载信息通过 `print` 输出,不利于日志分级、持久化和生产环境排查。 - **建议**:改用 `logging` 模块。 ### 9. 日期格式与范围未校验 **行号:60-61** - **问题**:`START_DATE` 和 `END_DATE` 仅作为字符串,无法保证格式正确、起始不晚于结束。 - **建议**:使用 `datetime.date.fromisoformat` 校验,并断言 `start <= end`。 ### 10. `.env` 文件有被误提交到版本控制的风险 - **建议**:在 `.gitignore` 中加入 `.env`,并提供 `.env.example` 模板。 --- ## 🟢 优化建议 1. **增加类型注解**,便于静态检查和 IDE 提示。 2. **提供 SQLAlchemy URI 构建函数**,避免各模块重复拼接连接串。 3. **默认 `END_DATE` 用 `date.today()`**,结合业务需求允许显式覆盖。 4. ** survivors bias 提醒**:`START_DATE` 固定为 2010-01-01 时,需确保下游代码处理退市/已消失标的,避免仅保留存活股票导致幸存者偏差。 --- ## 重构建议 ```python """ 量化数据库配置文件 敏感信息通过环境变量管理: - QUANT_DB_PASSWORD : 数据库密码 - TUSHARE_TOKEN : Tushare API Token 也可创建 .env 文件(参考 .env.example),注意将 .env 加入 .gitignore。 """ import logging import os from datetime import date from pathlib import Path from urllib.parse import quote_plus logger = logging.getLogger(__name__) try: from dotenv import load_dotenv except ImportError: load_dotenv = None def _load_dotenv() -> None: """加载模块同级目录下的 .env,默认不覆盖系统环境变量。""" if load_dotenv is None: logger.warning("python-dotenv 未安装,跳过 .env 自动加载") return env_path = Path(__file__).resolve().parent / ".env" if env_path.exists(): load_dotenv(dotenv_path=env_path, override=False) logger.info("已加载环境变量文件: %s", env_path) else: logger.debug("未找到 .env 文件,使用系统环境变量") def _getenv_int(name: str, default: str) -> int: value = os.environ.get(name, default) try: return int(value) except ValueError as exc: raise ValueError(f"环境变量 {name} 必须是整数,当前值: {value!r}") from exc def _getenv_required(name: str) -> str: value = os.environ.get(name, "") if not value: raise ValueError(f"环境变量 {name} 未设置或为空") return value _load_dotenv() # 数据库配置(不再硬编码内网地址) _PASSWORD = _getenv_required("QUANT_DB_PASSWORD") DB_CONFIG: dict[str, object] = { "host": os.environ.get("QUANT_DB_HOST", "localhost"), "port": _getenv_int("QUANT_DB_PORT", "5432"), "database": os.environ.get("QUANT_DB_NAME", "quant_db"), "user": os.environ.get("QUANT_DB_USER", "postgres"), "password": _PASSWORD, } PASSWORD_ENCODED = quote_plus(_PASSWORD) # Tushare API Token TUSHARE_TOKEN = _getenv_required("TUSHARE_TOKEN") # 批量导入参数 BATCH_SIZE = _getenv_int("QUANT_BATCH_SIZE", "5000") # 日期范围(默认起始日期固定,结束日期不超过今天以避免未来函数) START_DATE = os.environ.get("QUANT_START_DATE", "2010-01-01") END_DATE = os.environ.get("QUANT_END_DATE", str(date.today())) try: _start = date.fromisoformat(START_DATE) _end = date.fromisoformat(END_DATE) except ValueError as exc: raise ValueError("START_DATE/END_DATE 必须是 ISO 格式 (YYYY-MM-DD)") from exc if _start > _end: raise ValueError(f"START_DATE ({START_DATE}) 不能晚于 END_DATE ({END_DATE})") if _end > date.today(): logger.warning( "END_DATE (%s) 晚于当前日期,存在未来函数风险,请确认回测逻辑", END_DATE, ) def get_database_url(driver: str = "postgresql+psycopg2") -> str: """构建 SQLAlchemy 数据库连接串。""" return ( f"{driver}://{DB_CONFIG['user']}:{PASSWORD_ENCODED}" f"@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}" ) ``` --- **核心修改点总结**:消除未来函数风险、启动期校验必要凭证、避免 `.env` 覆盖系统环境变量、用日志替代 `print`、提供统一的数据库 URL 构建入口。 --- ## 📄 quantitative_data/importer.py --- ## 📄 quantitative_data/schema.sql --- --- # Gitea 代码审核报告 Part2 ## 📄 alpha/strategy.py 调用失败: The read operation timed out ## 📄 alpha/backtest.py ## 📄 alpha/evaluation.py ## 总体评价 `alpha/evaluation.py` 结构清晰、指标覆盖较全,适合作为回测后绩效统计模块。但存在 **量化指标计算语义歧义** 与 **边界/输入校验不足** 的问题,尤其 `return` 列的处理和 NAV 起点假设可能在真实数据中导致结果完全错误。建议在接入生产环境前修复 🔴 问题,并补充单元测试。 --- ## 🔴 严重 | 行号 | 问题 | 修改建议 | |------|------|----------| | **L35-36** | `'return'` 列被强制 `diff()`,默认当作**累计收益**;若用户传入的是日收益率,则所有指标都会错算(变成一阶差分)。 | 明确约定列语义;优先识别 `daily_return` 列;若只有 `return`,建议增加 `return_type` 参数或统一要求传入 `nav`/`daily_return`。 | | **L49-50** | `total_return` 在 `nav` 列上假设初始净值恒为 `1.0`:`nav.iloc[-1] - 1.0`。若 NAV 从 `1000` 或任意非 1 起点开始,累计收益会完全错误。 | 改为 `nav.iloc[-1] / nav.iloc[0] - 1.0`,并断言/处理 `nav.iloc[0] <= 0` 的情况。 | --- ## 🟡 建议 | 行号 | 问题 | 修改建议 | |------|------|----------| | **L36, L38** | 首个收益率用 `fillna(0)` 强填,会引入一个“零收益”观测,低估波动、影响年化起点。 | 首行保留为 `NaN`,或在计算指标时从第二个有效值开始;文档中明确说明。 | | **L53-57** | `annual_return` 基于 `len(self.equity) / 252`,未考虑实际日历跨度、缺失交易日或非日频数据;当 `total_return < -1` 时几何年化可能出现异常值/复数。 | 用 `self.equity.index` 计算实际年化天数;对 `total_return <= -1` 返回 `NaN` 或做保护。 | | **L59-61** | `annual_volatility` 在样本数 `< 2` 时 `std()` 返回 `NaN`,未做边界处理。 | 观测数不足时返回 `NaN` 并打印警告。 | | **L65-70** | `max_drawdown` 未处理 `running_max <= 0`,若传入未归一化的净值或价格为 0,会导致除零或错误回撤。 | 进入计算前校验 `nav > 0`;或处理异常值。 | | **L73-86** | `max_drawdown_duration` 统计的是“连续低于历史高点的天数”,不是从峰值到**再创新高**的完整回撤周期,语义可能与业务预期不一致。 | 文档中明确语义,或改为计算 underwater 期(peak → recovery)。 | | **L90-95** | `sharpe_ratio` 用 `1e-12` 硬编码兜底,波动接近 0 时会返回巨大数值;且未处理全零/单样本。 | 当 `excess.std() == 0` 或样本不足时返回 `NaN/0` 并警告,不要依赖 magic epsilon。 | | **L102-108** | `sortino_ratio` 实现的是“负收益的标准差”,不是标准的 **target downside deviation**(超过目标收益率部分计为 0)。 | 使用目标收益 `target = self.rf / periods_per_year`,计算 `np.sqrt(np.mean(np.minimum(returns - target, 0) ** 2)) * sqrt(252)`。 | | **L115-120** | `profit_loss_ratio` 在无盈利或无亏损时会因空序列平均产生 `NaN/Inf`。 | 分别判断 `avg_win`/`avg_loss` 是否为空,返回 `NaN` 并提示。 | | **L124-157** | `alpha/beta/information_ratio` 均依赖 `_align_benchmark()`,但未校验基准类型、索引是否重叠、样本量是否 ≥2;`benchmark_returns` 若传入 DataFrame 还会导致列名赋值失败。 | 在 `_align_benchmark()` ## 📄 quantitative_data/importer.py ## 总体评估 该模块结构清晰、职责单一,能满足基础数据落地需求。但在**数据正确性(NaT/NaN、DDL 错误隐藏)、量化回测有效性(幸存者偏差)、安全性(SQL 标识符未转义)**三类问题上存在较严重隐患,建议优先修复。其余多为可维护性与性能优化项。 --- ## 🔴 严重问题 ### 1. 幸存者偏差:仅导入当前上市股票 - **位置**:`get_stock_codes_from_db()` 约第 `993–1010` 行;`full_import()` 约第 `1050` 行附近 - **问题**:`WHERE list_status = 'L'` 只取上市股票,退市/暂停股票的历史日线、财务数据不会被导入。若后续用于回测,策略只会在“当前仍存活”的股票上测试,显著高估收益。 - **建议**: - 默认导入 `L/D/P` 全部状态,或至少把 `D`(退市)纳入日线与财务数据导入范围; - `full_import()` 增加 `include_delisted: bool = True` 参数,并在文档中明确说明。 - `get_stock_codes_from_db()` 改为: ```python def get_stock_codes_from_db(conn=None, status_list=("L", "D", "P")) -> List[str]: ... cursor.execute( "SELECT ts_code FROM stock_basic WHERE list_status = ANY(%s) ORDER BY ts_code", (list(status_list),), ) ``` ### 2. `batch_insert` 中 `pd.NaT` / `NaN` 直接入库 - **位置**:`batch_insert()` 约第 `109–110` 行 - **问题**:`df.itertuples(index=False)` 会保留 `pd.NaT` 与 `float('nan')`。`psycopg2` 无法识别 `NaT`,且 `NaN` 写入数值列会产生 PostgreSQL `NaN`,导致后续计算/比较异常。 - **建议**:入库前统一替换为 `None`: ```python columns = list(df.columns) df = df.where(pd.notna(df), None) # NaN/NaT -> None rows = [tuple(row) for row in df.itertuples(index=False, name=None)] ``` ### 3. `batch_insert` 的 `conflict_columns` 未做 SQL 标识符转义 - **位置**:约第 `113`、`124`、`137` 行 - **问题**:`conflict_str = ", ".join(conflict_columns)` 后直接 `sql.SQL(conflict_str)` 拼入 SQL。虽然当前由内部常量传入,但属于可被外部参数影响的函数接口,存在 SQL 注入/语法错误风险。 - **建议**: ```python conflict_sql = sql.SQL(", ").join(map(sql.Identifier, conflict_columns)) # 使用 conflict=conflict_sql ``` ### 4. `init_database()` 静默吞掉 DDL 错误 - **位置**:约第 `915–930` 行(`cursor.execute(stmt)` 的 `except Exception` 只记 `logger.debug`) - **问题**:建表/索引失败被静默忽略,模块运行到后续 `batch_insert` 时才报错,调试成本高;重跑时难以判断 schema 是否完整。 - **建议**:改为 `logger.error` 并重新抛出,或至少收集错误后统一抛出: ```python except Exception as e: logger.error(f"Schema 执行失败: {stmt[:200]}... 错误: {e}") raise ``` --- ## 🟡 建议改进 ### 5. 类型注解错误:`str = None` - **位置**:多处,如 `import_trade_cal(start_date: str = None, ...)`、`import_daily_batch(...)`、`import_adj_factor(...)` 等 - **问题**:`None` 默认值与 `str` 类型注解冲突,mypy 会报错。 - **建议**:统一改为 `Optional[str] = None`。 ### 6. `import_daily_by_year()` 计数逻辑错误且未聚合失败列表 - **位置**:约第 `445–470` 行 - **问题**:`total_imported += 1` 按“年”计数,而非实际记录数;每年 `fail_list` 被丢弃,无法整体重试。 - **建议**: ```python total_imported = 0 all_failures = set() for year in range(...): fail_list = import_daily_batch(...) all_failures.update(fail_list) # total_imported 由 import_daily_batch 返回累计,或新增返回值 ``` ### 7. `import_financial_statements()` 异常只记 `debug`,失败不可见 - **位置**:约第 `775–780` 行 - **问题**:单只股票单张报表失败仅在 debug 级别输出,生产环境默认 INFO 时完全不可见,容易漏掉大量缺失数据。 - **建议**:改为 `logger.warning` 或 `logger.error`,并记录 `(ts_code, table_name)`。 ### 8. 财务数据变量名具有误导性 - **位置**:`import_financial_statements()` 约第 `735–745` 行 - **问题**:变量命名为 `period_start`/`period_end`,但 Tushare 的 `income`/`balancesheet`/`cashflow`/`fina_indicator` 接口中 `start_date`/`end_date` 实际代表**公告日期范围**,不是报告期。 - **建议**:重命名为 `ann_start`/`ann_end`,并在 docstring 中说明,避免下游按报告期理解。 ### 9. `import_daily_basic_by_date()` 没有重试机制 - **位置**:约第 `550–595` 行 - **问题**:直接调用 `pro.daily_basic(trade_date=td)`,遇到 Tushare 偶发超时/限流即单日记丢失。 - **建议**:复用 `fetch_with_retry()`: ```python def fetch(): return pro.daily_basic(trade_date=td) df = fetch_with_retry(fetch, max_retries=3) ``` ### 10. 数据库密码未做 URL 编码 - **位置**:`get_sqlalchemy_engine()` 约第 `57–60` 行 - **问题**:`PASSWORD_ENCODED` 直接拼入连接串,若含 `@`、`/`、`#` 等字符会解析失败。 - **建议**: ```python from urllib.parse import quote_plus password = quote_plus(PASSWORD_ENCODED) db_url = f"postgresql://{DB_CONFIG['user']}:{password}@{host}:{port}/{database}" ``` ### 11. `schema.sql` 按分号切分过于脆弱 - **位置**:`init_database()` 约第 `905–915` 行 - **问题**:若 DDL 中包含函数体、字符串常量、触发器里的分号,会被错误拆分;注释过滤也不彻底(仅判断 `s.strip().startswith("--")`)。 - **建议**: - 使用 `sqlparse.split(ddl_sql)`; - 或在部署流程中直接通过 `psql` 执行 schema 文件,而不是在 Python 里做简单 split。 ### 12. `import_daily_for_stock()` 强插列可能引发 schema 不匹配 - **位置**:约第 `335–340` 行 - **问题**:代码把 `turnover_rate`、`ma5` 等列强制设为 `None`;若目标表无这些列,`batch_insert` 会直接报错。 - **建议**:从 `information_schema.columns` 读取目标表实际列,或仅在 schema 保证包含这些列时保留该逻辑。 ### 13. `safe_float()` 已定义但未使用 - **位置**:约第 `91–98` 行 - **问题**:说明原本希望把 `NaN` 转成 `None`,但实际未应用,导致第 2 条风险。 - **建议**:要么在数值列处理中统一使用(向量化方式),要么删除。 --- ## 🟢 优化项 ### 14. 模块导入时即配置全局日志 - **位置**:约第 `23–31` 行 - **建议**:作为可能被其他模块 import 的库,不应在顶层调用 `logging.basicConfig()`。建议封装成 `setup_logging()`,仅在 `if __name__ == "__main__":` 中调用。 ### 15. 使用 `with` 上下文管理连接与游标 - **位置**:贯穿全文件 - **建议**:`get_pg_connection()`、`conn.cursor()` 建议用上下文管理器,避免异常时连接泄漏。例如: ```python with get_pg_connection() as conn: with conn.cursor() as cur: ... ``` ### 16. 单线程串行抓取,全量导入极慢 - **位置**:`import_daily_batch()`、`import_adj_factor_batch()`、`import_financial_statements()` - **建议**:在遵守 Tushare 限流前提下,使用 `ThreadPoolExecutor`/`asyncio` + 令牌桶限流器并发抓取;写入数据库仍建议批量事务,避免单条提交。 ### 17. 按年导入日线效率低 - **位置**:`import_daily_by_year()` 约第 `430–470` 行 - **问题**:Tushare `daily` 接口单次最多返回约 6000 条,A 股 15 年日线通常不到 4000 条,完全可以一次取完整区间,按年调用浪费 API 额度。 - **建议**:默认按完整区间取;仅当返回提示超过限制时再按年/分段 fallback。 ### 18. `itertuples` 可替换为更快的转换 - **位置**:`batch_insert()` 约第 `110` 行 - **建议**: ```python rows = df.to_numpy(dtype=object).tolist() ``` 比 `itertuples` 更快,且配合 `df.where(pd.notna(df), None)` 后元素类型更可控。 ### 19. 去除冗余/未使用代码 - `timedelta` 已导入未使用; - `get_sqlalchemy_engine()` 当前未在文件内使用; - `init_database()` 内局部 `import os as _os` 可改为模块顶部 `import os` 或 `pathlib.Path`。 --- ## 量化投资特有风险汇总 | 风险点 | 位置 | 说明 | |---|---|---| | **幸存者偏差** | `get_stock_codes_from_db` / `full_import` | 仅取 `list_status='L'`,历史回测漏掉退市股 | | **前视偏差(Look-ahead bias)** | `income/balancesheet/cashflow/fina_indicator` | 以 `end_date` 作为冲突键,未以 `ann_date`/`f_ann_date` 作为实际可用时点;下游若直接按报告期末使用数据,将引入未来信息 | | **基本面数据时态问题** | `stock_basic` | 只保存最新行业/地区/`list_status`,未保留历史变更,行业中性/分层回测可能使用未来状态 | | **复权因子对齐** | `daily` + `adj_factor` 分别导入 | 未校验两表日期是否一一对应,缺失复权因子会导致复权价格错误 | 建议在后端查询层增加 `ann_date <= 当前交易日` 的点-in-time 视图,并在回测框架中显式处理前视偏差与幸存者偏差。
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: shellway/quanxiel#1