From 3304ff46b2cc48858c07be5dbede17c3f7a6e4fa Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 25 Aug 2026 20:54:40 +0800 Subject: [PATCH] =?UTF-8?q?perf(data):=20=E5=9B=9B=E7=83=AD=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=97=A5=E6=9C=9F=E5=8C=BA=E9=97=B4SARGable=E5=8C=96?= =?UTF-8?q?=E2=80=94=E2=80=94substr(datetime,1,10)=E5=AF=B9=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E5=88=97=E5=A5=97=E5=87=BD=E6=95=B0=E6=89=93=E4=B8=8D?= =?UTF-8?q?=E8=BF=9B=E5=A4=8D=E5=90=88=E7=B4=A2=E5=BC=95datetime=E5=88=97,?= =?UTF-8?q?=E6=AF=8F=E8=82=A1=E6=89=AB=E5=85=A8=E9=87=8F=E6=97=A5=E7=BA=BF?= =?UTF-8?q?=E5=8F=B2=E5=8F=96=E7=9F=AD=E7=AA=97=E2=80=94=E2=80=9408-25?= =?UTF-8?q?=E6=99=A89:30=E7=94=9F=E4=BA=A7=E5=AE=9E=E9=94=A4:momentum?= =?UTF-8?q?=E9=80=89=E8=82=A11/2(RPS=E6=B1=A0)174s=E6=9C=AA=E8=BE=BE<60s?= =?UTF-8?q?=E9=AA=8C=E6=94=B6,=E9=98=B6=E6=AE=B5=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E5=AE=9A=E7=BD=AA(=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E6=AE=B5=E5=90=AB=E5=8F=8Cseek=E4=BB=857.8s);=E7=97=85?= =?UTF-8?q?=E6=A0=B9=3DPanelFetcher=E7=9A=8430=E5=A4=A9=E7=AA=97SQL?= =?UTF-8?q?=E6=AF=8F=E5=8F=AA=E8=82=A1=E6=89=AB~5000=E8=A1=8C=E6=97=A5?= =?UTF-8?q?=E7=BA=BF=E5=8F=B2=E5=8F=96~22=E8=A1=8C,3226=E5=8F=AA=E2=89=881?= =?UTF-8?q?600=E4=B8=87=E8=A1=8C=3D174s=E9=87=8F=E7=BA=A7=E5=90=BB?= =?UTF-8?q?=E5=90=88=E3=80=82=E4=BF=AE=3D=E8=A3=B8=E5=88=97datetime>=3Dsta?= =?UTF-8?q?rt=20AND=20datetime 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