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:
Simon
2026-09-06 16:59:28 +08:00
parent 7a89d97c0b
commit 2da234220a
23 changed files with 2666 additions and 1 deletions
@@ -0,0 +1,215 @@
"""domain.repositories.market 的 SQLAlchemy 实现。
约定:本目录是唯一允许把 ORM 与业务实体互转的地方;
Repository 以 domain.entities 类型进出(AGENT.md §10)。
幂等键写在 __table_args__ 的 UniqueConstraint 上,upsert 先查后写,
与 SQLite / MySQL 方言无关(未来切库不改业务层)。
"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.domain.entities.market import (
AdjustFactor,
DailyBar,
FinancialIndicator,
Stock,
SyncLog,
TradingCalendar,
)
from app.infrastructure.persistence.sqlalchemy.models.market import (
AdjustFactorModel,
FinancialIndicatorModel,
StockDailyModel,
StockModel,
SyncLogModel,
TradingCalendarModel,
)
# 实体类型 → (ORM Model, 幂等键列)
_TABLE = {
Stock: (StockModel, ["symbol"]),
TradingCalendar: (TradingCalendarModel, ["calendar_date"]),
DailyBar: (StockDailyModel, ["symbol", "trade_date"]),
AdjustFactor: (AdjustFactorModel, ["symbol", "trade_date"]),
FinancialIndicator: (FinancialIndicatorModel, ["symbol", "report_date", "announce_date"]),
SyncLog: (SyncLogModel, ["id"]),
}
_ENTITY_TO_MODEL = {entity: model for entity, (model, _keys) in _TABLE.items()}
def _fields_of(entity) -> dict:
"""实体字段 → ORM 列名(模型列名与实体字段一致)。"""
return {k: v for k, v in entity.model_dump().items() if k != "id"}
def _upsert_by_business_key(
session: Session,
entity_cls,
entities: Sequence,
) -> int:
"""按业务幂等键查重后 insert/update,返回触及行数(新增+更新)。
同一批内出现重复键(数据源偶发)时:先 flush 使前面已 add 的行可见,
再按「后出现者覆盖」更新为最新值,避免 UNIQUE 冲突。
"""
model_cls, key_cols = _TABLE[entity_cls]
seen: set[tuple] = set()
touched = 0
for ent in entities:
values = _fields_of(ent)
key = tuple(values[k] for k in key_cols)
if key in seen:
session.flush() # 让本批内先前新增的行进入 select 视野
else:
seen.add(key)
filters = [getattr(model_cls, k) == values[k] for k in key_cols]
row = session.scalars(select(model_cls).where(*filters)).first()
if row is None:
session.add(model_cls(**values))
else:
for col, val in values.items():
setattr(row, col, val)
touched += 1
return touched
class SqlAlchemyStockRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get_by_symbol(self, symbol: str) -> Stock | None:
row = self._session.scalars(select(StockModel).where(StockModel.symbol == symbol)).first()
return Stock.model_validate(row.__dict__, from_attributes=True) if row else None
def list(self) -> list[Stock]:
rows = self._session.scalars(select(StockModel).order_by(StockModel.symbol)).all()
return [Stock.model_validate(r, from_attributes=True) for r in rows]
def upsert_many(self, stocks: Sequence[Stock]) -> int:
return _upsert_by_business_key(self._session, Stock, stocks)
class SqlAlchemyTradingCalendarRepository:
def __init__(self, session: Session) -> None:
self._session = session
def upsert_many(self, days: Sequence[TradingCalendar]) -> int:
return _upsert_by_business_key(self._session, TradingCalendar, days)
def list_range(self, start: date, end: date) -> list[TradingCalendar]:
rows = self._session.scalars(
select(TradingCalendarModel)
.where(
TradingCalendarModel.calendar_date >= start,
TradingCalendarModel.calendar_date <= end,
)
.order_by(TradingCalendarModel.calendar_date)
).all()
return [TradingCalendar.model_validate(r, from_attributes=True) for r in rows]
def is_open(self, day: date) -> bool:
row = self._session.scalars(
select(TradingCalendarModel).where(TradingCalendarModel.calendar_date == day)
).first()
return bool(row.is_open) if row else False
class SqlAlchemyDailyBarRepository:
def __init__(self, session: Session) -> None:
self._session = session
def upsert_many(self, bars: Sequence[DailyBar]) -> int:
return _upsert_by_business_key(self._session, DailyBar, bars)
def get_range(self, symbol: str, start: date, end: date) -> list[DailyBar]:
rows = self._session.scalars(
select(StockDailyModel)
.where(
StockDailyModel.symbol == symbol,
StockDailyModel.trade_date >= start,
StockDailyModel.trade_date <= end,
)
.order_by(StockDailyModel.trade_date)
).all()
return [DailyBar.model_validate(r, from_attributes=True) for r in rows]
def latest_date(self, symbol: str) -> date | None:
return self._session.scalar(
select(StockDailyModel.trade_date)
.where(StockDailyModel.symbol == symbol)
.order_by(StockDailyModel.trade_date.desc())
.limit(1)
)
class SqlAlchemyAdjustFactorRepository:
def __init__(self, session: Session) -> None:
self._session = session
def upsert_many(self, factors: Sequence[AdjustFactor]) -> int:
return _upsert_by_business_key(self._session, AdjustFactor, factors)
def get_range(self, symbol: str, start: date, end: date) -> list[AdjustFactor]:
rows = self._session.scalars(
select(AdjustFactorModel)
.where(
AdjustFactorModel.symbol == symbol,
AdjustFactorModel.trade_date >= start,
AdjustFactorModel.trade_date <= end,
)
.order_by(AdjustFactorModel.trade_date)
).all()
return [AdjustFactor.model_validate(r, from_attributes=True) for r in rows]
class SqlAlchemyFinancialRepository:
def __init__(self, session: Session) -> None:
self._session = session
def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int:
return _upsert_by_business_key(self._session, FinancialIndicator, rows)
def list_announced(
self,
symbol: str,
as_of_date: date,
report_start: date | None = None,
) -> list[FinancialIndicator]:
"""只返回 announce_date <= as_of_date —— 防未来函数红线实现。"""
stmt = (
select(FinancialIndicatorModel)
.where(
FinancialIndicatorModel.symbol == symbol,
FinancialIndicatorModel.announce_date <= as_of_date,
)
.order_by(FinancialIndicatorModel.announce_date)
)
if report_start is not None:
stmt = stmt.where(FinancialIndicatorModel.report_date >= report_start)
rows = self._session.scalars(stmt).all()
return [FinancialIndicator.model_validate(r, from_attributes=True) for r in rows]
class SqlAlchemySyncLogRepository:
def __init__(self, session: Session) -> None:
self._session = session
def add(self, log: SyncLog) -> SyncLog:
model = SyncLogModel(**log.model_dump())
self._session.add(model)
self._session.flush()
return SyncLog.model_validate(model, from_attributes=True)
def recent(self, source: str | None = None, limit: int = 20) -> list[SyncLog]:
stmt = select(SyncLogModel).order_by(SyncLogModel.id.desc()).limit(limit)
if source is not None:
stmt = stmt.where(SyncLogModel.source == source)
rows = self._session.scalars(stmt).all()
return [SyncLog.model_validate(r, from_attributes=True) for r in rows]