Files
sanguo_vnpy_v2/docs/archive/data/2026-07-05-plan1-data-layer.md
T
claude_dev c3e53fbef3 docs(data): 归档数据层验证产物 + 数据层总览README
- scripts/data_platform/_archive/legacy/: 归档20个独立探针/诊断/旧降级脚本(零引用验证)
- docs/archive/data/: 归档17个数据相关旧设计/plan/report(保留fusion spec作深读)
- docs/data-platform/README.md: 数据层单一权威记录(8节:架构/布局/源/管线/铁律/API/缺口/待办)
- 删除 _mootdx_depth_result.txt
- Phase2待办: 15m灌库链+旧回填import链(有测试/wrapper依赖,VPS schtask确认后归档)
2026-07-29 10:11:38 +08:00

855 lines
28 KiB
Markdown
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.
# Plan 1: 数据层实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 移植 v1 数据层到 v2,适配 vnpy 4.4.0,提供统一 DataReader 输出 vnpy BarData,支撑后续回测/因子层。
**Architecture:** 继承 v1 的 validator/fallback/增量更新(已验证资产),重组为 4 个单一职责组件 + YAML 集中配置;修复 v1 的 BaoStock 超时坑;NAS 容器本地读 parquet/SQLite,不走 SMB。
**Tech Stack:** Python 3.10、pandas、pyarrowparquet)、vnpy 4.4.0 数据库模块、BaoStock、requests、PyYAML、pytest
## Global Constraints
- 不改造 vnpy 核心(只用其数据库模块读 DbBarData)
- 数据读 NAS `/volume1/stock/`(容器本地挂载,不走 SMB
- 继承 v1 资产:`~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/`
- 配置集中 YAML(v1 散落代码 → v2 `config/data_platform.yaml`
- TDD:每个 task 先写测试 → 失败 → 实现 → 通过 → commit
- v2 工作目录:`~/.openclaw/sanguo_projects/sanguo_vnpy_v2/`
---
## File Structure
| 文件 | 职责 | 来源 |
|------|------|------|
| `sanguo_data/__init__.py` | 包入口,导出公共接口 | 新建 |
| `sanguo_data/config.py` | YAML 配置加载 | 新建 |
| `sanguo_data/validator.py` | 7 条 fatal 校验 | copy v1 `data_platform/validator.py` |
| `sanguo_data/datafeed.py` | 多源接入 + fallback + BaoStock 超时 | 基于 v1 `data_platform/fallback.py` 改造 |
| `sanguo_data/datareader.py` | 统一读 parquet/SQLite → BarData | 新建 |
| `sanguo_data/datawriter.py` | 写 parquet + SQLite DbBarData | 基于 v1 `data_platform/import_vnpy_daily_fast.py` 改造 |
| `sanguo_data/scheduler.py` | 增量更新 + 断点续传 | 基于 v1 `data_platform/daily_all_update.py` 改造 |
| `config/data_platform.yaml` | 数据源/路径/限流配置 | 新建 |
| `tests/data/__init__.py` | 测试包 | 新建 |
| `tests/data/test_validator.py` | validator 测试 | 新建 |
| `tests/data/test_datareader.py` | DataReader 测试 | 新建 |
| `tests/data/test_datafeed.py` | DataFeed 测试(含 BaoStock 超时) | 新建 |
| `tests/data/test_config.py` | 配置加载测试 | 新建 |
| `tests/data/conftest.py` | 测试夹具(合成 BarData/parquet | 新建 |
---
## Task 1: 项目脚手架 + YAML 配置
**Files:**
- Create: `sanguo_data/__init__.py`, `sanguo_data/config.py`, `config/data_platform.yaml`
- Create: `tests/data/__init__.py`, `tests/data/test_config.py`
**Interfaces:**
- Produces: `load_config(path: str) -> DataConfig``DataConfig` 是 dataclass,含 `data_paths``data_sources``validation``performance` 字段
- [ ] **Step 1: 写失败测试**
```python
# tests/data/test_config.py
from sanguo_data.config import load_config, DataConfig
def test_load_config_returns_dataconfig(tmp_path):
yaml_content = """
data_paths:
daily_dir: /tmp/daily
minute_15_dir: /tmp/15min
vnpy_db: /tmp/quant.db
stock_list: /tmp/stock.csv
data_sources:
daily:
- name: eastmoney
enabled: true
interval: 4.0
validation:
price_positive: true
performance:
max_retries: 3
"""
p = tmp_path / "config.yaml"
p.write_text(yaml_content)
cfg = load_config(str(p))
assert isinstance(cfg, DataConfig)
assert cfg.data_paths["daily_dir"] == "/tmp/daily"
assert cfg.data_sources["daily"][0]["name"] == "eastmoney"
assert cfg.performance["max_retries"] == 3
```
- [ ] **Step 2: 运行测试确认失败**
Run: `pytest tests/data/test_config.py -v`
Expected: FAIL with "ModuleNotFoundError: sanguo_data.config"
- [ ] **Step 3: 实现 config.py**
```python
# sanguo_data/config.py
from dataclasses import dataclass
import yaml
@dataclass(frozen=True)
class DataConfig:
data_paths: dict
data_sources: dict
validation: dict
performance: dict
def load_config(path: str) -> DataConfig:
with open(path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
return DataConfig(
data_paths=raw.get("data_paths", {}),
data_sources=raw.get("data_sources", {}),
validation=raw.get("validation", {}),
performance=raw.get("performance", {}),
)
```
```python
# sanguo_data/__init__.py
from .config import DataConfig, load_config
__all__ = ["DataConfig", "load_config"]
```
- [ ] **Step 4: 创建 config/data_platform.yaml**
```yaml
# config/data_platform.yaml
data_paths:
daily_dir: /volume1/stock/A股数据/日线数据/daily
minute_15_dir: /volume1/stock/minute_kline/15min
vnpy_db: /volume1/stock/sanguo_vnpy/data/quant_trading.db
stock_list: /volume1/stock/A股数据/stock_info/stock_basic_info_raw_20260326_113530.csv
data_sources:
daily:
- name: eastmoney
enabled: true
interval: 4.0
- name: baostock
enabled: true
interval: 0.0
timeout: 30
- name: tencent
enabled: true
interval: 0.0
minute_15:
- name: eastmoney
enabled: true
interval: 4.0
validation:
price_positive: true
ohlc_consistency: true
no_future_dates: true
performance:
request_interval: 0.3
max_retries: 3
fail_window: 100
fail_threshold: 0.8
```
- [ ] **Step 5: 运行测试确认通过**
Run: `pytest tests/data/test_config.py -v`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add sanguo_data/ config/data_platform.yaml tests/data/
git commit -m "feat(data): 脚手架 + YAML 配置加载"
```
---
## Task 2: Validatorcopy v1 + 测试)
**Files:**
- Create: `sanguo_data/validator.py`copy v1
- Create: `tests/data/test_validator.py`, `tests/data/conftest.py`
**Interfaces:**
- Produces: `validate_daily(df: pd.DataFrame) -> pd.DataFrame`(过滤非法行)
- [ ] **Step 1: copy v1 validator.py**
```bash
cp ~/.openclaw/sanguo_projects/sanguo_vnpy/data_platform/validator.py \
~/.openclaw/sanguo_projects/sanguo_vnpy_v2/sanguo_data/validator.py
```
读 copy 后的文件,确认 v1 原有校验函数名。如签名与下方测试不符,**以 v1 实际签名为准**调整。
- [ ] **Step 2: 写失败测试(合成数据)**
```python
# tests/data/conftest.py
import pandas as pd
import pytest
@pytest.fixture
def good_daily_df():
return pd.DataFrame({
"date": ["2026-01-01", "2026-01-02"],
"open": [10.0, 11.0], "high": [10.5, 11.5],
"low": [9.8, 10.8], "close": [10.2, 11.2],
"volume": [10000, 12000],
})
@pytest.fixture
def bad_daily_df():
return pd.DataFrame({
"date": ["2026-01-01"],
"open": [0.0], "high": [0.0], "low": [0.0], "close": [0.0],
"volume": [100],
})
```
```python
# tests/data/test_validator.py
from sanguo_data.validator import validate_daily
def test_validate_daily_keeps_good_rows(good_daily_df):
assert len(validate_daily(good_daily_df)) == 2
def test_validate_daily_drops_zero_price(bad_daily_df):
assert len(validate_daily(bad_daily_df)) == 0
```
- [ ] **Step 3: 运行测试**
Run: `pytest tests/data/test_validator.py -v`
Expected: FAIL(函数名不匹配)或 PASS(v1 直接可用)
- [ ] **Step 4: 适配导出接口**
如 v1 函数名/签名与测试不符,在 `validator.py` 末尾加薄适配(**不改 v1 校验逻辑**):
```python
# 适配层,不改 v1 校验逻辑
def validate_daily(df):
"""对外统一接口,委托 v1 校验规则"""
return _v1_validate(df) # 替换为 v1 实际函数名
```
- [ ] **Step 5: 运行确认通过**
Run: `pytest tests/data/test_validator.py -v`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add sanguo_data/validator.py tests/data/test_validator.py tests/data/conftest.py
git commit -m "feat(data): 移植 v1 validator + 适配接口 + 测试"
```
---
## Task 3: DataReader — parquet 读取
**Files:**
- Create: `sanguo_data/datareader.py`
- Create: `tests/data/test_datareader.py`
**Interfaces:**
- Consumes: `DataConfig.data_paths["daily_dir"]`Task 1
- Produces: `read_parquet_daily(symbol: str, start: str, end: str, cfg: DataConfig) -> list[BarData]``_row_to_bar(symbol, row, interval) -> BarData`
- [ ] **Step 1: 写失败测试(合成 parquet)**
```python
# tests/data/test_datareader.py
import pandas as pd
from sanguo_data.config import DataConfig
from sanguo_data.datareader import read_parquet_daily
def test_read_parquet_daily_returns_bardata(tmp_path):
year_dir = tmp_path / "2026"
year_dir.mkdir()
df = pd.DataFrame({
"date": ["2026-01-05", "2026-01-06"],
"open": [10.0, 11.0], "high": [10.5, 11.5],
"low": [9.8, 10.8], "close": [10.2, 11.2],
"volume": [10000, 12000],
})
df.to_parquet(year_dir / "600000.parquet")
cfg = DataConfig(
data_paths={"daily_dir": str(tmp_path)},
data_sources={}, validation={}, performance={},
)
bars = read_parquet_daily("600000", "2026-01-01", "2026-12-31", cfg)
assert len(bars) == 2
assert bars[0].symbol == "600000"
assert bars[0].open_price == 10.0
```
- [ ] **Step 2: 运行确认失败**
Run: `pytest tests/data/test_datareader.py::test_read_parquet_daily_returns_bardata -v`
Expected: FAIL "ModuleNotFoundError"
- [ ] **Step 3: 实现 datareader.pyparquet 部分)**
```python
# sanguo_data/datareader.py
import pandas as pd
from datetime import datetime
from pathlib import Path
from vnpy.trader.object import BarData
from vnpy.trader.constant import Exchange, Interval
def read_parquet_daily(symbol: str, start: str, end: str, cfg) -> list[BarData]:
daily_dir = Path(cfg.data_paths["daily_dir"])
start_dt = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d")
bars: list[BarData] = []
for year in range(start_dt.year, end_dt.year + 1):
f = daily_dir / str(year) / f"{symbol}.parquet"
if not f.exists():
continue
df = pd.read_parquet(f)
for _, row in df.iterrows():
d = pd.to_datetime(row["date"])
if start_dt <= d <= end_dt:
bars.append(_row_to_bar(symbol, row, Interval.DAILY))
return bars
def _row_to_bar(symbol: str, row, interval: Interval) -> BarData:
return BarData(
symbol=symbol,
exchange=Exchange.SSE, # Task 4 改为 guess_exchange
datetime=pd.to_datetime(row["date"]).to_pydatetime(),
interval=interval,
open_price=float(row["open"]),
high_price=float(row["high"]),
low_price=float(row["low"]),
close_price=float(row["close"]),
volume=float(row["volume"]),
gateway_name="DATA",
)
```
- [ ] **Step 4: 运行确认通过**
Run: `pytest tests/data/test_datareader.py::test_read_parquet_daily_returns_bardata -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_data/datareader.py tests/data/test_datareader.py
git commit -m "feat(data): DataReader parquet 读取 → BarData"
```
---
## Task 4: DataReader — SQLite DbBarData + 交易所判断
**Files:**
- Modify: `sanguo_data/datareader.py`(加 `read_db_daily` + `guess_exchange``_row_to_bar` 改用 `guess_exchange`
- Modify: `tests/data/test_datareader.py`
**Interfaces:**
- Produces: `read_db_daily(symbol, start, end, cfg) -> list[BarData]`(用 vnpy 4.4.0 数据库模块),`guess_exchange(symbol) -> Exchange`
- [ ] **Step 1: 写失败测试**
```python
# 追加到 tests/data/test_datareader.py
from sanguo_data.datareader import guess_exchange
def test_guess_exchange_sh():
assert guess_exchange("600000").value == "SSE"
def test_guess_exchange_sz():
assert guess_exchange("000001").value == "SZSE"
```
- [ ] **Step 2: 运行确认失败**
Run: `pytest tests/data/test_datareader.py::test_guess_exchange_sh -v`
Expected: FAIL "ImportError"
- [ ] **Step 3: 实现 guess_exchange + read_db_daily,并让 `_row_to_bar` 用 guess_exchange**
```python
# 追加到 sanguo_data/datareader.py;并把 _row_to_bar 的 exchange 改为 guess_exchange(symbol)
from vnpy.trader.database import get_database
def guess_exchange(symbol: str) -> Exchange:
"""按代码前缀判断交易所:6/68/5x→SSE0/3/15x→SZSE"""
if symbol.startswith(("60", "68", "51", "56", "58")):
return Exchange.SSE
if symbol.startswith(("00", "30", "15")):
return Exchange.SZSE
return Exchange.SSE
def read_db_daily(symbol: str, start: str, end: str, cfg) -> list[BarData]:
db = get_database()
start_dt = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d")
return db.load_bar_data(
symbol=symbol,
exchange=guess_exchange(symbol),
interval=Interval.DAILY,
start=start_dt,
end=end_dt,
)
```
`_row_to_bar` 内的 `exchange=Exchange.SSE` 改为 `exchange=guess_exchange(symbol)`
> **Spike 检查点**`get_database()` 与 `load_bar_data` 签名需对照 vnpy 4.4.0 `vnpy/trader/database.py`。Task 8 spike 验证,若变更回头修正。
- [ ] **Step 4: 运行确认通过**
Run: `pytest tests/data/test_datareader.py -v`
Expected: PASS
- [ ] **Step 5: 真实 NAS 数据冒烟(手工)**
```bash
python -c "
from sanguo_data.config import load_config
from sanguo_data.datareader import read_db_daily
cfg = load_config('config/data_platform.yaml')
bars = read_db_daily('600000', '2026-01-01', '2026-06-30', cfg)
print(f'读取 {len(bars)} 条')
"
```
Expected: N > 0。若 0,检查 vnpy_db 路径与 4.4.0 接口。
- [ ] **Step 6: Commit**
```bash
git add sanguo_data/datareader.py tests/data/test_datareader.py
git commit -m "feat(data): DataReader SQLite + 交易所判断 + vnpy 4.4.0 spike 点"
```
---
## Task 5: DataFeed — 多源 fallback + BaoStock 超时(v1 卡死坑修复)
**Files:**
- Create: `sanguo_data/datafeed.py`(基于 v1 `fallback.py`
- Create: `tests/data/test_datafeed.py`
**Interfaces:**
- Produces: `fetch_daily(symbol, start, end, cfg) -> pd.DataFrame``fetch_with_fallback(symbol, start, end, sources) -> pd.DataFrame`
- [ ] **Step 1: 写失败测试(mock + 超时)**
```python
# tests/data/test_datafeed.py
import time
import pandas as pd
import pytest
from unittest.mock import patch
from sanguo_data.datafeed import fetch_with_fallback, _fetch_baostock_with_timeout
def test_fetch_with_fallback_uses_second_when_first_fails():
df_good = pd.DataFrame({"date": ["2026-01-01"], "open": [10.0]})
with patch("sanguo_data.datafeed._fetch_eastmoney", side_effect=Exception("limit")), \
patch("sanguo_data.datafeed._fetch_baostock", return_value=df_good):
out = fetch_with_fallback("600000", "2026-01-01", "2026-01-02", ["eastmoney", "baostock"])
assert len(out) == 1
def test_baostock_timeout_does_not_hang():
"""v1 卡死坑修复验证:超时必须返回,不能无限挂起"""
start = time.time()
with patch("sanguo_data.datafeed._fetch_baostock_raw", side_effect=lambda *a: time.sleep(60)):
with pytest.raises(TimeoutError):
_fetch_baostock_with_timeout("600000", "2026-01-01", "2026-01-02", timeout=2)
assert time.time() - start < 5
```
- [ ] **Step 2: 运行确认失败**
Run: `pytest tests/data/test_datafeed.py -v`
Expected: FAIL "ModuleNotFoundError"
- [ ] **Step 3: 实现 datafeed.pyv1 fallback 模式 + BaoStock 超时包装)**
```python
# sanguo_data/datafeed.py
import pandas as pd
from multiprocessing import Process, Queue
from sanguo_data.config import DataConfig
def fetch_with_fallback(symbol, start, end, sources: list[str]) -> pd.DataFrame:
fetchers = {
"eastmoney": _fetch_eastmoney,
"baostock": lambda s, a, b: _fetch_baostock_with_timeout(s, a, b, timeout=30),
"tencent": _fetch_tencent,
}
last_err = None
for name in sources:
try:
df = fetchers[name](symbol, start, end)
if df is not None and len(df) > 0:
return df
except Exception as e:
last_err = e
continue
raise RuntimeError(f"all sources failed: {last_err}")
def fetch_daily(symbol, start, end, cfg: DataConfig) -> pd.DataFrame:
sources = [s["name"] for s in cfg.data_sources.get("daily", []) if s.get("enabled", True)]
return fetch_with_fallback(symbol, start, end, sources)
def _fetch_baostock_with_timeout(symbol, start, end, timeout):
"""子进程隔离 BaoStock(修复 v1 无超时卡死坑)"""
q = Queue()
def worker():
try:
q.put(_fetch_baostock_raw(symbol, start, end))
except Exception as e:
q.put(e)
p = Process(target=worker)
p.start()
p.join(timeout)
if p.is_alive():
p.terminate(); p.join()
raise TimeoutError(f"baostock timeout after {timeout}s")
res = q.get()
if isinstance(res, Exception):
raise res
return res
def _fetch_baostock_raw(symbol, start, end):
"""从 v1 data_platform/fallback.py copy BaoStock 接入(baostock.query_history_k_data_plus"""
raise NotImplementedError("copy from v1 data_platform/fallback.py")
def _fetch_eastmoney(symbol, start, end):
raise NotImplementedError("copy from v1 data_platform/fallback.py")
def _fetch_tencent(symbol, start, end):
raise NotImplementedError("copy from v1 data_platform/fallback.py")
```
> **执行注意**`_fetch_baostock_raw` / `_fetch_eastmoney` / `_fetch_tencent` 从 v1 `data_platform/fallback.py` copy 接入逻辑。**超时包装是新增修复,不 copy。**
- [ ] **Step 4: 运行确认通过**
Run: `pytest tests/data/test_datafeed.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_data/datafeed.py tests/data/test_datafeed.py
git commit -m "feat(data): DataFeed 多源 fallback + BaoStock 超时修复"
```
---
## Task 6: DataWriter — 原子写 parquet + vnpy SQLite
**Files:**
- Create: `sanguo_data/datawriter.py`
- Create: `tests/data/test_datawriter.py`
**Interfaces:**
- Produces: `write_daily(symbol, df, cfg) -> None``atomic_write_parquet(path, df) -> None`
- [ ] **Step 1: 写失败测试**
```python
# tests/data/test_datawriter.py
import pandas as pd
from sanguo_data.config import DataConfig
from sanguo_data.datawriter import write_daily, atomic_write_parquet
def test_atomic_write_parquet(tmp_path):
f = tmp_path / "2026" / "600000.parquet"
df = pd.DataFrame({"date": ["2026-01-01"], "open": [10.0]})
atomic_write_parquet(str(f), df)
assert f.exists()
assert not list(tmp_path.glob("*.tmp"))
def test_write_daily_writes_parquet_and_db(tmp_path, monkeypatch):
cfg = DataConfig(
data_paths={"daily_dir": str(tmp_path / "daily"), "vnpy_db": str(tmp_path / "q.db")},
data_sources={}, validation={}, performance={},
)
df = pd.DataFrame({"date": ["2026-01-01"], "open": [10.0], "high": [10.0],
"low": [10.0], "close": [10.0], "volume": [100]})
called = {}
monkeypatch.setattr("sanguo_data.datawriter._save_to_vnpy_db", lambda bars, cfg: called.setdefault("bars", bars))
write_daily("600000", df, cfg)
assert (tmp_path / "daily" / "2026" / "600000.parquet").exists()
assert len(called["bars"]) == 1
```
- [ ] **Step 2: 运行确认失败**
Run: `pytest tests/data/test_datawriter.py -v`
Expected: FAIL
- [ ] **Step 3: 实现 datawriter.py**
```python
# sanguo_data/datawriter.py
import os
import pandas as pd
from pathlib import Path
from vnpy.trader.object import BarData
from vnpy.trader.constant import Interval
from sanguo_data.datareader import _row_to_bar
from sanguo_data.config import DataConfig
def atomic_write_parquet(path: str, df: pd.DataFrame) -> None:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
tmp = str(p) + ".tmp"
df.to_parquet(tmp)
os.replace(tmp, str(p)) # 原子替换
def write_daily(symbol: str, df: pd.DataFrame, cfg: DataConfig) -> None:
# 1) parquet 增量合并(按年分区,去重保留最新)
for year, group in df.groupby(df["date"].str[:4]):
f = Path(cfg.data_paths["daily_dir"]) / year / f"{symbol}.parquet"
if f.exists():
old = pd.read_parquet(f)
combined = pd.concat([old, group]).drop_duplicates("date", keep="last")
else:
combined = group
atomic_write_parquet(str(f), combined)
# 2) vnpy SQLite
bars = [_row_to_bar(symbol, row, Interval.DAILY) for _, row in df.iterrows()]
_save_to_vnpy_db(bars, cfg)
def _save_to_vnpy_db(bars: list[BarData], cfg: DataConfig) -> None:
from vnpy.trader.database import get_database
db = get_database()
db.save_bar_data(bars) # spike 验证签名
```
- [ ] **Step 4: 运行确认通过**
Run: `pytest tests/data/test_datawriter.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_data/datawriter.py tests/data/test_datawriter.py
git commit -m "feat(data): DataWriter 原子写 parquet + vnpy SQLite"
```
---
## Task 7: UpdateScheduler — 增量更新 + 断点续传 + 熔断
**Files:**
- Create: `sanguo_data/scheduler.py`(基于 v1 `daily_all_update.py`
- Create: `tests/data/test_scheduler.py`
**Interfaces:**
- Consumes: fetch_dailyTask 5+ validate_dailyTask 2+ write_dailyTask 6
- Produces: `run_daily_update(cfg, symbols=None) -> UpdateReport`
- [ ] **Step 1: 写失败测试(断点续传)**
```python
# tests/data/test_scheduler.py
import json
import pandas as pd
from unittest.mock import patch
from sanguo_data.config import DataConfig
from sanguo_data.scheduler import run_daily_update, UpdateReport
def test_run_daily_update_skips_completed_on_resume(tmp_path):
progress_file = tmp_path / "progress.json"
progress_file.write_text('{"600000": "done"}')
cfg = DataConfig(
data_paths={"daily_dir": str(tmp_path), "vnpy_db": str(tmp_path / "q.db"),
"progress_file": str(progress_file)},
data_sources={"daily": [{"name": "eastmoney", "enabled": True}]},
validation={}, performance={},
)
with patch("sanguo_data.scheduler.fetch_daily", return_value=pd.DataFrame({
"date": ["2026-01-01"], "open": [10.0], "high": [10.0],
"low": [10.0], "close": [10.0], "volume": [100]})) as m_fetch, \
patch("sanguo_data.scheduler.write_daily") as m_write:
report = run_daily_update(cfg, symbols=["600000"])
assert m_fetch.call_count == 0 # 已 done,跳过
assert isinstance(report, UpdateReport)
assert report.skipped == 1
```
- [ ] **Step 2: 运行确认失败**
Run: `pytest tests/data/test_scheduler.py -v`
Expected: FAIL
- [ ] **Step 3: 实现 scheduler.py**
```python
# sanguo_data/scheduler.py
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from sanguo_data.config import DataConfig
from sanguo_data.datafeed import fetch_daily
from sanguo_data.validator import validate_daily
from sanguo_data.datawriter import write_daily
@dataclass
class UpdateReport:
total: int = 0
success: int = 0
failed: int = 0
skipped: int = 0
failures: list = field(default_factory=list)
def run_daily_update(cfg: DataConfig, symbols: list[str] | None = None) -> UpdateReport:
progress_path = Path(cfg.data_paths.get("progress_file", "progress.json"))
progress = json.loads(progress_path.read_text()) if progress_path.exists() else {}
symbols = symbols or _load_stock_list(cfg)
report = UpdateReport(total=len(symbols))
fail_window = cfg.performance.get("fail_window", 100)
fail_threshold = cfg.performance.get("fail_threshold", 0.8)
for sym in symbols:
if progress.get(sym) == "done":
report.skipped += 1
continue
try:
df = fetch_daily(sym, _last_date(sym, cfg), _today(), cfg)
df = validate_daily(df)
if len(df) > 0:
write_daily(sym, df, cfg)
progress[sym] = "done"
progress_path.write_text(json.dumps(progress, ensure_ascii=False))
report.success += 1
except Exception as e:
report.failed += 1
report.failures.append({"symbol": sym, "error": str(e)})
checked = report.success + report.failed
if checked >= fail_window and report.failed / max(checked, 1) > fail_threshold:
report.failures.append({"error": "FAIL_THRESHOLD_REACHED, abort"})
break
time.sleep(cfg.performance.get("request_interval", 0.3))
return report
def _load_stock_list(cfg: DataConfig) -> list[str]:
"""从 v1 data_platform/daily_all_update.py copy 全市场股票列表读取"""
raise NotImplementedError("copy from v1")
def _last_date(symbol: str, cfg: DataConfig) -> str:
return "2020-01-01" # 简化,实际读 parquet 最后日期
def _today() -> str:
return "2026-07-05"
```
- [ ] **Step 4: 运行确认通过**
Run: `pytest tests/data/test_scheduler.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add sanguo_data/scheduler.py tests/data/test_scheduler.py
git commit -m "feat(data): UpdateScheduler 增量 + 断点续传 + 熔断"
```
---
## Task 8: vnpy 4.4.0 接口 spike + 端到端冒烟
**Files:**
- Create: `tests/data/test_spike_vnpy44.py`
- Modify: 按 spike 结果修正 Task 4/6 的 `get_database()` 调用
- [ ] **Step 1: 写 spike 测试(探查 vnpy 4.4.0 接口)**
```python
# tests/data/test_spike_vnpy44.py
"""Spike: 验证 vnpy 4.4.0 数据库接口。无 NAS 数据时可 skip。"""
import datetime
import pytest
from vnpy.trader.database import get_database
from vnpy.trader.constant import Interval, Exchange
def test_vnpy44_database_interface():
db = get_database()
assert hasattr(db, "load_bar_data")
assert hasattr(db, "save_bar_data")
bars = db.load_bar_data(
symbol="600000", exchange=Exchange.SSE, interval=Interval.DAILY,
start=datetime.datetime(2026, 1, 1), end=datetime.datetime(2026, 6, 30),
)
assert isinstance(bars, list)
```
- [ ] **Step 2: 容器内运行 spike**
```bash
docker exec sanguo_vnpy_v2 pytest tests/data/test_spike_vnpy44.py -v
```
Expected: PASS(接口符合假设)或 FAIL(4.4.0 接口变更 → 记录差异,修正 Task 4/6 调用)。
- [ ] **Step 3: 端到端冒烟**
```bash
python -c "
from sanguo_data.config import load_config
from sanguo_data.datareader import read_db_daily
from sanguo_data.scheduler import run_daily_update
cfg = load_config('config/data_platform.yaml')
bars = read_db_daily('600000', '2026-01-01', '2026-06-30', cfg)
print(f'读取 {len(bars)} 条')
report = run_daily_update(cfg, symbols=['600000'])
print(f'更新: {report.success} 成功, {report.failed} 失败')
"
```
Expected: 读取 N 条 + 更新成功。**这是 Plan 1 最终验收。**
- [ ] **Step 4: spike 发现差异则修正 Task 4/6,重测**
- [ ] **Step 5: Commit**
```bash
git add tests/data/test_spike_vnpy44.py
git commit -m "test(data): vnpy 4.4.0 接口 spike + 端到端冒烟"
```
---
## Self-Review(写完内联检查)
**1. Spec coverage(对照 design §2 数据层):**
- DataFeed(多源 fallback + BaoStock 超时)→ Task 5 ✅
- Validator7 条 fatal)→ Task 2 ✅
- DataWriter/Reader(双存储)→ Task 3/4/6 ✅
- UpdateScheduler(增量 + 断点续传 + 熔断)→ Task 7 ✅
- 配置集中 YAML → Task 1 ✅
- 早期 spikevnpy 4.4.0 接口)→ Task 4 + Task 8 ✅
- BaoStock 超时坑修复 → Task 5 ✅
**2. Placeholder 扫描:**
- `_fetch_baostock_raw` / `_fetch_eastmoney` / `_fetch_tencent` / `_load_stock_list``NotImplementedError("copy from v1 ...")`——这是**有意的执行指引**(指明从 v1 哪个文件 copy),不是 plan 占位。执行 subagent 按 v1 `fallback.py`/`daily_all_update.py` copy。
- 其余步骤均有完整代码/命令。
**3. 类型一致性:**
- `DataConfig`Task 1)在 Task 2-7 一致使用 ✅
- `BarData``_row_to_bar`Task 3)在 Task 4/6 复用 ✅
- `guess_exchange`Task 4)在 Task 6 间接复用 ✅
- `fetch_daily` / `validate_daily` / `write_daily` 跨 Task 一致 ✅
**4. 风险:**
- vnpy 4.4.0 `get_database()` / `load_bar_data` / `save_bar_data` 签名需 Task 8 spike 验证,若变更修正 Task 4/6。已在 plan 显式标注 spike 检查点。