as-event/backend/app/sources.py

203 lines
6.4 KiB
Python
Raw Permalink Normal View History

2026-07-27 09:05:52 +08:00
"""外部数据源适配(直连现有内网库,只读)+ 可插拔 OHLC Provider。
- LegacyIndexSource zs_day_data(MySQL-A 18.199) 的指数 close/percent/amount
- LegacySentimentSource gp_market_sentiment(PG 16.150) 的情绪
- OHLCProvider 指数开高低收源你后续提供默认 NullOHLCProvider = close_only 退化
所有源在 DSN 未配置或连接失败时**降级返回空**绝不让 ETL 崩掉
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Dict, List, Optional, Protocol
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from .config import get_settings
log = logging.getLogger("as-event.sources")
settings = get_settings()
# ---- 惰性引擎(只在配置了 DSN 时创建)----
_engines: Dict[str, Optional[Engine]] = {}
def _engine_for(dsn: Optional[str]) -> Optional[Engine]:
if not dsn:
return None
if dsn not in _engines:
try:
_engines[dsn] = create_engine(dsn, pool_pre_ping=True, future=True)
except Exception as e: # noqa: BLE001
log.warning("创建引擎失败 dsn=%s err=%s", _mask(dsn), e)
_engines[dsn] = None
return _engines[dsn]
def _mask(dsn: str) -> str:
"""隐藏 DSN 中的密码用于日志。"""
try:
head, tail = dsn.split("@", 1)
scheme_user = head.split("//", 1)
return f"{scheme_user[0]}//***@{tail}"
except Exception: # noqa: BLE001
return "<dsn>"
def _to_float(v) -> Optional[float]:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
# ============================ 数据行 ============================
@dataclass
class IndexBar:
trade_date: date
close: Optional[float]
amount: Optional[float] = None
pct_chg: Optional[float] = None
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
volume: Optional[int] = None
@dataclass
class SentimentRow:
trade_date: date
up_down_ratio: Optional[float] = None
median_pct_chg: Optional[float] = None
pct_chg_gt_5_count: Optional[int] = None
# ============================ 现有库读适配 ============================
class LegacyIndexSource:
"""读 zs_day_dataMySQL-A 18.199。字段symbol, timestamp, close, percent, amount。"""
def __init__(self) -> None:
self.engine = _engine_for(settings.legacy_mysql_dsn)
@property
def available(self) -> bool:
return self.engine is not None
def fetch(self, index_code: str, start: date, end: date) -> List[IndexBar]:
if not self.engine:
log.info("LEGACY_MYSQL_DSN 未配置,跳过指数拉取 %s", index_code)
return []
sql = text(
"""
SELECT DATE(`timestamp`) AS d, `close` AS c, `percent` AS p, `amount` AS a
FROM zs_day_data
WHERE symbol = :code AND DATE(`timestamp`) BETWEEN :s AND :e
ORDER BY d ASC
"""
)
try:
with self.engine.connect() as conn:
rows = conn.execute(sql, {"code": index_code, "s": start, "e": end}).all()
except Exception as e: # noqa: BLE001
log.warning("读 zs_day_data 失败 %s: %s", index_code, e)
return []
return [
IndexBar(
trade_date=r.d,
close=_to_float(r.c),
amount=_to_float(r.a),
pct_chg=_to_float(r.p),
)
for r in rows
]
class LegacySentimentSource:
"""读 gp_market_sentimentPG 16.150。字段trade_date, up_down_ratio, median_pct_chg, pct_chg_gt_5_count。"""
def __init__(self) -> None:
self.engine = _engine_for(settings.legacy_pg_dsn)
@property
def available(self) -> bool:
return self.engine is not None
def fetch(self, start: date, end: date) -> List[SentimentRow]:
if not self.engine:
log.info("LEGACY_PG_DSN 未配置,跳过情绪拉取")
return []
sql = text(
"""
SELECT trade_date, up_down_ratio, median_pct_chg, pct_chg_gt_5_count
FROM gp_market_sentiment
WHERE trade_date BETWEEN :s AND :e
ORDER BY trade_date ASC
"""
)
try:
with self.engine.connect() as conn:
rows = conn.execute(sql, {"s": start, "e": end}).all()
except Exception as e: # noqa: BLE001
log.warning("读 gp_market_sentiment 失败: %s", e)
return []
out: List[SentimentRow] = []
for r in rows:
cnt = r.pct_chg_gt_5_count
out.append(
SentimentRow(
trade_date=r.trade_date,
up_down_ratio=_to_float(r.up_down_ratio),
median_pct_chg=_to_float(r.median_pct_chg),
pct_chg_gt_5_count=int(cnt) if cnt is not None else None,
)
)
return out
# ============================ 可插拔 OHLC Provider ============================
class OHLCProvider(Protocol):
"""指数开高低收源接口。你接入自己的数据源时实现此协议。"""
name: str
def fetch(self, index_code: str, start: date, end: date) -> Dict[date, IndexBar]:
"""返回 {trade_date: IndexBar(带 open/high/low/close[/volume])}。"""
...
class NullOHLCProvider:
"""默认:无 OHLC 源。ETL 会用 close 兜底成 open=high=low=close标 ohlc_source='close_only'"""
name = "none"
def fetch(self, index_code: str, start: date, end: date) -> Dict[date, IndexBar]:
return {}
# ------- 你后续接入 OHLC 源的示例骨架(改名注册即可)-------
# class MyOHLCProvider:
# name = "myprovider"
# def fetch(self, index_code, start, end):
# # 调你自己的接口/库表,返回 {date: IndexBar(open/high/low/close/volume)}
# return {}
_OHLC_REGISTRY = {
NullOHLCProvider.name: NullOHLCProvider,
# MyOHLCProvider.name: MyOHLCProvider, # <-- 接入后取消注释
}
def build_ohlc_provider() -> OHLCProvider:
name = settings.ohlc_provider
cls = _OHLC_REGISTRY.get(name)
if cls is None:
log.warning("未知 OHLC_PROVIDER=%s,回退 none收盘线", name)
cls = NullOHLCProvider
return cls()