feat: 量化引擎加固 — 新增测试 + 数据/因子/回测层优化
- 新增 finance/tests/ 6 个测试套件(agents/backtest/dao_upsert/factors/features/fundamental_lookahead) - 数据层: data_manager / dao 优化,新增 upsert 逻辑 - 因子层: 基本面因子抽象定位 _mapping、ROE/PE/PB 重构 - 回测层: vectorbt/engine 大改动(251 行),report 增强 - ML 层: features/backtest_integration 特征工程与回测优化 - CLI: agent_cli 重构 - config/settings 扩充配置项
This commit is contained in:
@@ -5,6 +5,7 @@ DataManager — 统一数据管理层。
|
||||
优先从 DB 读取,缺失时依次尝试 AkShare → Tushare 拉取并入库。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import pandas as pd
|
||||
@@ -15,6 +16,27 @@ from data.sources.tushare_source import TushareSource
|
||||
from database import dao
|
||||
from database.models import create_all_tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _rollback_days(yyyymmdd: str, days: int) -> str:
|
||||
"""把 'YYYYMMDD' 往前回退 days 个自然日,返回 'YYYYMMDD'。"""
|
||||
from datetime import datetime, timedelta
|
||||
dt = datetime.strptime(yyyymmdd, "%Y%m%d") - timedelta(days=days)
|
||||
return dt.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _safe_save_daily(df: pd.DataFrame, skip_exc: bool = True) -> None:
|
||||
"""把拉取到的日线入库;失败时记日志(可选抛错)。"""
|
||||
cols = [c for c in dao._DAILY_COLS if c in df.columns]
|
||||
try:
|
||||
dao.save_daily(df[cols])
|
||||
except Exception as e:
|
||||
if skip_exc:
|
||||
logger.exception("[DataManager] 日线入库失败(数据已获取,未持久化):%s", e)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
class DataManager:
|
||||
"""统一数据管理。双数据源:AkShare(主)+ Tushare(备)。"""
|
||||
@@ -68,7 +90,7 @@ class DataManager:
|
||||
if df is not None and not df.empty:
|
||||
return df, label
|
||||
except Exception as e:
|
||||
print(" [{}] {} 失败: {}".format(label, method_name, e))
|
||||
logger.warning(" [%s] %s 失败: %s", label, method_name, e)
|
||||
return pd.DataFrame(), None
|
||||
|
||||
# ── 股票列表 ──────────────────────────────────────────
|
||||
@@ -77,15 +99,15 @@ class DataManager:
|
||||
if not force_refresh:
|
||||
df = dao.query_stock_list()
|
||||
if not df.empty:
|
||||
print("[DataManager] 从 DB 读取股票列表: {} 只".format(len(df)))
|
||||
logger.info("[DataManager] 从 DB 读取股票列表: %s 只", len(df))
|
||||
return df
|
||||
|
||||
print("[DataManager] 拉取股票列表 (AkShare → Tushare)...")
|
||||
logger.info("[DataManager] 拉取股票列表 (AkShare → Tushare)...")
|
||||
df, src = self._try_fetch("fetch_stock_list")
|
||||
if df.empty:
|
||||
print("[DataManager] 所有数据源均无法获取股票列表")
|
||||
logger.warning("[DataManager] 所有数据源均无法获取股票列表")
|
||||
return pd.DataFrame()
|
||||
print("[DataManager] 股票列表已入库 ({}): {} 只".format(src, len(df)))
|
||||
logger.info("[DataManager] 股票列表已入库 (%s): %s 只", src, len(df))
|
||||
dao.save_stock_list(df)
|
||||
time.sleep(2)
|
||||
return df
|
||||
@@ -105,40 +127,48 @@ class DataManager:
|
||||
if not force_refresh:
|
||||
df = dao.query_daily(ts_code, start, end)
|
||||
if not df.empty:
|
||||
# 检查 DB 窗口尾部是否明显落后于请求的 end(>5 个自然日)。
|
||||
# 若是,说明存在数据缺口,触发一次增量补拉,而不是把陈旧数据当完整返回。
|
||||
latest = str(df["trade_date"].astype(str).max())
|
||||
if end >= "20200101" and latest < _rollback_days(end, 7):
|
||||
logger.warning(
|
||||
"%s DB 窗口尾部 %s 明显落后于请求日 %s,触发补拉", ts_code, latest, end)
|
||||
refreshed, src = self._try_fetch("fetch_daily", ts_code, latest, end)
|
||||
if not refreshed.empty and src:
|
||||
_safe_save_daily(refreshed)
|
||||
return df
|
||||
|
||||
# DB 未命中 → 从数据源拉取
|
||||
df, src = self._try_fetch("fetch_daily", ts_code, start, end)
|
||||
if df.empty:
|
||||
print("[WARN] {} 日线获取失败 (AkShare+Tushare 均不可用)".format(ts_code))
|
||||
logger.warning("%s 日线获取失败 (AkShare+Tushare 均不可用)", ts_code)
|
||||
return pd.DataFrame()
|
||||
|
||||
# 保存到 DB
|
||||
try:
|
||||
# 筛选 DB 需要的列
|
||||
cols = [c for c in dao._DAILY_COLS if c in df.columns]
|
||||
dao.save_daily(df[cols])
|
||||
except Exception as e:
|
||||
print("[WARN] 日线入库失败: {}".format(e))
|
||||
|
||||
# 保存到 DB(失败仅记日志,不阻断已获取到的数据返回)
|
||||
_safe_save_daily(df)
|
||||
return df
|
||||
|
||||
def sync_daily(self, ts_code: str) -> int:
|
||||
latest = dao.get_latest_trade_date(ts_code)
|
||||
today = time.strftime("%Y%m%d")
|
||||
if latest and latest >= today:
|
||||
print("[DataManager] {} 数据已是最新 ({})".format(ts_code, latest))
|
||||
logger.info("[DataManager] %s 数据已是最新 (%s)", ts_code, latest)
|
||||
return 0
|
||||
|
||||
start = latest or DEFAULT_START_DATE
|
||||
df, src = self._try_fetch("fetch_daily", ts_code, start, today)
|
||||
if df.empty:
|
||||
print("[WARN] {} sync_daily 失败 (AkShare+Tushare 均不可用)".format(ts_code))
|
||||
logger.warning("[DataManager] %s sync_daily 失败 (AkShare+Tushare 均不可用)", ts_code)
|
||||
return 0
|
||||
|
||||
# 入库失败要能反映到返回值,否则调用方会误以为已持久化
|
||||
cols = [c for c in dao._DAILY_COLS if c in df.columns]
|
||||
dao.save_daily(df[cols])
|
||||
print("[DataManager] {} 同步 {} 条日线 ({})".format(ts_code, len(df), src))
|
||||
try:
|
||||
dao.save_daily(df[cols])
|
||||
except Exception as e:
|
||||
logger.exception("[DataManager] %s 日线入库失败:%s", ts_code, e)
|
||||
raise
|
||||
logger.info("[DataManager] %s 同步 %s 条日线 (%s)", ts_code, len(df), src)
|
||||
return len(df)
|
||||
|
||||
def sync_all_daily(self) -> int:
|
||||
@@ -148,11 +178,11 @@ class DataManager:
|
||||
try:
|
||||
total += self.sync_daily(ts_code)
|
||||
if (i + 1) % 50 == 0:
|
||||
print("[DataManager] 进度: {}/{}".format(i + 1, len(stock_list)))
|
||||
logger.info("[DataManager] 进度: %s/%s", i + 1, len(stock_list))
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print("[WARN] {} 同步失败: {}".format(ts_code, e))
|
||||
print("[DataManager] 全量同步完成,新增 {} 条".format(total))
|
||||
logger.warning("[DataManager] %s 同步失败: %s", ts_code, e)
|
||||
logger.info("[DataManager] 全量同步完成,新增 %s 条", total)
|
||||
return total
|
||||
|
||||
# ── 财务数据 ──────────────────────────────────────────
|
||||
@@ -167,5 +197,5 @@ class DataManager:
|
||||
cols = [c for c in dao._FINA_COLS if c in df.columns]
|
||||
dao.save_financial(df[cols])
|
||||
except Exception as e:
|
||||
print("[WARN] 财务数据入库失败: {}".format(e))
|
||||
logger.exception("财务数据入库失败: %s", e)
|
||||
return df
|
||||
|
||||
Reference in New Issue
Block a user