as-event/backend/app/etl.py

170 lines
5.8 KiB
Python
Raw Normal View History

2026-07-27 09:05:52 +08:00
"""ETL把现有内网库数据同步进自有库。
python -m app.etl init-db
python -m app.etl sync-index --start 20150101 [--end 20260726] [--codes 000001.SH,399006.SZ]
python -m app.etl sync-sentiment --start 20150101
python -m app.etl sync-all --start 20150101
设计看板只读自有库此处是唯一读现有库写自有库的地方
内网库不可达时降级为空同步不报错
"""
from __future__ import annotations
import argparse
import logging
from datetime import date, datetime
from typing import Dict, List, Optional
from sqlalchemy.dialects.postgresql import insert as pg_insert
from .config import get_settings
from .db import IndexDaily, SentimentDaily, engine, init_db
from .sources import (
IndexBar,
LegacyIndexSource,
LegacySentimentSource,
build_ohlc_provider,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("as-event.etl")
settings = get_settings()
def _parse_date(s: str) -> date:
s = s.strip()
for fmt in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise ValueError(f"无法解析日期: {s}(用 YYYYMMDD 或 YYYY-MM-DD")
# ============================ 指数同步 ============================
def sync_index(start: date, end: date, codes: Optional[List[str]] = None) -> Dict[str, int]:
codes = codes or settings.index_code_list
legacy = LegacyIndexSource()
ohlc = build_ohlc_provider()
result: Dict[str, int] = {}
for code in codes:
bars = legacy.fetch(code, start, end) # close/amount/pct
ohlc_map = ohlc.fetch(code, start, end) # 可能为空
by_date: Dict[date, IndexBar] = {b.trade_date: b for b in bars}
# 若 OHLC 源提供了 close 而现有库没有该日,也纳入
for d, ob in ohlc_map.items():
by_date.setdefault(d, IndexBar(trade_date=d, close=ob.close))
rows = []
for d, b in sorted(by_date.items()):
ob = ohlc_map.get(d)
close = b.close if b.close is not None else (ob.close if ob else None)
if ob and ob.open is not None:
o, h, l = ob.open, ob.high, ob.low
vol = ob.volume
src = ohlc.name
else:
o = h = l = close # 收盘兜底
vol = b.volume
src = "close_only"
rows.append(
dict(
index_code=code, trade_date=d, open=o, high=h, low=l, close=close,
volume=vol, amount=b.amount, pct_chg=b.pct_chg,
turnover_rate=None, ohlc_source=src,
)
)
_upsert_index(rows)
result[code] = len(rows)
log.info("指数同步 %s: %d 行 (ohlc=%s)", code, len(rows), ohlc.name)
return result
def _upsert_index(rows: List[dict]) -> None:
if not rows:
return
stmt = pg_insert(IndexDaily).values(rows)
stmt = stmt.on_conflict_do_update(
constraint="uq_index_daily",
set_={
"open": stmt.excluded.open, "high": stmt.excluded.high,
"low": stmt.excluded.low, "close": stmt.excluded.close,
"volume": stmt.excluded.volume, "amount": stmt.excluded.amount,
"pct_chg": stmt.excluded.pct_chg, "turnover_rate": stmt.excluded.turnover_rate,
"ohlc_source": stmt.excluded.ohlc_source,
},
)
with engine.begin() as conn:
conn.execute(stmt)
# ============================ 情绪同步 ============================
def sync_sentiment(start: date, end: date) -> int:
legacy = LegacySentimentSource()
rows = legacy.fetch(start, end)
payload = [
dict(
trade_date=r.trade_date,
up_down_ratio=r.up_down_ratio,
median_pct_chg=r.median_pct_chg,
pct_chg_gt_5_count=r.pct_chg_gt_5_count,
)
for r in rows
]
_upsert_sentiment(payload)
log.info("情绪同步: %d", len(payload))
return len(payload)
def _upsert_sentiment(rows: List[dict]) -> None:
if not rows:
return
stmt = pg_insert(SentimentDaily).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=["trade_date"],
set_={
"up_down_ratio": stmt.excluded.up_down_ratio,
"median_pct_chg": stmt.excluded.median_pct_chg,
"pct_chg_gt_5_count": stmt.excluded.pct_chg_gt_5_count,
},
)
with engine.begin() as conn:
conn.execute(stmt)
# ============================ CLI ============================
def main() -> None:
p = argparse.ArgumentParser(description="as-event ETL")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("init-db", help="建表(等价 ddl/own_db_init.sql")
for name in ("sync-index", "sync-sentiment", "sync-all"):
sp = sub.add_parser(name)
sp.add_argument("--start", default="20150101")
sp.add_argument("--end", default=date.today().strftime("%Y%m%d"))
if name in ("sync-index", "sync-all"):
sp.add_argument("--codes", default=None, help="逗号分隔,缺省用配置")
args = p.parse_args()
if args.cmd == "init-db":
init_db()
log.info("建表完成")
return
start, end = _parse_date(args.start), _parse_date(args.end)
codes = args.codes.split(",") if getattr(args, "codes", None) else None
if args.cmd == "sync-index":
sync_index(start, end, codes)
elif args.cmd == "sync-sentiment":
sync_sentiment(start, end)
elif args.cmd == "sync-all":
sync_index(start, end, codes)
sync_sentiment(start, end)
if __name__ == "__main__":
main()