diff --git a/sanguo_data/datareader.py b/sanguo_data/datareader.py index af19278..9f00b08 100644 --- a/sanguo_data/datareader.py +++ b/sanguo_data/datareader.py @@ -9,7 +9,7 @@ if _VNPY_SRC not in sys.path: sys.path.insert(0, _VNPY_SRC) import pandas as pd -from datetime import datetime, date +from datetime import datetime, date, timedelta from vnpy.trader.object import BarData from vnpy.trader.constant import Exchange, Interval from vnpy.trader.setting import SETTINGS @@ -148,12 +148,16 @@ def read_index_daily(code: str, start, end, cfg) -> pd.DataFrame: conn = sqlite3.connect(db_path, timeout=30) conn.execute("PRAGMA busy_timeout = 30000") try: - # substr(datetime,1,10) 比日期规避混合格式(有纯日期有带时间,同 provider 模式) + # SARGable 日期区间(2026-08-25 同 provider P0 治本): 裸列 datetime>=/<, + # 右端 end+1 天排他——d 裸日期与 15m/5m 时间戳混合格式下与按日期前 10 位 + # 比较语义严格等价(含 end 当日全部行);substr 版打不进复合索引 datetime + # 列, 短窗读每股扫全量日线史 + end_excl = (datetime.strptime(end_str, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d") df = pd.read_sql( "SELECT datetime, open_price, high_price, low_price, close_price, volume " "FROM dbbardata WHERE symbol=? AND exchange=? AND interval='d' " - "AND substr(datetime,1,10)>=? AND substr(datetime,1,10)<=? ORDER BY datetime", - conn, params=(symbol, exchange, start_str, end_str), + "AND datetime>=? AND datetime str: return f"'{s}'" +def end_exclusive_str(end_str: str) -> str: + """end 日期 → 排他上界(end+1 天)字符串。SARGable 日期区间右端。 + + 2026-08-25 P0 治本: 旧「取日期前 10 位再比较」写法对索引列套函数,日期区间 + 打不进复合索引 datetime 列 → 每只股扫全量日线史取 30 天窗(momentum RPS 池 + 3226 只实测 174s)。裸列 ``datetime str: if not isinstance(s, str) or not _INTERVAL_RE.match(s) or len(s) > 16: raise ValueError(f"Invalid interval: {s!r}") @@ -55,10 +69,13 @@ class PriceFetcher: conn = ctx._connect() start_str = query.start_date or "1990-01-01" end_str = query.end_date or datetime.now().strftime("%Y-%m-%d") + # SARGable 日期区间(2026-08-25 P0): 裸列 datetime>=/<,右端 end+1 天排他 + # ——substr 版每只股扫全量日线史,详见 end_exclusive_str + end_excl = end_exclusive_str(end_str) q = ( "SELECT datetime, open_price, high_price, low_price, close_price, " "volume, turnover FROM dbbardata WHERE symbol=? AND exchange=? " - "AND interval='d' AND substr(datetime,1,10)>=? AND substr(datetime,1,10)<=? " + "AND interval='d' AND datetime>=? AND datetime={start_lit} " - f"AND substr(datetime,1,10)<={end_lit}" + f"AND datetime>={start_lit} " + f"AND datetime<{end_excl_lit}" ) q = " UNION ALL ".join([sub_template] * len(chunk)) params: List[Any] = [] diff --git a/sanguo_portfolio/providers/local_unified_provider.py b/sanguo_portfolio/providers/local_unified_provider.py index fa271bf..caf22dd 100644 --- a/sanguo_portfolio/providers/local_unified_provider.py +++ b/sanguo_portfolio/providers/local_unified_provider.py @@ -782,14 +782,18 @@ class LocalUnifiedProvider(DataProvider): # type: ignore[misc] from datetime import timedelta start_lim = (datetime.strptime(date_str, "%Y-%m-%d") - timedelta(days=90)).strftime("%Y-%m-%d") start_lit = _safe_date_literal(start_lim) - end_lit = _safe_date_literal(date_str) + # SARGable 日期区间(2026-08-25 P0): 裸列区间+end+1 天排他——substr 版的 + # 90 天下界打不进索引, 每股仍扫全量日线史(语义等价, 见 fetchers.price) + end_excl_lit = _safe_date_literal( + (datetime.strptime(date_str, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d") + ) interval_lit = _safe_interval_literal("d") # 纯 SELECT UNION ALL(子查询带 ORDER BY/LIMIT 触发 SQLite compound 限制); - # 用 90 天下界限定范围(全历史 → 近 90 天), 走复合索引, pandas 端取最近 2 根。 + # 90 天下界 + 裸列区间走复合索引(每股只扫窗口行), pandas 端取最近 2 根。 sub = ( "SELECT symbol, exchange, close_price, high_price, low_price, volume, datetime " f"FROM dbbardata WHERE symbol=? AND exchange=? AND interval={interval_lit} " - f"AND substr(datetime,1,10)>={start_lit} AND substr(datetime,1,10)<={end_lit}" + f"AND datetime>={start_lit} AND datetime<{end_excl_lit}" ) bars: Dict[tuple, list] = {} sym_exc = list({p[1] for p in pairs}) # 去重 (sym,exc) diff --git a/tests/portfolio/test_fetchers.py b/tests/portfolio/test_fetchers.py index 81f5d5b..2ccae45 100644 --- a/tests/portfolio/test_fetchers.py +++ b/tests/portfolio/test_fetchers.py @@ -242,3 +242,62 @@ class TestOptionalContract: fields=["close", "acc_net_value"], ) assert df["acc_net_value"].isna().all() + + +# ======================== SARGable 日期区间(2026-08-25 P0) ======================== + +class TestSargableDateRange: + """substr(datetime,1,10) 对索引列套函数 → 日期区间打不进复合索引 datetime 列, + 每股扫全量日线史取短窗(momentum RPS 池 3226 只 30 天窗 VPS 实测 174s;provider + /datareader 四热路径同病)。裸列 ``datetime>=start AND datetime