Files
sanguo_vnpy_v2/scripts/pipeline/is_price_source_vps.py
T
claude_dev b5aa18cf8d
CI/CD / test (push) Successful in 26s
CI/CD / nas-deploy (push) Successful in 9s
CI/CD / nas-verify (push) Successful in 8s
fix(pipeline): 冒烟实证修——桥扁DataFrame行走+env全链配方+epoch退化守卫 [vps]
三案皆 VPS 真桥只读探针实证(09-26 冒烟):
- is_price_source_vps:minute_rows_to_map 形态归一(扁 DataFrame[index,close]
  实测形态为准,兼容遗留嵌套 dict);_minute_closes 改逐票调桥(扁形态无
  code 列,批量切片无法按票归属);_ensure_bridge_env 补 DEFAULT_DATA_
  PROVIDER=miniqmt + PYTHONPATH 全链(bridge/vnpy 根/vnpy_v4.4.0/vnpy_qmt_
  v0.3.3,SANGUO_VNPY_ROOT 可覆)——缺任一 xtdata import 即断。
- order_log_parser._t_raw_ts:epoch sanity floor=2000-01-01(生产实见
  t_raw=0 退化单 3565 条→1970 幽灵日;退化/负/垃圾回退行首时间戳),
  毫秒分支移入 floor 内评估。
- 测试 +3(扁/嵌套形态×2、epoch 退化回退×1)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 14:07:22 +08:00

