"""市场数据领域实体(Phase 1)。 约定(AGENT.md §8/§9): - 行情时间用 trade_date;财务数据同时区分 report_date(报告期)与 announce_date(公告日) - 禁止以 report_date 作可见性依据 —— 只允许 announce_date 已过的数据进入研究 - 复权一律通过独立 AdjustFactor 表达,不在此层偷偷改前/后复权口径 """ from __future__ import annotations from datetime import date, datetime from decimal import Decimal from pydantic import BaseModel, ConfigDict, Field # 常见精度:价格 4 位小数;成交量(股) 2 位;金额(元) 2 位 PRICE_PLACES = Decimal("0.0001") AMOUNT_PLACES = Decimal("0.01") # ---- 研究面板数值列白名单(单一事实来源) ---- # 说明:研究装配(quant 层)与持久化列裁剪(infrastructure 层)必须用同一套列名, # 否则会出现「因子需要某列、仓储却拒绝」的隐蔽不一致。故在此统一定义,两处引用。 # 只影响数值列;symbol / trade_date 恒返回。 DAILY_BAR_NUMERIC_FIELDS: tuple[str, ...] = ("open", "high", "low", "close", "volume", "amount") DAILY_BASIC_NUMERIC_FIELDS: tuple[str, ...] = ( "close", "turnover_rate", "volume_ratio", "pe", "pe_ttm", "pb", "ps", "ps_ttm", "dv_ratio", "dv_ttm", "total_share", "float_share", "free_share", "total_mv", "circ_mv", ) class Stock(BaseModel): """A 股基础信息。symbol 统一为 Tushare 风格,如 600519.SH。""" model_config = ConfigDict(str_strip_whitespace=True) symbol: str = Field(pattern=r"^\d{6}\.(SH|SZ|BJ)$", description="如 600519.SH") name: str industry: str | None = None area: str | None = None market: str | None = Field(default=None, description="主板/创业板/科创板/北交所") exchange: str | None = None list_date: date delist_date: date | None = None status: str = Field(default="L", description="L 上市 / D 退市 / P 暂停") class TradingCalendar(BaseModel): """交易日历。""" calendar_date: date is_open: bool = True class DailyBar(BaseModel): """日线。默认不复权(source=tushare, adjust=none)。 备用源兜底行会标记 source=sina、adjust=qfq(新浪返回前复权价)。 字段统一、可区分、可追溯(AGENT §5.2/§8):研究侧应优先消费 source=tushare 且 adjust=none 的行;新浪行仅在 Tushare 不可用期间作为兜底, Tushare 恢复后重跑 --resume 会按日覆盖回不复权口径。 """ symbol: str trade_date: date source: str = Field(default="tushare", description="tushare | sina") adjust: str = Field(default="none", description="none 不复权 | qfq 前复权") open: Decimal | None = None high: Decimal | None = None low: Decimal | None = None close: Decimal | None = None volume: Decimal | None = Field(default=None, description="成交量(股)") amount: Decimal | None = Field(default=None, description="成交额(元)") @property def is_complete(self) -> bool: """基础行情字段是否齐全(供校验器使用)。""" return all( v is not None for v in (self.open, self.high, self.low, self.close, self.volume, self.amount) ) class AdjustFactor(BaseModel): """复权因子。因子原始口径由数据源决定,必须与数据源文档一致地存取。""" symbol: str trade_date: date factor: Decimal class DailyBasic(BaseModel): """每日指标快照(Tushare daily_basic)—— 估值 / 股息率 / 市值。 时点性说明(防未来函数,AGENT.md §9): - 本表每一行都是**该交易日收盘后**即可得的横截面指标(dv_ratio 由 「过去 12 个月现金分红 / 当日总市值」逐日重算),属时点值; - 研究侧一律按 trade_date <= as_of_date 取值,不存在未来信息。 列语义: - dv_ratio 股息率(%):近 12 个月现金分红 / 总市值 × 100 - dv_ttm 股息率(TTM,%):滚动 12 个月口径 - 两者均可能因**特别分红**出现畸高值(实测 600738 在 2020-01-02 为 37.2%), 使用时建议配合上限过滤。 """ symbol: str trade_date: date close: Decimal | None = Field(default=None, description="当日收盘价(不复权,与 stock_daily 一致)") turnover_rate: Decimal | None = Field(default=None, description="换手率(%)") volume_ratio: Decimal | None = Field(default=None, description="量比") pe: Decimal | None = None pe_ttm: Decimal | None = None pb: Decimal | None = None ps: Decimal | None = None ps_ttm: Decimal | None = None dv_ratio: Decimal | None = Field(default=None, description="股息率(%),近 12 个月现金分红/总市值") dv_ttm: Decimal | None = Field(default=None, description="股息率 TTM(%)") total_share: Decimal | None = Field(default=None, description="总股本(万股)") float_share: Decimal | None = Field(default=None, description="流通股本(万股)") free_share: Decimal | None = Field(default=None, description="自由流通股本(万股)") total_mv: Decimal | None = Field(default=None, description="总市值(万元)") circ_mv: Decimal | None = Field(default=None, description="流通市值(万元)") source: str = Field(default="tushare", description="tushare | sina(新浪不提供本接口)") class StockNameHistory(BaseModel): """股票名称变更历史(Tushare namechange)—— 时点 ST / 风险警示判定的依据。 为什么需要它(实测背景):`stock.name` 只是**最新名称快照**,用它做 `universe.exclude_st` 会把「曾为高股息、后来才变 ST/退市」的标的在**整段历史**里 都排除掉 —— 而那正是「股息陷阱」样本。实测 `600565.SH` 2020 年叫「迪马股份」 (dv_ratio 7.9%,当年高股息候选),2024-05-06 才变「ST迪马」,用最新名称判定 会在 2020 年就把它排除,导致高股息回测收益被高估(对照组实测约 3.70pp)。 一行 = 一个「名称生效区间」: - `name` 在 `[start_date, end_date]` 内有效(`end_date` 为空表示至今有效) - `change_reason` 为 tushare 口径:ST / *ST / 撤销ST / 撤销*ST / 从ST变为*ST / 其他 - 时点取值按**生效区间**:`start_date <= as_of <= end_date`(实现口径,无前视: 名称自 `start_date` 起即对市场可见)。`ann_date` 为公告日,仅作留痕/审计, **不参与**判定 —— 实测数据中 `ann_date` 恒早于或等于 `start_date`, 若改用「公告即改名」会让 `ann_date` 为空的记录整体丢失。 """ symbol: str name: str start_date: date end_date: date | None = None ann_date: date | None = None change_reason: str | None = None source: str = "tushare" @property def is_risk_warned(self) -> bool: """该区间名称是否含风险警示(ST / *ST)。""" return "ST" in self.name.upper() class FinancialIndicator(BaseModel): """核心财务指标(快照)。 可见性红线:研究侧查询一律按 announce_date <= as_of_date 过滤, report_date 只表示报告所属期间,不代表公开时间。 source 标记数据来源:tushare(首选,字段全)| sina(兜底,字段 可能不全——新浪关键指标只含 eps/roe/gross_margin 等少数项)。 新浪兜底行只在「该股票本地历史与新浪重叠部分两边一致」通过校验后 才导入(见 application/services/data_sync.py),且只补本地缺失键。 研究侧对同一报告期应优先消费 source=tushare 的行。 """ symbol: str report_date: date announce_date: date source: str = Field(default="tushare", description="tushare | sina") eps: Decimal | None = None roe: Decimal | None = None total_revenue: Decimal | None = None net_profit: Decimal | None = None gross_margin: Decimal | None = None def announced_by(self, as_of_date: date) -> bool: """as_of_date(含当日)是否已可见。防未来函数的核心判断。""" return self.announce_date <= as_of_date class SyncLog(BaseModel): """数据拉取审计记录(AGENT.md §7:来源必须可追踪,禁止静默切换)。""" source: str api: str request_time: datetime = Field(default_factory=datetime.utcnow) success: bool failure_reason: str | None = None row_count: int = 0 data_start: date | None = None data_end: date | None = None