- 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确认后归档)
30 KiB
LocalUnifiedProvider Implementation Plan (spec §6 使用层)
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: 实现 spec §6 使用层 LocalUnifiedProvider——读方案A 权威数据层(dbbardata/constituent_unified/valuation_baostock),零 online,治幸存者偏差,喂 all_weather 策略。
Architecture: 新建 LocalUnifiedProvider(bullet_trade.DataProvider),内部按数据类路由方案A 权威表:日线读 dbbardata('d') raw + bs_adjust_factor 算前复权;成份股读 constituent_unified 并集(治偏差);估值读 valuation_baostock parquet + 市值读 static/valuation akshare parquet。Mac 测试用 sqlite :memory: + tmp parquet fixture,零 VPS 依赖。
Tech Stack: Python 3.10, pandas 2.3, sqlite3, pyarrow, pytest
Global Constraints(spec + 用户铁律)
- 零 online: provider 不 import baostock 调 online,纯读本地 DB/parquet(memory provider-local-data-only)。baostock 48000/天限频不波及使用层。
- surgical: 不改
LocalParquetProvider/BaostockProvider(旧链路保留,向后兼容)。 - dbbardata 不破坏:
UNIQUE(symbol,exchange,interval,datetime),只读不写。 - 复权: dbbardata 存 raw,消费端按
bs_adjust_factor.foreAdjustFactor算前复权(§14.7 最终目标,用户定不降级)。 - constituent_unified 并集模型: 表无 date 列,
get_index_stocks(date)返回 in_current∪was_removed 并集,date 参数无法精确时点过滤——治"纯当前幸存者"偏差,有轻微前视(使用说明标注)。 - 代码归一: jq_code
600519.XSHG↔ dbbardatasymbol=600519, exchange=SSE;SSE→SH, SZSE→SZ。
实测 schema(VPS 2026-07-23 probe,执行 agent 必读)
DB = C:\sanguo_vnpy_v2\data\quant_trading.db(VPS) / Mac 测试用 fixture 路径。
dbbardata('d') — 唯一行情表,raw 真实价:
列: symbol TEXT, exchange TEXT(SSE/SZSE), datetime TEXT(YYYY-MM-DD HH:MM:SS),
interval TEXT('d'), volume REAL, turnover REAL, open_interest REAL,
open_price REAL, high_price REAL, low_price REAL, close_price REAL
样本: 600519 10056行 2001-08-27~2026-07-22; 000005退市 8146行~2024-04-26; 510300 ETF 3439行
constituent_unified — 成份股并集(无 date!):
列: index_code TEXT(如 '000300'), code TEXT(纯6位如 '000001'), code_name TEXT,
source TEXT('baostock'/'akshare'), in_current INT(0/1), was_removed INT(0/1)
分布: 000300=940(300当前+640被踢) 000905=1803 000016=195 000852=1000(全当前,历史不可补)
399001=702 399005=145 399006=175 399330=150 932000=2000(全当前)
bs_adjust_factor — 复权因子:
列: code TEXT('sh.600519'), dividOperateDate TEXT(YYYY-MM-DD),
foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL
语义: foreAdjustFactor 按除权日分段,最新事件=1.0,递减往历史。qfq[t]=raw[t]*factor[date[t]]。
600519 有 12 事件: 2020-06-24=0.856267 ... 2026-06-26=1.0
valuation_baostock/.parquet — baostock 估值(1990-2026 全年份):
列: symbol(6位), exchange(SH/SZ), date(YYYY-MM-DD), peTTM, psTTM, pcfNcfTTM, pbMRQ, turn, pctChg, isST
注: 无 market_cap/total_share 列! 市值从 static/valuation akshare 补。
static/valuation/_valuation.parquet — akshare 估值(市值/股本来源,5530 文件):
中文列(见 LocalParquetProvider._VAL_COL_MAP): 总市值→total_market_cap, 流通市值→circ_market_cap,
总股本→total_share, PE(TTM)→pe_ttm, 市净率→pb ...
static/{balance,income,cashflow}/_.parquet — akshare 三表(balance 221列/income 170列):通用列: SECUCODE, REPORT_DATE, REPORT_TYPE; balance 有 TOTAL_ASSETS/TOTAL_LIABILITIES/TOTAL_PARENT_EQUITY;
income 有 BASIC_EPS/OPERATE_INCOME/PARENT_NETPROFIT/OPERATE_INCOME_YOY
File Structure
- Create:
sanguo_portfolio/providers/local_unified_provider.py — LocalUnifiedProvider 类(~400行)
- Modify:
sanguo_portfolio/providers/__init__.py — 导出 LocalUnifiedProvider
- Modify:
sanguo_portfolio/runner_backtest.py — build_provider 加 unified 选项(choices + 分支)
- Create:
tests/portfolio/test_local_unified_provider.py — DataProvider 契约单测(fixture: sqlite + tmp parquet)
- Create:
tests/portfolio/conftest.py 追加 — local_unified_provider fixture(若需要,否则在测试文件内建)
- Create:
docs/portfolio_local_unified_provider.md — 使用说明(架构/数据源/接口/复权/治偏差/Mac测试/部署)
Task 0: 代码转换 + DB 连接辅助 + 复权因子构造
Files:
- Create:
sanguo_portfolio/providers/local_unified_provider.py(本 task 建文件骨架 + 模块级辅助函数)
- Test:
tests/portfolio/test_local_unified_provider.py
Interfaces:
-
Produces: jq_to_dbbardata(jq_code) -> (symbol, exchange) / dbbardata_to_jq(symbol, exchange) -> jq_code; _connect(cfg) -> sqlite3.Connection; _build_qfq_factor(code, conn, dates) -> pd.Series(factor indexed by date)
-
Step 1: 写失败测试 — 代码转换
# tests/portfolio/test_local_unified_provider.py
from sanguo_portfolio.providers.local_unified_provider import (
jq_to_dbbardata, dbbardata_to_jq, LocalUnifiedProvider,
)
def test_jq_to_dbbardata_roundtrip():
assert jq_to_dbbardata("600519.XSHG") == ("600519", "SSE")
assert jq_to_dbbardata("000001.XSHE") == ("000001", "SZSE")
assert jq_to_dbbardata("600519") == ("600519", "SSE") # 纯6位推断
assert dbbardata_to_jq("600519", "SSE") == "600519.XSHG"
assert dbbardata_to_jq("000001", "SZSE") == "000001.XSHE"
-
Step 2: 跑测试确认 FAIL — pytest tests/portfolio/test_local_unified_provider.py::test_jq_to_dbbardata_roundtrip -v(ImportError)
-
Step 3: 实现模块骨架 + 代码转换
# sanguo_portfolio/providers/local_unified_provider.py
"""LocalUnifiedProvider: 读方案A 权威数据层, 零 online, 治幸存者偏差(spec §6)。
数据源(全本地 VPS C:\\sanguo_vnpy_v2\\data\\):
- 日线: dbbardata('d') raw + bs_adjust_factor 算前复权(§14.7)
- 成份股: constituent_unified 并集(治偏差,无 date 时点)
- 估值 pe/pb/ps/pcf: valuation_baostock/<year>.parquet(baostock 权威)
- 市值/股本: static/valuation akshare parquet(baostock valuation 无市值列)
- 三表: static/{balance,income,cashflow} akshare parquet
零 online: 不 import baostock 调 online。Mac 测试用 sqlite+parquet fixture。
"""
from __future__ import annotations
import logging, os, sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import pandas as pd
try:
from bullet_trade.data.providers.base import DataProvider # type: ignore
except ImportError:
class DataProvider: # type: ignore[no-redef]
name: str = "base"
logger = logging.getLogger(__name__)
_DEFAULT_DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
_DEFAULT_DATA_DIR = r"C:\sanguo_vnpy_v2\data"
_JQ_SUFFIX_TO_EXC = {"XSHG": "SSE", "XSHE": "SZSE", "SH": "SSE", "SZ": "SZSE"}
_EXC_TO_JQ_SUFFIX = {"SSE": "XSHG", "SZSE": "XSHE"}
def jq_to_dbbardata(jq_code: str) -> tuple[str, str]:
"""600519.XSHG → ('600519', 'SSE')。纯6位按6开头=sh/0,3=sz 推断。"""
s = (jq_code or "").strip()
if "." not in s:
if len(s) == 6:
return s, ("SSE" if s.startswith("6") else "SZSE")
return s, "SSE"
code, suffix = s.split(".", 1)
return code, _JQ_SUFFIX_TO_EXC.get(suffix.upper(), "SSE")
def dbbardata_to_jq(symbol: str, exchange: str) -> str:
"""('600519','SSE') → '600519.XSHG'。"""
jq_suffix = _EXC_TO_JQ_SUFFIX.get(str(exchange).upper(), "XSHG")
return f"{symbol}.{jq_suffix}"
# 复权因子代码转换: 600519.XSHG → 'sh.600519'(bs_adjust_factor.code 格式)
def _jq_to_bs_code(jq_code: str) -> str:
sym, exc = jq_to_dbbardata(jq_code)
prefix = "sh" if exc == "SSE" else "sz"
return f"{prefix}.{sym}"
-
Step 4: 跑测试确认 PASS
-
Step 5: 写失败测试 — 复权因子构造
def test_build_qfq_factor(tmp_path):
# fixture: 2 除权事件, 最新=1.0
import sqlite3
db = tmp_path / "t.db"
c = sqlite3.connect(str(db))
c.execute("CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)")
c.executemany("INSERT INTO bs_adjust_factor VALUES(?,?,?,?,?)", [
("sh.600519", "2024-06-19", 0.90, 0, 0),
("sh.600519", "2025-06-19", 1.00, 0, 0),
])
c.commit(); c.close()
from sanguo_portfolio.providers.local_unified_provider import _build_qfq_factor
dates = pd.to_datetime(["2023-01-01", "2024-07-01", "2025-07-01"])
f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates)
# 2023(早于最早事件)=0.90; 2024-07(between)=0.90; 2025-07(最新后)=1.00
assert abs(f.iloc[0] - 0.90) < 1e-6
assert abs(f.iloc[1] - 0.90) < 1e-6
assert abs(f.iloc[2] - 1.00) < 1e-6
- Step 6: 实现
_build_qfq_factor — asof join 逻辑(每个 date 找 ≤ 的最大 dividOperateDate 的 foreAdjustFactor;早于所有事件用最早;晚于所有用最新):
def _build_qfq_factor(bs_code: str, conn: sqlite3.Connection,
dates: pd.Series) -> pd.Series:
"""构造每个 date 的前复权因子(asof)。qfq[t]=raw[t]*factor[t]。"""
rows = conn.execute(
"SELECT dividOperateDate, foreAdjustFactor FROM bs_adjust_factor "
"WHERE code=? ORDER BY dividOperateDate", (bs_code,)).fetchall()
if not rows:
return pd.Series([1.0] * len(dates), index=dates)
ev_dates = pd.to_datetime([r[0] for r in rows])
factors = [float(r[1]) for r in rows]
out = []
for d in pd.to_datetime(dates):
# 找 <= d 的最大事件; 全部 > d 用最早(第一个); 全部 <= d 用最后一个
mask = ev_dates <= d
out.append(factors[mask.argmax()] if mask.any() else factors[0])
# mask.argmax() 给第一个 True 的索引;但我们要"<= d 的最大事件"= 最后一个 True
# 修正:取最后一个 True
out = []
for d in pd.to_datetime(dates):
mask = ev_dates <= d
idx = int(np.where(mask)[0][-1]) if mask.any() else 0
out.append(factors[idx])
return pd.Series(out, index=pd.to_datetime(dates))
(注意:np 需 import numpy as np。实现时简化为单次循环取最后一个 True 索引。)
- Step 7: 跑测试确认 PASS
- Step 8: Commit —
feat(portfolio): LocalUnifiedProvider 代码转换+复权因子(Task0)
Task 1: get_price(dbbardata raw + 前复权 + panel 长表)
Files: Modify local_unified_provider.py 加 __init__ + get_price; Test 同文件。
Interfaces:
- Consumes: Task0 辅助函数 +
_connect
- Produces:
LocalUnifiedProvider.get_price(security, start_date, end_date, frequency, fields, skip_paused, fq, count, panel, fill_paused) -> DataFrame
策略契约(all_weather 实证):
-
get_price(hold_list, end_date, freq=daily, fields=[close,high_limit], count=1, panel=False) — panel=False 长表需 time/code 列
-
get_price(stocks, freq=1d, fields=[close], count=n, panel=False) — _trend_mean pivot(index=time,columns=code)
-
get_price(stock, freq=1m, fq="pre", count=1, panel=False) — intraday(day 频率回测降级,1m 无数据返空)
-
Step 1: 写失败测试 — get_price daily 单股 + 复权
@pytest.fixture
def unified_provider(tmp_path):
"""造小样本 sqlite + parquet fixture。"""
db = tmp_path / "quant_trading.db"
c = sqlite3.connect(str(db))
c.execute("CREATE TABLE dbbardata(symbol,exchange,datetime,interval,volume,turnover,open_interest,open_price,high_price,low_price,close_price)")
rows = [
("600519","SSE","2024-06-18 00:00:00","d",1000,1e6,0,1000.0,1010.0,990.0,1000.0), # 除权前
("600519","SSE","2024-06-19 00:00:00","d",1000,1e6,0,900.0,910.0,890.0,900.0), # 除权日 raw 跳水
("600519","SSE","2024-06-20 00:00:00","d",1000,1e6,0,910.0,920.0,900.0,910.0),
]
c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
c.execute("CREATE TABLE bs_adjust_factor(code,dividOperateDate,foreAdjustFactor,backAdjustFactor,adjustFactor)")
c.execute("INSERT INTO bs_adjust_factor VALUES('sh.600519','2024-06-19',0.9,0,0)") # 除权日 factor
c.commit(); c.close()
return LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
def test_get_price_raw_vs_qfq(unified_provider):
p = unified_provider
# raw: 除权日 900 跳水
df_raw = p.get_price("600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="raw")
assert len(df_raw) == 3
assert abs(df_raw.loc["2024-06-19", "close"] - 900.0) < 1e-6
# qfq: 06-18 = 1000*0.9 = 900; 06-19/20 = raw(factor=0.9 当 06-19 之后? 用最新段逻辑)
df_qfq = p.get_price("600519.XSHG", start_date="2024-06-18", end_date="2024-06-20", fq="qfq")
assert abs(df_qfq.loc["2024-06-18", "close"] - 900.0) < 1e-6 # 1000*0.9(早于事件用最早factor)
(复权断言:06-18 早于除权日 06-19 → 用 factor 0.9 → 10000.9=900;06-19/20 ≥ 事件日 → factor 取 06-19 的 0.9 → 9000.9=810, 910*0.9=819。实现时按 _build_qfq_factor 语义校准断言。)
-
Step 2: 跑测试确认 FAIL
-
Step 3: 实现 __init__ + get_price
class LocalUnifiedProvider(DataProvider): # type: ignore[misc]
name: str = "sanguo_local_unified"
requires_live_data: bool = False
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
cfg = config or {}
self.db_path: str = cfg.get("db_path", _DEFAULT_DB)
self.data_dir: str = cfg.get("data_dir", _DEFAULT_DATA_DIR)
self._conn: Optional[sqlite3.Connection] = None
self._val_bs_cache: Dict[int, pd.DataFrame] = {} # year -> valuation_baostock
def _connect(self) -> sqlite3.Connection:
if self._conn is None:
self._conn = sqlite3.connect(self.db_path, timeout=30)
self._conn.execute("PRAGMA busy_timeout = 30000")
return self._conn
def get_price(self, security, start_date=None, end_date=None, frequency="daily",
fields=None, skip_paused=False, fq="raw", count=None,
panel=True, fill_paused=True, **kwargs):
freq = str(frequency or "").lower()
if freq not in ("daily", "day", "1d", "d"):
return pd.DataFrame() # 1m/分钟 day 频率回测降级(数据层无 1m)
secs = [security] if isinstance(security, str) else list(security or [])
conn = self._connect()
start_str = self._to_date_str(start_date)
end_str = self._to_date_str(end_date) or datetime.now().strftime("%Y-%m-%d")
frames: Dict[str, pd.DataFrame] = {}
for jq_code in secs:
sym, exc = jq_to_dbbardata(jq_code)
q = "SELECT datetime, open_price, high_price, low_price, close_price, " \
"volume, turnover FROM dbbardata WHERE symbol=? AND exchange=? " \
"AND interval='d' AND datetime>=? AND datetime<=? ORDER BY datetime"
df = pd.read_sql(q, conn, params=(sym, exc, start_str + " 00:00:00", end_str + " 23:59:59"))
if df.empty:
frames[jq_code] = df; continue
df["datetime"] = pd.to_datetime(df["datetime"])
df = df.set_index("datetime")
df.index.name = None
if count:
df = df.tail(count)
# 复权
if fq in ("qfq", "pre", "前复权"):
factor = _build_qfq_factor(_jq_to_bs_code(jq_code), conn, df.index)
for col in ("open_price", "high_price", "low_price", "close_price"):
df[col] = df[col].values * factor.values
# 策略要 close/high_limit 字段名(jq 风格)
df = df.rename(columns={"open_price": "open", "high_price": "high",
"low_price": "low", "close_price": "close"})
# high_limit 不在 dbbardata, 留给 get_current_tick 语义;这里策略 prepare_stock_list 要 high_limit 列
# → 缺失列返 NaN(策略 hit = close==high_limit 不会命中,降级可接受)
if fields:
for f in fields:
if f not in df.columns:
df[f] = float("nan")
df = df[fields]
frames[jq_code] = df
if not frames or all(f.empty for f in frames.values()):
return pd.DataFrame()
if not panel:
parts = []
for jq_code, df in frames.items():
if df.empty:
continue
d = df.reset_index().rename(columns={"datetime": "time"})
d.insert(0, "code", jq_code)
parts.append(d)
return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()
if len(frames) == 1:
return next(iter(frames.values()))
return pd.concat(frames, axis=1)
- Step 4: 跑测试确认 PASS
- Step 5: 写失败测试 — panel=False 多股长表 + count
def test_get_price_panel_false_multi(unified_provider):
df = unified_provider.get_price("600519.XSHG", end_date="2024-06-20", count=2, panel=False, fields=["close"])
assert "code" in df.columns and "time" in df.columns
assert len(df) == 2
- Step 6: 实现(Step 3 已含 panel 分支),跑 PASS
- Step 7: Commit —
feat(portfolio): LocalUnifiedProvider get_price+前复权(Task1)
Task 2: get_index_stocks + get_constituent(constituent_unified 并集,治偏差)
Files: Modify local_unified_provider.py; Test 同文件。
Interfaces:
-
Produces: get_index_stocks(index_symbol, date) -> List[str] + get_constituent(index, date) -> List[str](语义别名)
-
Step 1: 写失败测试
def test_get_index_stocks_union(tmp_path):
db = tmp_path / "t.db"; c = sqlite3.connect(str(db))
c.execute("CREATE TABLE constituent_unified(index_code TEXT,code TEXT,code_name TEXT,source TEXT,in_current INT,was_removed INT)")
c.executemany("INSERT INTO constituent_unified VALUES(?,?,?,?,?,?)", [
("000300", "600519", "贵州茅台", "baostock", 1, 0),
("000300", "000001", "平安银行", "baostock", 1, 0),
("000300", "600811", "退市股", "baostock", 0, 1), # 被踢
])
c.commit(); c.close()
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
stocks = p.get_index_stocks("000300.XSHG", "2020-01-01")
assert set(stocks) == {"600519.XSHG", "000001.XSHE", "600811.SH"} # 并集含被踢
# date 参数不报错(并集模型忽略)
assert p.get_constituent("000300", None) == stocks # 别名
-
Step 2: 跑测试确认 FAIL
-
Step 3: 实现 — 查 constituent_unified,index_code 匹配(去 .XXXX 后缀),返回 in_current=1 OR was_removed=1 的并集,code→jq_code:
def get_index_stocks(self, index_symbol, date=None) -> List[str]:
idx = index_symbol.split(".")[0] if "." in str(index_symbol) else str(index_symbol)
conn = self._connect()
rows = conn.execute(
"SELECT code FROM constituent_unified WHERE index_code=? "
"AND (in_current=1 OR was_removed=1)", (idx,)).fetchall()
out = []
for (code,) in rows:
code = str(code).strip()
if len(code) != 6:
continue
exc = "SSE" if code.startswith("6") else "SZSE"
out.append(dbbardata_to_jq(code, exc))
return out
def get_constituent(self, index, date=None) -> List[str]:
"""spec §6 语义别名 = get_index_stocks。"""
return self.get_index_stocks(index, date)
- Step 4: 跑测试 PASS
- Step 5: Commit —
feat(portfolio): LocalUnifiedProvider 成份股并集治偏差(Task2)
Task 3: get_fundamentals_df(valuation_baostock + static akshare + 三表)
Files: Modify local_unified_provider.py; Test 同文件 + tmp parquet fixture。
Interfaces:
- Produces:
get_fundamentals_df(stocks, date) -> DataFrame 列对齐 _FUNDAMENTAL_COLUMNS
数据源映射:
-
pe_ratio/pb_ratio/ps_ratio/pcf_ratio ← valuation_baostock parquet(peTTM/pbMRQ/psTTM/pcfNcfTTM,baostock 权威)
-
market_cap/circulating_market_cap ← static/valuation akshare parquet(total_market_cap/circ_market_cap,baston 无市值)
-
三表字段(eps/net_profit_margin/total_liability 等) ← static/{balance,income} akshare parquet(复用 LocalParquetProvider 读法)
-
Step 1: 写失败测试 — 估值字段从 valuation_baostock
def test_get_fundamentals_valuation(tmp_path):
# valuation_baostock/2024.parquet
vdir = tmp_path / "valuation_baostock"; vdir.mkdir()
pd.DataFrame({"symbol":["600519"],"exchange":["SH"],"date":["2024-09-30"],
"peTTM":[25.0],"psTTM":[15.0],"pcfNcfTTM":[20.0],"pbMRQ":[7.5],
"turn":[0.1],"pctChg":[1.0],"isST":[0]}).to_parquet(vdir/"2024.parquet")
# static/valuation akshare(市值)
sdir = tmp_path / "static" / "valuation"; sdir.mkdir(parents=True)
pd.DataFrame({"数据日期":["2024-09-30"],"总市值":[2e12],"流通市值":[2e12],"总股本":[1.256e9],
"PE(TTM)":[25],"市净率":[7.5]}).to_parquet(sdir/"600519.SH_valuation.parquet")
p = LocalUnifiedProvider({"db_path": str(tmp_path/"t.db"), "data_dir": str(tmp_path)})
df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
assert abs(df.loc["600519.XSHG","pe_ratio"] - 25.0) < 1e-6 # baostock 权威
assert abs(df.loc["600519.XSHG","pb_ratio"] - 7.5) < 1e-6
assert abs(df.loc["600519.XSHG","market_cap"] - 2e4) < 1 # 2e12元→2e4亿
-
Step 2: 跑测试确认 FAIL
-
Step 3: 实现 — 读 valuation_baostock parquet(year from date)+ static/valuation akshare;合并对齐 _FUNDAMENTAL_COLUMNS(复用 LocalParquetProvider 的 _VAL_COL_MAP / to_yi / 三表读法,import 复用):
from .local_parquet_provider import (_VAL_COL_MAP, jq_to_file_code,
_to_float, _or_nan, _pct_to_decimal, _FUNDAMENTAL_COLUMNS)
from ..factors.valuation import to_yi
def get_fundamentals_df(self, stocks, date=None) -> pd.DataFrame:
if not stocks:
return pd.DataFrame(columns=_FUNDAMENTAL_COLUMNS)
date_str = self._to_date_str(date) or datetime.now().strftime("%Y-%m-%d")
rows = [self._build_fundamental_row(s, date_str) for s in stocks]
df = pd.DataFrame(rows, columns=_FUNDAMENTAL_COLUMNS)
if "code" in df.columns:
df = df.set_index("code", drop=False)
return df
def _read_valuation_baostock(self, year: int) -> pd.DataFrame:
if year in self._val_bs_cache:
return self._val_bs_cache[year]
p = os.path.join(self.data_dir, "valuation_baostock", f"{year}.parquet")
df = pd.read_parquet(p) if os.path.exists(p) else pd.DataFrame()
self._val_bs_cache[year] = df
return df
def _build_fundamental_row(self, jq_code, date_str) -> Dict[str, Any]:
sym, exc = jq_to_dbbardata(jq_code)
fc = jq_to_file_code(jq_code) # 600519.SH(static akshare 文件名)
row: Dict[str, Any] = {"code": jq_code}
# 1. pe/pb/ps/pcf ← valuation_baostock(baostock 权威)
year = int(date_str[:4])
vbs = self._read_valuation_baostock(year)
if not vbs.empty:
sub = vbs[(vbs["symbol"].astype(str) == sym) & (vbs["date"].astype(str) <= date_str)]
vrow = sub.iloc[-1] if not sub.empty else None
else:
vrow = None
def gbs(k):
return _to_float(vrow.get(k)) if vrow is not None else None
row["pe_ratio"] = _or_nan(gbs("peTTM"))
row["pb_ratio"] = _or_nan(gbs("pbMRQ"))
row["ps_ratio"] = _or_nan(gbs("psTTM"))
row["pcf_ratio"] = _or_nan(gbs("pcfNcfTTM"))
# 2. 市值/股本 + 三表 ← static akshare(复用 LocalParquetProvider 读法)
# 复用:直接实例化 LocalParquetProvider 读 static 部分,或内联读 static/valuation
ak_val = self._read_akshare_valuation(fc, date_str) # 返 renamed Series
mkt = _to_float(ak_val.get("total_market_cap")) if ak_val is not None else None
circ = _to_float(ak_val.get("circ_market_cap")) if ak_val is not None else None
row["market_cap"] = to_yi(mkt) if mkt else float("nan")
row["circulating_market_cap"] = to_yi(circ) if circ else float("nan")
# 3. 三表(income/balance)— 复用 LocalParquetProvider._read_quarter + 字段提取
# 简化:委托一个内部 LocalParquetProvider 实例读三表部分(eps/margin/liability)
lpp = self._get_lpp_helper()
inc = lpp._latest_row_before(lpp._read_quarter("income", fc), "REPORT_DATE", date_str)
bal = lpp._latest_row_before(lpp._read_quarter("balance", fc), "REPORT_DATE", date_str)
row["eps"] = _or_nan(_to_float(inc.get("BASIC_EPS")) if inc is not None else None)
# ... net_profit_margin/total_liability/roe 等(照 LocalParquetProvider._build_fundamental_row 逻辑)
return row
(实现时:_get_lpp_helper() 返一个复用的 LocalParquetProvider(config) 实例读 static 三表;_read_akshare_valuation 复用 LocalParquetProvider._read_valuation。DRY:不重写三表/akshare valuation 逻辑,委托 LocalParquetProvider。pe/pb 改 baostock 源覆盖 akshare 的。)
- Step 4: 跑测试 PASS
- Step 5: 写测试 — 三表字段(eps/market_cap 全 _FUNDAMENTAL_COLUMNS 有值不 NaN)
- Step 6: 实现 + PASS
- Step 7: Commit —
feat(portfolio): LocalUnifiedProvider fundamentals baostock估值+akshare市值(Task3)
Task 4: 辅助方法(trade_days/all_securities/security_info/current_tick/split_dividend)
Files: Modify local_unified_provider.py; Test 同文件。
-
Step 1-2: 写失败测试 + FAIL — get_trade_days(count=2) 返 datetime list;get_security_info 返 display_name/start_date;get_current_tick 返 close+high_limit;get_split_dividend 返 bs_adjust_factor 事件;get_all_securities 返 dbbardata distinct symbol。
-
Step 3: 实现:
get_trade_days: 读 dbbardata 某 symbol(如 600519)distinct datetime,filter/count。
get_security_info: dbbardata min/max datetime → start/end_date;display_name 从 constituent_unified code_name 或 code。
get_current_tick: dbbardata 最近 close + valuation_baostock 最近 pctChg → high_limit=close×1.1(ST 0.05)。
get_split_dividend: bs_adjust_factor → events(dividOperateDate + adjustFactor)。
get_all_securities: dbbardata distinct symbol → DataFrame。
-
Step 4: 跑测试 PASS
-
Step 5: Commit — feat(portfolio): LocalUnifiedProvider 辅助方法(Task4)
Task 5: 接线(init 导出 + runner build_provider 加 unified)
Files: Modify sanguo_portfolio/providers/__init__.py; Modify sanguo_portfolio/runner_backtest.py。
- Step 1: __init__.py 加导出
from .local_unified_provider import LocalUnifiedProvider
__all__ = ["SanguoMiniQmtProvider", "BaostockProvider", "LocalParquetProvider", "LocalUnifiedProvider"]
- Step 2: runner_backtest build_provider 加 unified
# parse_args choices 加 "unified"; build_provider 加分支
p.add_argument("--provider", default="local", choices=["local", "baostock", "miniqmt", "unified"], ...)
# build_provider:
from .providers import LocalUnifiedProvider
if name == "unified":
return LocalUnifiedProvider(cfg)
- Step 3: 跑
pytest tests/portfolio/ -v 全绿(回归)
- Step 4: Commit —
feat(portfolio): 接线 LocalUnifiedProvider 到 runner(Task5)
Task 6: 使用说明 + VPS E2E 验证
Files: Create docs/portfolio_local_unified_provider.md; VPS 跑 python -m sanguo_portfolio.runner_backtest --provider unified --start 2024-01-01 --end 2024-03-31 --max-pool 20。
-
Step 1: 写使用说明 docs/portfolio_local_unified_provider.md(其他 session 直用)— 含:
- 一句话定位(读方案A权威层/零online/治偏差)
- 数据源映射表(每接口→哪张表/parquet)
- 接口清单(DataProvider 接口 + get_constituent)
- 复权说明(raw存储+消费端按bs_adjust_factor算qfq;fq参数 raw/qfq)
- 幸存者偏差说明(constituent_unified 并集模型,治纯当前偏差,有轻微前视,date 参数忽略;中证1000/2000只快照永久gap)
- Mac 测试(fixture,零VPS依赖)
- 部署/运行(runner --provider unified;VPS 数据依赖 dbbardata/constituent_unified/valuation_baostock/static)
- 已知限制(high_limit 列 NaN→prepare_stock_list 涨停识别降级;1m 无数据;三表委托 LocalParquetProvider)
- 与旧 provider 关系(LocalParquetProvider/BaostockProvider 保留,unified 是方案A 后推荐)
-
Step 2: VPS E2E — rsync 代码到 VPS,跑 --provider unified --max-pool 20 小样本回测,确认:
- get_price 读 dbbardata 出 K 线(含退市)
- get_index_stocks 出并集成份股
- get_fundamentals_df 出市值+pe/pb
- 回测不崩,有选股+指标输出
-
Step 3: Commit — docs(portfolio): LocalUnifiedProvider 使用说明+VPS E2E(Task6)
Self-Review(plan 自检)
- Spec 覆盖: spec §6 接口(get_daily/get_constituent/get_fundamentals/...)— get_constituent 别名✓;get_price 覆盖 get_daily+get_etf_daily(都读 dbbardata,ETF 也在);get_fundamentals_df ✓;其余 §6 方法(industry/longhubang/instrument)数据层未就绪(P1),使用说明标注 NotImplementedError。✓
- 方案A §14 一致: dbbardata 唯一行情✓;constituent_unified 治偏差✓;valuation_baostock pe/pb✓;raw+factor 复权✓;零online✓。
- 类型一致:
_build_qfq_factor(code, conn, dates) -> Series 在 Task0/Task1 调用签名一致✓。
- 占位扫描: Task3 的
_get_lpp_helper/_read_akshare_valuation 标了"复用 LocalParquetProvider",实现 agent 须内联或委托,不留空✓。
- 风险: get_price 的 high_limit 列缺失(NaN)→策略 prepare_stock_list 涨停识别降级,使用说明标注(Task6)✓。
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-07-23-local-unified-provider.md.