184 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""VPS 价格源适配器(偏差日报三价链锚点;Mac 不可跑——桥/dbbardata 仅 VPS)。
分钟 close:桥 xtdata shim(PYTHONPATH 全链 bridge+vnpy 根+vnpy_v4.4.0+
vnpy_qmt_v0.3.3,env DEFAULT_DATA_PROVIDER/BIGQMT_ACCOUNT_ID/
BIGQMT_REDIS_PASSWORD——口令运行时读 C:\\redis\\redis.conf,
永不落盘/回显)。日线 OHLC/prev_close:quant_trading.db dbbardata(datareader
同款列名)。symbol 规范化:日志/台账四种形态 → xtdata 的 XXXXXX.SH/SZ。
"""
from __future__ import annotations
import os
import re
import sqlite3
from datetime import datetime, timedelta
from typing import Any
_BRIDGE_PYROOT = os.environ.get(
"SANGUO_BRIDGE_PYROOT", r"C:\sanguo_bigqmt\xtquant_bridge")
_VNPY_ROOT = os.environ.get("SANGUO_VNPY_ROOT", r"C:\sanguo_vnpy_v2")
_ACCOUNT = os.environ.get("BIGQMT_ACCOUNT_ID", "66639661")
_DAILY_DB = os.environ.get(
"SANGUO_DAILY_DB", r"C:\sanguo_vnpy_v2\data\quant_trading.db")
def normalize_symbol(sym: str) -> str:
"""600276.SH/600276/sh600276/600276.XSHG → 600276.SH(SZ 对称)。"""
s = sym.strip().upper()
m = re.match(r"^(?:SH|SZ)?(\d{6})(?:\.(?:SH|SZ|XSHG|XSHE))?$", s)
if not m:
return s
code = m.group(1)
exch = "SH" if s.startswith("SH") or s.endswith(("SH", "XSHG")) \
or code.startswith(("6", "9", "5")) else "SZ"
return f"{code}.{exch}"
def _read_redis_password() -> str:
with open(r"C:\redis\redis.conf", "r", errors="replace") as f:
for line in f:
m = re.match(r"^requirepass\s+(\S+)", line)
if m:
return m.group(1).strip()
raise SystemExit("NO_REQUIREPASS_IN_CONF")
def _ensure_bridge_env() -> None:
"""桥 env 配方(冒烟 09-26 实证):DEFAULT_DATA_PROVIDER=miniqmt +
PYTHONPATH 全链(bridge/vnpy 根/vnpy_v4.4.0/vnpy_qmt_v0.3.3)——缺任一项
xtdata import 即报 无法连接xtquant服务。逐项 append,已有项不重复。"""
parts = [p for p in os.environ.get("PYTHONPATH", "").split(os.pathsep)
if p]
for p in (_BRIDGE_PYROOT, _VNPY_ROOT,
os.path.join(_VNPY_ROOT, "vnpy_v4.4.0"),
os.path.join(_VNPY_ROOT, "vnpy_qmt_v0.3.3")):
if p not in parts:
parts.append(p)
os.environ["PYTHONPATH"] = os.pathsep.join(parts)
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
os.environ.setdefault("BIGQMT_ACCOUNT_ID", _ACCOUNT)
if "BIGQMT_REDIS_PASSWORD" not in os.environ:
os.environ["BIGQMT_REDIS_PASSWORD"] = _read_redis_password()
def minute_rows_to_map(obj: Any, code: str) -> dict[tuple[str, str], float]:
"""桥 get_market_data 返回 → {(code,'HH:MM'): close}。
实测返回=扁 DataFrame[index, close](冒烟 09-26 实证;时间在 index 列
或行索引皆出值);兼容遗留嵌套 dict {code:{ts:close}} 形态。分钟键=
digits 解析("20260924 14:59:00" 与 "2026-09-24 14:59:00" 皆出 "14:59")。
"""
out: dict[tuple[str, str], float] = {}
if hasattr(obj, "iterrows"): # 扁 DataFrame
for idx, row in obj.iterrows():
v = row.get("close") if hasattr(row, "get") else row["close"]
if v is None or v != v:
continue
ts_src = row.get("index", idx) if hasattr(row, "get") else idx
digits = re.sub(r"\D", "", str(ts_src))
if len(digits) >= 12:
minute = f"{digits[8:10]}:{digits[10:12]}"
else:
continue
out[(code, minute)] = float(v)
return out
sub = obj.get(code) if hasattr(obj, "get") else None # 遗留嵌套形态
if sub is None:
return out
for idx, v in (sub.items() if hasattr(sub, "items") else []):
if v is None or v != v:
continue
digits = re.sub(r"\D", "", str(idx))
if len(digits) >= 12:
out[(code, f"{digits[8:10]}:{digits[10:12]}")] = float(v)
return out
def _minute_closes(symbols: list[str], day: str) -> dict[tuple[str, str], float]:
"""桥拉一日分钟 close → {(symbol, 'HH:MM'): close}(当日锚点一次拉齐)。
逐票调桥(冒烟 09-26 实证:返回扁 DataFrame 无 code 列,批量切片无法
按票归属);形态解析归一走 minute_rows_to_map。
"""
_ensure_bridge_env()
from xtquant import xtdata # noqa: WPS433(shim 需 env 先就位,延迟导入)
start = f"{day} 09:00:00"
end = f"{day} 15:30:00"
out: dict[tuple[str, str], float] = {}
codes = sorted({normalize_symbol(s) for s in symbols})
for code in codes:
result = xtdata.get_market_data(
field_list=["close"], stock_list=[code], period="1m",
start_time=start, end_time=end)
out.update(minute_rows_to_map(result, code))
return out
def _daily_bars(symbol: str, day: str) -> dict | None:
"""dbbardata 当日+前一日日线 → {open/high/low/close/prev_close}。
exchange 映射(infra 核修):dbbardata 用 vt 约定 SSE/SZSE(datareader.py:158
同款),非 SH/SZ——照抄 split 结果会静默查空、锚点全 None。
"""
code = normalize_symbol(symbol)
bare, exch = code.split(".")
exch = {"SH": "SSE", "SZ": "SZSE"}[exch]
prev = (datetime.strptime(day, "%Y-%m-%d") - timedelta(days=20)
).strftime("%Y-%m-%d")
end_excl = (datetime.strptime(day, "%Y-%m-%d") + timedelta(days=1)
).strftime("%Y-%m-%d")
with sqlite3.connect(_DAILY_DB) as conn:
rows = conn.execute(
"SELECT datetime, open_price, high_price, low_price, close_price "
"FROM dbbardata WHERE symbol=? AND exchange=? AND interval='d' "
"AND datetime>=? AND datetime<? ORDER BY datetime",
(bare, exch, prev, end_excl)).fetchall()
rows = [r for r in rows if str(r[0])[:10] <= day]
if not rows:
return None
cur = rows[-1]
return {"open": cur[1], "high": cur[2], "low": cur[3], "close": cur[4],
"prev_close": rows[-2][4] if len(rows) >= 2 else cur[1]}
def make_price_fn():
"""PriceFn:minute≠None 走桥分钟缓存;None 走 dbbardata 日 close。
缓存键=(day, code)——只按 day 键会首票之后全票静默拿 None 锚(终审核修)。
"""
cache: dict[tuple[str, str], dict[tuple[str, str], float]] = {}
def fn(symbol: str, day: str, minute: str | None):
code = normalize_symbol(symbol)
if minute is None:
bars = _daily_bars(code, day)
return bars and bars["close"]
key = (day, code)
if key not in cache:
cache[key] = _minute_closes([code], day)
return cache[key].get((code, minute))
return fn
def make_ohlc_fn():
def fn(symbol: str, day: str):
return _daily_bars(normalize_symbol(symbol), day)
return fn
def probe(day: str, symbol: str = "600276.SH") -> None:
"""fail-loud 自检(infra 核修):已知 symbol 查不到日线/分钟→直接 raise,
绝不静默空转(VPS 首跑/部署后冒烟必调;测试不依赖)。"""
bars = _daily_bars(symbol, day)
if not bars or not bars.get("close"):
raise RuntimeError(f"probe: dbbardata 无 {symbol}@{day} 日线(exchange 映射/库路径错?)")
mins = _minute_closes([symbol], day)
if not mins:
raise RuntimeError(f"probe: 桥分钟 close 空 {symbol}@{day}(shim env/形态错?)")
if not any(re.match(r"^\d{2}:\d{2}$", k[1]) for k in mins):
raise RuntimeError(
f"probe: 分钟键形态异常 {symbol}@{day} keys={list(mins)[:3]}"
"(索引形态与解析不符)")
print(f"probe ok: close={bars['close']} minutes={len(mins)}")