feat(backend): Phase 1 数据层 — Domain / Provider / Failover 审计 + 持久化 + 同步 CLI
- domain:市场数据实体(Stock / 交易日历 / 日线 / 复权 / 财务含 announce_date)+ Repository 与 MarketDataProvider Protocol
- 数据源:TushareProvider(归一化、重试、鉴权错误归类)、SinaProvider(备用,明确前复权口径与能力边界)、FailoverProvider + SyncLog 审计(禁止静默切换)
- 持久化:SQLAlchemy 2.x Models + Repository 实现(按业务键幂等 upsert、as_of_date 防未来函数过滤)+ Alembic 迁移
- CLI:uv run python -m app.cli.sync {basic|calendar|daily|financial|verify},支持 --resume 断点续传
- 真实 Tushare 验证:stock 5556 / 交易日历 366 / daily+factor 242 / 财务 55;sync_log 审计完整
- 测试:38 passed(domain / provider / failover / repository / 未来函数 / 迁移),ruff clean
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""市场数据领域实体(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")
|
||||
|
||||
|
||||
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):
|
||||
"""不复权日线。复权请使用 AdjustFactor 在消费侧显式计算。"""
|
||||
|
||||
symbol: str
|
||||
trade_date: date
|
||||
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 FinancialIndicator(BaseModel):
|
||||
"""核心财务指标(快照)。
|
||||
|
||||
可见性红线:研究侧查询一律按 announce_date <= as_of_date 过滤,
|
||||
report_date 只表示报告所属期间,不代表公开时间。
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
report_date: date
|
||||
announce_date: date
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
"""MarketDataProvider(数据源抽象)。
|
||||
|
||||
业务层只依赖本 Protocol(AGENT.md §6),禁止在业务代码中 import
|
||||
tushare / 新浪实现。数据源一律返回 domain.entities 中的归一化实体。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.entities.market import (
|
||||
AdjustFactor,
|
||||
DailyBar,
|
||||
FinancialIndicator,
|
||||
Stock,
|
||||
TradingCalendar,
|
||||
)
|
||||
|
||||
|
||||
class MarketDataProvider(Protocol):
|
||||
"""统一市场数据源接口。
|
||||
|
||||
实现约定:
|
||||
- get_daily 返回**不复权**行情(复权经 AdjustFactor 显式计算,禁止静默改口径)
|
||||
- get_financial 返回带 announce_date 的指标,供上层按 as_of 过滤
|
||||
- 实现不得抛出裸连接异常以外的噪音;业务错误应转为 DataSourceError
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def get_stock_basic(self) -> list[Stock]: ...
|
||||
|
||||
def get_trade_cal(self, start: date, end: date) -> list[TradingCalendar]: ...
|
||||
|
||||
def get_daily(self, symbol: str, start: date, end: date) -> list[DailyBar]: ...
|
||||
|
||||
def get_adjust_factor(self, symbol: str, start: date, end: date) -> list[AdjustFactor]: ...
|
||||
|
||||
def get_financial(self, symbol: str) -> list[FinancialIndicator]: ...
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Repository Protocol(Phase 1 数据层)。
|
||||
|
||||
业务层只依赖这些 Protocol;具体实现位于 infrastructure/persistence。
|
||||
实体一律以 domain.entities 类型进出,禁止把 ORM Model 泄漏到上层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.entities.market import (
|
||||
AdjustFactor,
|
||||
DailyBar,
|
||||
FinancialIndicator,
|
||||
Stock,
|
||||
SyncLog,
|
||||
TradingCalendar,
|
||||
)
|
||||
|
||||
|
||||
class StockRepository(Protocol):
|
||||
def get_by_symbol(self, symbol: str) -> Stock | None: ...
|
||||
|
||||
def list(self) -> list[Stock]: ...
|
||||
|
||||
def upsert_many(self, stocks: Sequence[Stock]) -> int:
|
||||
"""批量写入,以 symbol 为幂等键,返回写入/更新的行数。"""
|
||||
|
||||
|
||||
class TradingCalendarRepository(Protocol):
|
||||
def upsert_many(self, days: Sequence[TradingCalendar]) -> int: ...
|
||||
|
||||
def list_range(self, start: date, end: date) -> list[TradingCalendar]: ...
|
||||
|
||||
def is_open(self, day: date) -> bool: ...
|
||||
|
||||
|
||||
class DailyBarRepository(Protocol):
|
||||
def upsert_many(self, bars: Sequence[DailyBar]) -> int: ...
|
||||
|
||||
def get_range(self, symbol: str, start: date, end: date) -> list[DailyBar]: ...
|
||||
|
||||
def latest_date(self, symbol: str) -> date | None:
|
||||
"""断点续传用:该股票本地已有数据的最新交易日。"""
|
||||
|
||||
|
||||
class AdjustFactorRepository(Protocol):
|
||||
def upsert_many(self, factors: Sequence[AdjustFactor]) -> int: ...
|
||||
|
||||
def get_range(self, symbol: str, start: date, end: date) -> list[AdjustFactor]: ...
|
||||
|
||||
|
||||
class FinancialRepository(Protocol):
|
||||
def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int: ...
|
||||
|
||||
def list_announced(
|
||||
self,
|
||||
symbol: str,
|
||||
as_of_date: date,
|
||||
report_start: date | None = None,
|
||||
) -> list[FinancialIndicator]:
|
||||
"""只返回 announce_date <= as_of_date 的记录 —— 未来函数红线。"""
|
||||
|
||||
|
||||
class SyncLogRepository(Protocol):
|
||||
def add(self, log: SyncLog) -> SyncLog: ...
|
||||
|
||||
def recent(self, source: str | None = None, limit: int = 20) -> list[SyncLog]: ...
|
||||
Reference in New Issue
Block a user