203 lines
6.4 KiB
Python
203 lines
6.4 KiB
Python
|
|
"""外部数据源适配(直连现有内网库,只读)+ 可插拔 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_data(MySQL-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_sentiment(PG 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()
|