feat(data): 财务/日线同步增量 + 新浪「两边一致」校验兜底 + 逐只进度
- financial 默认增量:按 A 股披露节奏判断已最新并跳过;--full 强制全量重拉 - Tushare fina_indicator 增加报告期窗口与 100 条/请求自动分页(修复老报告期静默截断) - 新浪兜底收紧为校验兜底:两源重叠历史一致才导入缺失键,行标记 source=sina; 财务可比字段取 eps/销售毛利率(ROE 两端口径不同不作依据),日线只比较最近重叠交易日 - CLI 输出逐只进度与导入内容描述(来源/行数/报告期与公告区间),失败股票留待重跑 - financial_indicator 增 source 列(迁移 d3f6c9a21b04);新增一致性/分页/服务测试
This commit is contained in:
+174
-41
@@ -1,28 +1,43 @@
|
||||
"""Phase 1 数据同步 CLI(Tushare 首选 → SQLite)。
|
||||
"""Phase 1 数据同步 CLI(Tushare 首选 → SQLite,新浪校验兜底)。
|
||||
|
||||
用法(cd backend):
|
||||
uv run python -m app.cli.sync basic
|
||||
uv run python -m app.cli.sync calendar --start 20240101 --end 20241231
|
||||
uv run python -m app.cli.sync daily --symbols 600519.SH,000001.SZ --start 20240101
|
||||
uv run python -m app.cli.sync daily --all --start 20240101 # 全市场
|
||||
uv run python -m app.cli.sync financial --all
|
||||
uv run python -m app.cli.sync financial --all # 财务指标(增量)
|
||||
uv run python -m app.cli.sync financial --all --full # 财务指标(强制全量重拉)
|
||||
uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证
|
||||
|
||||
增量与兜底:
|
||||
- daily --resume:从本地最新交易日续传(已有);Tushare 失败时走新浪校验兜底,
|
||||
只有「两源重叠历史一致」才用新浪补本地缺失交易日(source=sina/前复权)。
|
||||
- financial:默认增量——本地已含最新应披露报告期则跳过;Tushare 失败时新浪
|
||||
数据须通过「两边一致」校验(重叠报告期 eps/销售毛利率逐期一致)才允许补入
|
||||
本地缺失键(source=sina)。失败股票留待下轮重跑补齐,不会静默导入未核验数据。
|
||||
- 每次拉取写入 sync_log 审计(来源 / 成功与否 / 行数 / 区间),禁止静默切源。
|
||||
|
||||
本模块是组装层(composition root):在此装配 Provider / Repository / Session,
|
||||
业务层代码仍只依赖抽象(domain.repositories / domain.providers)。
|
||||
业务逻辑在 application.services.data_sync,业务层仍只依赖抽象。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.services.data_sync import (
|
||||
DailySymbolResult,
|
||||
FinancialSymbolResult,
|
||||
VerifiedDailySyncer,
|
||||
VerifiedFinancialSyncer,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.infrastructure.data_sources.errors import DataSourceError
|
||||
from app.infrastructure.data_sources.failover import FailoverProvider
|
||||
from app.infrastructure.data_sources.sina import SinaProvider
|
||||
from app.infrastructure.data_sources.tushare import TushareProvider
|
||||
from app.infrastructure.persistence.sqlalchemy.models.market import StockModel
|
||||
@@ -44,12 +59,12 @@ def _parse_day(text: str) -> date:
|
||||
|
||||
|
||||
def _failover_provider(session):
|
||||
"""Tushare 首选 + 新浪兜底(FailoverProvider 每次尝试写 sync_log,AGENT.md §7)。
|
||||
"""Tushare 首选 + 新浪兜底(basic/calendar 用;daily/financial 走校验兜底服务)。
|
||||
|
||||
能力矩阵:新浪仅提供日线(前复权,标记 source=sina/adjust=qfq);其余接口
|
||||
(adj_factor/financial/basic/calendar)新浪不支持 → 抛错保留单源语义,日志可见。
|
||||
Tushare 恢复后重跑 --resume 会把新浪兜底日按同键覆盖回不复权口径。
|
||||
FailoverProvider 每次尝试写 sync_log(AGENT.md §7)。能力矩阵:新浪仅提供
|
||||
日线/财务,basic/calendar 新浪不支持 → 抛错保留单源语义,日志可见。
|
||||
"""
|
||||
from app.infrastructure.data_sources.failover import FailoverProvider
|
||||
from app.infrastructure.data_sources.sina import SinaProvider
|
||||
|
||||
audit_repo = SqlAlchemySyncLogRepository(session)
|
||||
@@ -97,6 +112,23 @@ def _symbols_of(args) -> list[str]:
|
||||
return [s.strip() for s in args.symbols.split(",") if s.strip()]
|
||||
|
||||
|
||||
def _stock_names(session, symbols: list[str]) -> dict[str, str]:
|
||||
"""一次性取出股票名称(进度描述用);批量查询避开 SQLite 变量上限。"""
|
||||
names: dict[str, str] = {}
|
||||
for i in range(0, len(symbols), 500):
|
||||
chunk = symbols[i : i + 500]
|
||||
rows = session.execute(
|
||||
select(StockModel.symbol, StockModel.name).where(StockModel.symbol.in_(chunk))
|
||||
)
|
||||
names.update({sym: nm for sym, nm in rows})
|
||||
return names
|
||||
|
||||
|
||||
def _warn_notes(notes: list[str]) -> None:
|
||||
for note in notes:
|
||||
print(f" [warn] {note}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_daily(args) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
@@ -105,12 +137,20 @@ def cmd_daily(args) -> int:
|
||||
symbols = _symbols_of(args)
|
||||
start = _parse_day(args.start) if args.start else date(2005, 1, 1)
|
||||
end = _parse_day(args.end) if args.end else date.today()
|
||||
total = 0
|
||||
skipped = 0
|
||||
started = time.monotonic()
|
||||
n_ok = n_sina = n_failed = n_skip = 0
|
||||
rows_tushare = rows_sina = 0
|
||||
with _session_ctx() as session:
|
||||
provider = _failover_provider(session)
|
||||
names = _stock_names(session, symbols)
|
||||
audit = SqlAlchemySyncLogRepository(session).add
|
||||
syncer = VerifiedDailySyncer(
|
||||
primary=TushareProvider(token=get_settings().tushare_token),
|
||||
fallback=SinaProvider(),
|
||||
bars=SqlAlchemyDailyBarRepository(session),
|
||||
factors=SqlAlchemyAdjustFactorRepository(session),
|
||||
audit=audit,
|
||||
)
|
||||
bar_repo = SqlAlchemyDailyBarRepository(session)
|
||||
factor_repo = SqlAlchemyAdjustFactorRepository(session)
|
||||
# 增量基准:本地数据已到该日期即视为「已最新」,resume 时不再调 API
|
||||
global_latest = (
|
||||
session.scalar(select(func.max(StockDailyModel.trade_date))) if args.resume else None
|
||||
@@ -121,43 +161,119 @@ def cmd_daily(args) -> int:
|
||||
latest = bar_repo.latest_date(symbol)
|
||||
if latest is not None:
|
||||
if global_latest is not None and latest >= global_latest:
|
||||
skipped += 1 # 已同步到本地最新交易日,无需续拉
|
||||
n_skip += 1 # 已同步到本地最新交易日,无需续拉
|
||||
continue
|
||||
begin = max(begin, latest + timedelta(days=1))
|
||||
if begin > end:
|
||||
skipped += 1 # 无待拉区间(如区间已含在本地)
|
||||
n_skip += 1 # 无待拉区间(如区间已含在本地)
|
||||
continue
|
||||
try:
|
||||
bars = provider.get_daily(symbol, begin, end)
|
||||
factors = provider.get_adjust_factor(symbol, begin, end)
|
||||
bar_repo.upsert_many(bars)
|
||||
factor_repo.upsert_many(factors)
|
||||
total += len(bars)
|
||||
if i % 100 == 0:
|
||||
session.commit()
|
||||
print(f" ... {i}/{len(symbols)} {symbol} 累计 {total} 根")
|
||||
except DataSourceError as exc:
|
||||
print(f" [warn] {symbol} 拉取失败: {exc}", file=sys.stderr)
|
||||
session.commit()
|
||||
detail = f"[daily] {len(symbols)} 只股票合计写入 {total} 根日线(含复权因子)"
|
||||
if getattr(args, "sleep", 0) > 0:
|
||||
time.sleep(args.sleep)
|
||||
res: DailySymbolResult = syncer.sync_symbol(symbol, begin, end)
|
||||
session.commit() # 逐只落库:中断/报错只丢当前一只,重跑增量续传
|
||||
if res.status == "ok":
|
||||
n_ok += 1
|
||||
rows_tushare += res.bars_written
|
||||
elif res.status == "sina":
|
||||
n_sina += 1
|
||||
rows_sina += res.bars_written
|
||||
elif res.status == "failed":
|
||||
n_failed += 1
|
||||
_warn_notes(res.notes)
|
||||
if i % 100 == 0:
|
||||
name = names.get(symbol, "")
|
||||
print(
|
||||
f" ... {i}/{len(symbols)} {symbol} {name}: "
|
||||
f"累计 tushare {rows_tushare} 根 + 新浪补缺 {rows_sina} 根;"
|
||||
f"成功 {n_ok} / 新浪 {n_sina} / 失败待重试 {n_failed}"
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
detail = (
|
||||
f"[daily] {len(symbols)} 只股票:成功 {n_ok} / 新浪校验补缺 {n_sina} / "
|
||||
f"已最新跳过 {n_skip} / 失败待重试 {n_failed}"
|
||||
)
|
||||
if args.resume:
|
||||
detail += f";增量跳过(已最新){skipped} 只(本地最新 {global_latest})"
|
||||
detail += f"(本地最新 {global_latest})"
|
||||
detail += f";写入 {rows_tushare} 根(tushare 不复权)+ {rows_sina} 根(sina 前复权),耗时 {elapsed:.0f}s"
|
||||
print(detail)
|
||||
return 0
|
||||
|
||||
|
||||
def _fin_progress_line(i: int, n: int, symbol: str, name: str, res: FinancialSymbolResult) -> str:
|
||||
"""financial 逐只进度行:结果 + 导入内容简单描述(报告期/公告区间、来源)。"""
|
||||
head = f"[financial {i}/{n}] {symbol} {name or ''}".rstrip()
|
||||
if res.status == "skip":
|
||||
return f"{head}:已最新,跳过(增量)"
|
||||
if res.status == "failed":
|
||||
return f"{head}:失败待重试(tushare 失败;新浪源 {'未通过校验' if res.source == 'sina' else '不可用'})"
|
||||
if res.status == "sina":
|
||||
return (
|
||||
f"{head}:tushare 失败 → 新浪校验通过,补入 {res.written} 行(source=sina)"
|
||||
+ _fin_span(res)
|
||||
)
|
||||
# status == ok(tushare 成功)
|
||||
if res.written:
|
||||
updated = f",覆盖更新 {res.updated} 行" if res.updated else ""
|
||||
return f"{head}:tushare 返回 {res.fetched} 行 → 新增 {res.written} 行{updated}" + _fin_span(res)
|
||||
return f"{head}:tushare 返回 {res.fetched} 行,均已在库,无新增"
|
||||
|
||||
|
||||
def _fin_span(res: FinancialSymbolResult) -> str:
|
||||
if not res.written or res.report_first is None:
|
||||
return ""
|
||||
if res.announce_first is None or res.announce_last is None:
|
||||
return ""
|
||||
return (
|
||||
f";报告期 {res.report_first.isoformat()}~{res.report_last.isoformat()}"
|
||||
f"(公告 {res.announce_first.isoformat()}~{res.announce_last.isoformat()})"
|
||||
)
|
||||
|
||||
|
||||
def cmd_financial(args) -> int:
|
||||
symbols = _symbols_of(args)
|
||||
total = 0
|
||||
started = time.monotonic()
|
||||
n_ok = n_sina = n_failed = n_skip = 0
|
||||
rows_tushare = rows_sina = 0
|
||||
with _session_ctx() as session:
|
||||
provider = _failover_provider(session)
|
||||
fin_repo = SqlAlchemyFinancialRepository(session)
|
||||
for symbol in symbols:
|
||||
rows = provider.get_financial(symbol)
|
||||
fin_repo.upsert_many(rows)
|
||||
total += len(rows)
|
||||
session.commit()
|
||||
print(f"[financial] {len(symbols)} 只股票合计写入 {total} 条财务指标快照")
|
||||
names = _stock_names(session, symbols)
|
||||
audit = SqlAlchemySyncLogRepository(session).add
|
||||
syncer = VerifiedFinancialSyncer(
|
||||
primary=TushareProvider(token=get_settings().tushare_token),
|
||||
fallback=SinaProvider(),
|
||||
repo=SqlAlchemyFinancialRepository(session),
|
||||
audit=audit,
|
||||
)
|
||||
for i, symbol in enumerate(symbols, start=1):
|
||||
if getattr(args, "sleep", 0) > 0:
|
||||
time.sleep(args.sleep)
|
||||
res: FinancialSymbolResult = syncer.sync_symbol(symbol, force_full=args.full)
|
||||
session.commit() # 逐只落库:中断只丢当前一只,重跑增量续传
|
||||
print(_fin_progress_line(i, len(symbols), symbol, names.get(symbol, ""), res))
|
||||
_warn_notes(res.notes)
|
||||
if res.status == "ok":
|
||||
n_ok += 1
|
||||
rows_tushare += res.written
|
||||
elif res.status == "sina":
|
||||
n_sina += 1
|
||||
rows_sina += res.written
|
||||
elif res.status == "failed":
|
||||
n_failed += 1
|
||||
elif res.status == "skip":
|
||||
n_skip += 1
|
||||
elapsed = time.monotonic() - started
|
||||
mode = "全量重拉(--full)" if args.full else "增量"
|
||||
print(
|
||||
f"[financial] 共 {len(symbols)} 只({mode}):成功 {n_ok} / 新浪校验兜底 {n_sina} / "
|
||||
f"已最新跳过 {n_skip} / 失败待重试 {n_failed};"
|
||||
f"合计写入 {rows_tushare + rows_sina} 行(tushare {rows_tushare} + sina {rows_sina}),"
|
||||
f"耗时 {elapsed:.0f}s"
|
||||
)
|
||||
if n_failed:
|
||||
print(
|
||||
" [tip] 失败股票未写入未核验数据,重跑本命令即可续传补齐;"
|
||||
"若因频率超限,可用 --sleep 加大间隔(如 --sleep 60)分多次跑。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -253,17 +369,34 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_cal.add_argument("--end", required=True, help="YYYYMMDD")
|
||||
p_cal.set_defaults(func=cmd_calendar)
|
||||
|
||||
p_daily = sub.add_parser("daily", help="同步日线与复权因子")
|
||||
p_daily = sub.add_parser("daily", help="同步日线与复权因子(Tushare 失败 → 新浪校验兜底补缺)")
|
||||
p_daily.add_argument("--symbols", default="", help="600519.SH,000001.SZ")
|
||||
p_daily.add_argument("--all", action="store_true", help="遍历 stock 表全部股票")
|
||||
p_daily.add_argument("--start", default="", help="YYYYMMDD(默认 20050101)")
|
||||
p_daily.add_argument("--end", default="", help="YYYYMMDD(默认今天)")
|
||||
p_daily.add_argument("--resume", action="store_true", help="从本地最新交易日续传")
|
||||
p_daily.add_argument("--resume", action="store_true", help="从本地最新交易日续传(增量)")
|
||||
p_daily.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0,
|
||||
help="每只股票请求间隔秒数(限速时加大,如 1 或 60)",
|
||||
)
|
||||
p_daily.set_defaults(func=cmd_daily)
|
||||
|
||||
p_fin = sub.add_parser("financial", help="同步财务指标快照")
|
||||
p_fin = sub.add_parser("financial", help="同步财务指标快照(默认增量;Tushare 失败 → 新浪校验兜底)")
|
||||
p_fin.add_argument("--symbols", default="")
|
||||
p_fin.add_argument("--all", action="store_true")
|
||||
p_fin.add_argument("--all", action="store_true", help="遍历 stock 表全部股票")
|
||||
p_fin.add_argument(
|
||||
"--full",
|
||||
action="store_true",
|
||||
help="强制全量重拉并覆盖既有行(默认只补本地缺失/更新的报告期,已最新跳过)",
|
||||
)
|
||||
p_fin.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0,
|
||||
help="每只股票请求间隔秒数(限速时加大,如 1 或 60)",
|
||||
)
|
||||
p_fin.set_defaults(func=cmd_financial)
|
||||
|
||||
p_verify = sub.add_parser("verify", help="新浪交叉验证最新行情")
|
||||
|
||||
Reference in New Issue
Block a user