283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""LocalUnifiedProvider 单元测试(spec §6 使用层)。
|
|
|
|
Mac 本地 TDD: sqlite tmp_path + tmp parquet fixture,零 VPS 依赖,零网络。
|
|
覆盖:
|
|
- ``jq_to_dbbardata`` / ``dbbardata_to_jq`` / ``_jq_to_bs_code`` 代码转换
|
|
- ``_build_qfq_factor`` 复权因子构造(asof)
|
|
- ``get_price`` dbbardata('d') raw + fq='qfq' 前复权 + panel=False 长表
|
|
- ``get_index_stocks`` constituent_unified 并集(治偏差,无 date 时点)
|
|
- ``get_fundamentals_df`` pe/pb/ps/pcf←valuation_baostock + 市值←static akshare + 三表委托 LocalParquetProvider
|
|
- 辅助方法 get_trade_days / get_all_securities / get_security_info / get_current_tick / get_split_dividend
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from typing import Any, Dict, List
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from sanguo_portfolio.providers.local_unified_provider import (
|
|
jq_to_dbbardata,
|
|
dbbardata_to_jq,
|
|
_jq_to_bs_code,
|
|
_build_qfq_factor,
|
|
LocalUnifiedProvider,
|
|
)
|
|
|
|
|
|
# ======================== Task 0: 代码转换 ========================
|
|
class TestCodeFormat:
|
|
def test_jq_to_dbbardata_sh(self):
|
|
assert jq_to_dbbardata("600519.XSHG") == ("600519", "SSE")
|
|
|
|
def test_jq_to_dbbardata_sz(self):
|
|
assert jq_to_dbbardata("000001.XSHE") == ("000001", "SZSE")
|
|
|
|
def test_jq_to_dbbardata_pure_digit_sh(self):
|
|
# 6 开头 → SSE
|
|
assert jq_to_dbbardata("600519") == ("600519", "SSE")
|
|
|
|
def test_jq_to_dbbardata_pure_digit_sz(self):
|
|
# 0/3 开头 → SZSE
|
|
assert jq_to_dbbardata("000001") == ("000001", "SZSE")
|
|
assert jq_to_dbbardata("300001") == ("300001", "SZSE")
|
|
|
|
def test_dbbardata_to_jq_sh(self):
|
|
assert dbbardata_to_jq("600519", "SSE") == "600519.XSHG"
|
|
|
|
def test_dbbardata_to_jq_sz(self):
|
|
assert dbbardata_to_jq("000001", "SZSE") == "000001.XSHE"
|
|
|
|
def test_round_trip_jq_to_dbbardata_to_jq(self):
|
|
original = "600519.XSHG"
|
|
sym, exc = jq_to_dbbardata(original)
|
|
assert dbbardata_to_jq(sym, exc) == original
|
|
|
|
def test_jq_to_bs_code_sh(self):
|
|
# 600519.XSHG → 'sh.600519'(bs_adjust_factor.code 格式)
|
|
assert _jq_to_bs_code("600519.XSHG") == "sh.600519"
|
|
|
|
def test_jq_to_bs_code_sz(self):
|
|
assert _jq_to_bs_code("000001.XSHE") == "sz.000001"
|
|
|
|
|
|
# ======================== Task 0: 复权因子 asof ========================
|
|
class TestBuildQfqFactor:
|
|
def test_asof_before_all_events_uses_earliest(self, tmp_path):
|
|
# 2 除权事件, 最新=1.0
|
|
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()
|
|
# 2023 早于所有事件 → 用最早 factor=0.90
|
|
dates = pd.to_datetime(["2023-01-01"])
|
|
f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates)
|
|
assert abs(f.iloc[0] - 0.90) < 1e-6
|
|
|
|
def test_asof_between_events(self, tmp_path):
|
|
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()
|
|
# 2024-07 在两事件之间 → ≤ 的最大事件是 2024-06-19, factor=0.90
|
|
dates = pd.to_datetime(["2024-07-01"])
|
|
f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates)
|
|
assert abs(f.iloc[0] - 0.90) < 1e-6
|
|
|
|
def test_asof_after_all_events_uses_latest(self, tmp_path):
|
|
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()
|
|
# 2025-07 晚于所有事件 → 最新 factor=1.00
|
|
dates = pd.to_datetime(["2025-07-01"])
|
|
f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates)
|
|
assert abs(f.iloc[0] - 1.00) < 1e-6
|
|
|
|
def test_no_events_returns_ones(self, tmp_path):
|
|
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.commit()
|
|
c.close()
|
|
# 无事件 → 全 1.0
|
|
dates = pd.to_datetime(["2024-01-01", "2024-06-01"])
|
|
f = _build_qfq_factor("sh.600519", sqlite3.connect(str(db)), dates)
|
|
assert len(f) == 2
|
|
assert abs(f.iloc[0] - 1.0) < 1e-6
|
|
assert abs(f.iloc[1] - 1.0) < 1e-6
|
|
|
|
|
|
# ======================== Task 1: get_price fixture ========================
|
|
@pytest.fixture
|
|
def unified_provider(tmp_path):
|
|
"""造小样本 sqlite fixture: dbbardata 日线 + bs_adjust_factor。"""
|
|
db = tmp_path / "quant_trading.db"
|
|
c = sqlite3.connect(str(db))
|
|
c.execute(
|
|
"CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, "
|
|
"interval TEXT, volume REAL, turnover REAL, open_interest REAL, "
|
|
"open_price REAL, high_price REAL, low_price REAL, close_price REAL)"
|
|
)
|
|
# 600519: 除权日 2024-06-19 raw close 1000 → 900 跳水
|
|
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),
|
|
("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)
|
|
# 复权因子: 2024-06-19 起除权, factor=0.9
|
|
c.execute(
|
|
"CREATE TABLE bs_adjust_factor(code TEXT, dividOperateDate TEXT, "
|
|
"foreAdjustFactor REAL, backAdjustFactor REAL, adjustFactor REAL)"
|
|
)
|
|
c.execute(
|
|
"INSERT INTO bs_adjust_factor VALUES('sh.600519','2024-06-19',0.9,0,0)"
|
|
)
|
|
c.commit()
|
|
c.close()
|
|
return LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
|
|
|
|
|
# ======================== Task 1: get_price daily ========================
|
|
class TestGetPrice:
|
|
def test_raw_keeps_original_prices(self, unified_provider):
|
|
# raw: 除权日 close=900 跳水(原值)
|
|
df = unified_provider.get_price(
|
|
"600519.XSHG",
|
|
start_date="2024-06-18",
|
|
end_date="2024-06-20",
|
|
fq="raw",
|
|
)
|
|
assert len(df) == 3
|
|
assert abs(df.loc["2024-06-19", "close"] - 900.0) < 1e-6
|
|
|
|
def test_qfq_earlier_date_uses_earliest_factor(self, unified_provider):
|
|
# 2024-06-18 早于除权日 06-19 → factor=0.9 → 1000*0.9=900
|
|
df = unified_provider.get_price(
|
|
"600519.XSHG",
|
|
start_date="2024-06-18",
|
|
end_date="2024-06-20",
|
|
fq="qfq",
|
|
)
|
|
assert abs(df.loc["2024-06-18", "close"] - 900.0) < 1e-6
|
|
|
|
def test_qfq_after_event_uses_event_factor(self, unified_provider):
|
|
# 2024-06-19/20 ≥ 除权日 → factor=0.9 → 900*0.9=810, 910*0.9=819
|
|
df = unified_provider.get_price(
|
|
"600519.XSHG",
|
|
start_date="2024-06-19",
|
|
end_date="2024-06-20",
|
|
fq="qfq",
|
|
)
|
|
assert abs(df.loc["2024-06-19", "close"] - 810.0) < 1e-6
|
|
assert abs(df.loc["2024-06-20", "close"] - 819.0) < 1e-6
|
|
|
|
def test_panel_false_returns_long_table(self, unified_provider):
|
|
# panel=False → 长表含 time + code 列
|
|
df = unified_provider.get_price(
|
|
"600519.XSHG",
|
|
end_date="2024-06-20",
|
|
count=2,
|
|
panel=False,
|
|
fields=["close"],
|
|
)
|
|
assert "code" in df.columns
|
|
assert "time" in df.columns
|
|
assert len(df) == 2
|
|
assert "600519.XSHG" in set(df["code"])
|
|
|
|
def test_fields_with_missing_column_fills_nan(self, unified_provider):
|
|
# high_limit 不在 dbbardata → NaN 降级(策略 prepare_stock_list 涨停识别降级)
|
|
df = unified_provider.get_price(
|
|
"600519.XSHG",
|
|
end_date="2024-06-20",
|
|
count=1,
|
|
panel=False,
|
|
fields=["close", "high_limit"],
|
|
)
|
|
assert "high_limit" in df.columns
|
|
# high_limit NaN(不崩)
|
|
assert pd.isna(df.iloc[0]["high_limit"]) or df.iloc[0]["high_limit"] != df.iloc[0]["high_limit"]
|
|
|
|
def test_minute_frequency_returns_empty(self, unified_provider):
|
|
# 1m 频率无数据 → 返空 DataFrame
|
|
df = unified_provider.get_price(
|
|
"600519.XSHG",
|
|
end_date="2024-06-20",
|
|
frequency="1m",
|
|
count=1,
|
|
panel=False,
|
|
)
|
|
assert isinstance(df, pd.DataFrame)
|
|
assert df.empty
|
|
|
|
def test_multi_stocks_panel_false(self, tmp_path):
|
|
# 多股 panel=False → 长表含 code 列区分
|
|
db = tmp_path / "t.db"
|
|
c = sqlite3.connect(str(db))
|
|
c.execute(
|
|
"CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, "
|
|
"interval TEXT, volume REAL, turnover REAL, open_interest REAL, "
|
|
"open_price REAL, high_price REAL, low_price REAL, close_price REAL)"
|
|
)
|
|
rows = [
|
|
("600519", "SSE", "2024-06-19 00:00:00", "d", 1000, 1e6, 0,
|
|
900.0, 910.0, 890.0, 900.0),
|
|
("000001", "SZSE", "2024-06-19 00:00:00", "d", 1000, 1e6, 0,
|
|
10.0, 10.5, 9.8, 10.2),
|
|
]
|
|
c.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
|
|
c.commit()
|
|
c.close()
|
|
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
|
|
df = p.get_price(
|
|
["600519.XSHG", "000001.XSHE"],
|
|
end_date="2024-06-19",
|
|
count=1,
|
|
panel=False,
|
|
fields=["close"],
|
|
)
|
|
assert len(df) == 2
|
|
assert set(df["code"]) == {"600519.XSHG", "000001.XSHE"}
|