feat(portfolio): LocalUnifiedProvider fundamentals baostock估值+akshare市值(Task3)

This commit is contained in:
2026-07-23 08:11:35 +08:00
parent b46a0c26c8
commit 011c9d4ddb
2 changed files with 232 additions and 0 deletions
@@ -340,3 +340,135 @@ class TestGetIndexStocks:
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
stocks = p.get_index_stocks("000300", "2020-01-01")
assert len(stocks) == 3
# ======================== Task 3: get_fundamentals_df fixture ========================
def _make_fundamentals_fixture(tmp_path):
"""造 valuation_baostock + static/valuation + static/income + static/balance 样本。"""
# 1. valuation_baostock/2024.parquet(baostock 权威: pe/pb/ps/pcf)
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")
# 2. static/valuation akshare(市值/股本)
sdir = tmp_path / "static" / "valuation"
sdir.mkdir(parents=True)
pd.DataFrame({
"数据日期": ["2024-09-30"],
"总市值": [2e12],
"流通市值": [1.5e12],
"总股本": [1.256e9],
"PE(TTM)": [25.0],
"市净率": [7.5],
}).to_parquet(sdir / "600519.SH_valuation.parquet")
# 3. static/income akshare(eps + yoy + net_profit)
idir = tmp_path / "static" / "income"
idir.mkdir(parents=True)
pd.DataFrame({
"SECUCODE": ["600519.SH"],
"REPORT_DATE": ["2024-09-30"],
"REPORT_TYPE": ["Q3"],
"BASIC_EPS": [41.0],
"OPERATE_INCOME": [3.7e10],
"PARENT_NETPROFIT": [9.5e9],
"OPERATE_INCOME_YOY": [15.0],
"OPERATE_PROFIT_YOY": [14.0],
}).to_parquet(idir / "600519.SH_income.parquet")
# 4. static/balance akshare(资产/负债/权益)
bdir = tmp_path / "static" / "balance"
bdir.mkdir(parents=True)
pd.DataFrame({
"SECUCODE": ["600519.SH"],
"REPORT_DATE": ["2024-09-30"],
"REPORT_TYPE": ["Q3"],
"TOTAL_ASSETS": [2.5e11],
"TOTAL_LIABILITIES": [5.4e10],
"TOTAL_PARENT_EQUITY": [2.2e11],
"SURPLUS_RESERVE": [8e10],
"UNASSIGN_RPOFIT": [7e10],
}).to_parquet(bdir / "600519.SH_balance.parquet")
db = tmp_path / "t.db"
c = sqlite3.connect(str(db))
c.execute("CREATE TABLE dbbardata(symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT)")
c.execute("INSERT INTO dbbardata VALUES('600519','SSE','2024-09-30 00:00:00','d')")
c.commit()
c.close()
return db
# ======================== Task 3: get_fundamentals_df ========================
class TestGetFundamentals:
def test_pe_pb_from_baostock(self, tmp_path):
db = _make_fundamentals_fixture(tmp_path)
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
assert not df.empty
# baostock 权威: peTTM=25 / pbMRQ=7.5
assert abs(df.loc["600519.XSHG", "pe_ratio"] - 25.0) < 1e-6
assert abs(df.loc["600519.XSHG", "pb_ratio"] - 7.5) < 1e-6
assert abs(df.loc["600519.XSHG", "ps_ratio"] - 15.0) < 1e-6
assert abs(df.loc["600519.XSHG", "pcf_ratio"] - 20.0) < 1e-6
def test_market_cap_from_akshare(self, tmp_path):
# baostock valuation 无市值列 → 从 static/valuation akshare 补
db = _make_fundamentals_fixture(tmp_path)
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
# 2e12 元 → 2e4 亿
assert abs(df.loc["600519.XSHG", "market_cap"] - 2e4) < 1
# 1.5e12 元 → 1.5e4 亿
assert abs(df.loc["600519.XSHG", "circulating_market_cap"] - 1.5e4) < 1
def test_three_tables_delegated(self, tmp_path):
# 三表(income/balance)从 static akshare 读(委托 LocalParquetProvider)
db = _make_fundamentals_fixture(tmp_path)
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
row = df.loc["600519.XSHG"]
# eps 来自 income.BASIC_EPS
assert abs(row["eps"] - 41.0) < 1e-6
# 总负债 5.4e10 元 → 540 亿
assert abs(row["total_liability"] - 540.0) < 1
# 归母权益 2.2e11 元 → 2200 亿
assert abs(row["total_sheet_owner_equities"] - 2200.0) < 1
# 留存收益 = 盈余公积 8e10 + 未分配利润 7e10 = 1.5e11 元 → 1500 亿
assert abs(row["retained_profit"] - 1500.0) < 1
# OPERATE_INCOME_YOY 15.0% → 0.15
assert abs(row["inc_revenue_year_on_year"] - 0.15) < 1e-6
def test_required_columns_present(self, tmp_path):
db = _make_fundamentals_fixture(tmp_path)
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
df = p.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
# _FUNDAMENTAL_COLUMNS(对齐策略 all_weather)
expected = [
"code", "market_cap", "circulating_market_cap",
"pe_ratio", "pb_ratio", "ps_ratio", "pcf_ratio",
"roe", "roa", "eps", "gross_profit_margin", "net_profit_margin",
"inc_revenue_year_on_year", "inc_operation_profit_year_on_year",
"inc_total_revenue_year_on_year",
"total_liability", "total_sheet_owner_equities", "retained_profit",
"roic",
]
for col in expected:
assert col in df.columns, f"missing col: {col}"
def test_empty_stocks_returns_empty(self, tmp_path):
db = _make_fundamentals_fixture(tmp_path)
p = LocalUnifiedProvider({"db_path": str(db), "data_dir": str(tmp_path)})
df = p.get_fundamentals_df([], date="2024-09-30")
assert df.empty