603 lines
28 KiB
Python
603 lines
28 KiB
Python
"""SanguoMiniQmtProvider 单元测试(mock xtquant)。
|
||
|
||
bullet-trade 装了(0.2.0+),MiniQMTProvider 基类可继承。
|
||
xtquant 没 装,通过 mock_xtquant fixture 注入 sys.modules。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from unittest.mock import MagicMock
|
||
|
||
import pandas as pd
|
||
import pytest
|
||
|
||
from sanguo_portfolio import SanguoMiniQmtProvider
|
||
|
||
|
||
pytestmark = pytest.mark.requires_bullet_trade
|
||
|
||
|
||
class TestSanguoMiniQmtProviderInstantiation:
|
||
def test_can_instantiate_with_mock_xtquant(self, mock_xtquant):
|
||
# Arrange + Act
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
# Assert
|
||
assert provider.name == "sanguo_miniqmt"
|
||
# 应继承 MiniQMTProvider
|
||
from bullet_trade.data.providers.miniqmt import MiniQMTProvider
|
||
assert isinstance(provider, MiniQMTProvider)
|
||
|
||
|
||
class TestGetFundamentalsDf:
|
||
def test_returns_dataframe_with_required_columns(self, mock_xtquant):
|
||
# Arrange
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
stocks = ["600519.XSHG", "601318.XSHG"]
|
||
# Act
|
||
df = provider.get_fundamentals_df(stocks, date="2024-09-30")
|
||
# Assert
|
||
assert isinstance(df, pd.DataFrame)
|
||
assert len(df) == 2
|
||
# 核心列都在
|
||
for col in [
|
||
"code", "market_cap", "circulating_market_cap",
|
||
"pe_ratio", "pb_ratio", "ps_ratio", "pcf_ratio",
|
||
"roe", "roa", "eps",
|
||
"total_liability", "total_sheet_owner_equities", "retained_profit",
|
||
"roic",
|
||
]:
|
||
assert col in df.columns, f"missing col: {col}"
|
||
# index 是 jq-style code
|
||
assert "600519.XSHG" in df.index
|
||
|
||
def test_empty_stocks_returns_empty_df(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df([], date="2024-09-30")
|
||
assert isinstance(df, pd.DataFrame)
|
||
assert len(df) == 0
|
||
# 空表也要有列定义,方便上层 select
|
||
assert "code" in df.columns
|
||
|
||
def test_market_cap_in_yi_unit(self, mock_xtquant):
|
||
"""close × total_capital / 1e8 = 亿元。茅台 1600 × 12.56e8 / 1e8 = 20096 亿。"""
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
mc = float(df.iloc[0]["market_cap"])
|
||
# 茅台市值应在 20000 亿左右(允许 close 1600±10)
|
||
assert 19000 < mc < 22000, f"market_cap 异常: {mc}"
|
||
|
||
def test_pe_ratio_finite_for_profitable_stock(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
pe = float(df.iloc[0]["pe_ratio"])
|
||
assert math.isfinite(pe)
|
||
assert pe > 0
|
||
|
||
def test_roic_computed(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
roic = float(df.iloc[0]["roic"])
|
||
# 茅台 mock 数据:oper=1.2e10, tax=25%, eqy=2.2e11, debt=0, cash=1.7e11
|
||
# NOPAT = 1.2e10 * 0.75 = 9e9
|
||
# IC = 2.2e11 + 0 - 1.7e11 = 5e10
|
||
# ROIC = 9e9 / 5e10 = 0.18
|
||
assert 0.05 < roic < 0.5, f"ROIC 异常: {roic}"
|
||
|
||
def test_financial_data_failure_returns_empty_df(self, mock_xtquant):
|
||
"""xtdata.get_financial_data 抛异常时返空表(不崩)。"""
|
||
mock_xtquant["xtdata"].get_financial_data.side_effect = Exception("QMT offline")
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
assert df.empty
|
||
|
||
|
||
class TestFundamentalsMarketCapLocalization:
|
||
"""2026-08-24 P0: 市值改本地估值委托, 开盘不再依赖盘中 Capital 下载。
|
||
|
||
实锤: 开盘 Capital 下载常超时 → close×total_capital=NaN → 策略 sort_values
|
||
无操作保持代码序, 平安银行(≈3800亿)混进 small_cap 买入清单。修 = market_cap/
|
||
circulating_market_cap 优先本地 unified 估值 parquet(EOD, 亿元同单位, 全池
|
||
同一时点口径); 本地缺/异常静默回退 xt Capital 路径。"""
|
||
|
||
@staticmethod
|
||
def _local_unified(rows=None, side_effect=None):
|
||
m = MagicMock()
|
||
if side_effect is not None:
|
||
m.get_fundamentals_df.side_effect = side_effect
|
||
else:
|
||
m.get_fundamentals_df.return_value = pd.DataFrame(rows or [])
|
||
return m
|
||
|
||
def test_local_market_cap_wins_over_capital_path(self, mock_xtquant):
|
||
"""本地有值 → 覆盖 close×Capital(全池同口径优先, 非「仅补 NaN」)。"""
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
provider._unified = self._local_unified([
|
||
{"code": "600519.XSHG", "market_cap": 3500.0,
|
||
"circulating_market_cap": 3400.0},
|
||
])
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
assert abs(float(df.iloc[0]["market_cap"]) - 3500.0) < 1e-6
|
||
assert abs(float(df.iloc[0]["circulating_market_cap"]) - 3400.0) < 1e-6
|
||
|
||
def test_local_fills_nan_when_capital_missing(self, mock_xtquant):
|
||
"""Capital 缺(盘中下载超时形态) → 本地补上, 不再 NaN。"""
|
||
mock_xtquant["xtdata"].get_financial_data.return_value = {} # 无任何表
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
provider._unified = self._local_unified([
|
||
{"code": "600519.XSHG", "market_cap": 21000.0,
|
||
"circulating_market_cap": 21000.0},
|
||
])
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
assert abs(float(df.iloc[0]["market_cap"]) - 21000.0) < 1e-6
|
||
|
||
def test_fallback_to_capital_when_local_empty(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
provider._unified = self._local_unified([])
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
mc = float(df.iloc[0]["market_cap"])
|
||
assert 19000 < mc < 22000 # close×Capital 原路径(≈20096 亿)
|
||
|
||
def test_fallback_when_local_raises(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
provider._unified = self._local_unified(side_effect=RuntimeError("parquet io"))
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
assert 19000 < float(df.iloc[0]["market_cap"]) < 22000
|
||
|
||
def test_local_nan_keeps_capital_value(self, mock_xtquant):
|
||
"""本地 NaN(新股未入估值表)不清掉可算值; 同行其它列正常覆盖。"""
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
provider._unified = self._local_unified([
|
||
{"code": "600519.XSHG", "market_cap": float("nan"),
|
||
"circulating_market_cap": 999.0},
|
||
])
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
assert 19000 < float(df.iloc[0]["market_cap"]) < 22000 # NaN 不覆盖
|
||
assert abs(float(df.iloc[0]["circulating_market_cap"]) - 999.0) < 1e-6
|
||
|
||
def test_none_date_passes_today_to_unified(self, mock_xtquant):
|
||
"""live date=None(9:30 选股) → 本地委托收到今天(EOD 估值取最新≤今天)。"""
|
||
from datetime import datetime as _dt
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
provider._unified = self._local_unified([])
|
||
provider.get_fundamentals_df(["600519.XSHG"], date=None)
|
||
kwargs = provider._unified.get_fundamentals_df.call_args.kwargs
|
||
assert kwargs["date"] == _dt.now().strftime("%Y-%m-%d")
|
||
assert kwargs["fields"] == ["market_cap", "circulating_market_cap"]
|
||
|
||
|
||
class TestGetFundamentalsQueryDictMode:
|
||
def test_dict_with_stocks_returns_dataframe(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
query = {"stocks": ["600519.XSHG", "601318.XSHG"], "date": "2024-09-30"}
|
||
df = provider.get_fundamentals(query)
|
||
assert len(df) == 2
|
||
|
||
def test_dict_with_filter_callable_applied(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
query = {
|
||
"stocks": ["600519.XSHG", "601318.XSHG"],
|
||
"date": "2024-09-30",
|
||
"filter": lambda d: d["roe"] > 0.3, # 只保留茅台(归一后 roe=0.30)
|
||
}
|
||
df = provider.get_fundamentals(query)
|
||
assert len(df) == 1
|
||
assert df.iloc[0]["code"] == "600519.XSHG"
|
||
|
||
def test_dict_with_order_by_applied(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
query = {
|
||
"stocks": ["600519.XSHG", "601318.XSHG"],
|
||
"date": "2024-09-30",
|
||
"order_by": [("market_cap", "desc")],
|
||
}
|
||
df = provider.get_fundamentals(query)
|
||
assert df.iloc[0]["code"] == "600519.XSHG" # 茅台市值 > 平安
|
||
|
||
def test_dict_with_limit_applied(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
query = {
|
||
"stocks": ["600519.XSHG", "601318.XSHG"],
|
||
"date": "2024-09-30",
|
||
"limit": 1,
|
||
}
|
||
df = provider.get_fundamentals(query)
|
||
assert len(df) == 1
|
||
|
||
|
||
class TestSetDataProviderInjection:
|
||
def test_set_data_provider_accepts_sanguo_provider(self, mock_xtquant):
|
||
"""set_data_provider 注入 SanguoMiniQmtProvider 实例。"""
|
||
from bullet_trade.data.api import get_data_provider, set_data_provider
|
||
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
# Act
|
||
set_data_provider(provider)
|
||
# Assert
|
||
active = get_data_provider()
|
||
assert active is provider
|
||
assert active.name == "sanguo_miniqmt"
|
||
|
||
|
||
# ======================== 本地库委托(2026-08-19 生产缺口修复) ========================
|
||
# VPS 实盘/影子 8-18/8-19 连续两天空转根因:SanguoMiniQmtProvider 缺
|
||
# get_closes_panel/get_constituent_ex + get_fundamentals_df 不认 fields +
|
||
# get_index_stocks 忽略历史日期(前后端 session 巡检实锤,见 memory
|
||
# data-session-todo-miniqmt-provider-gaps)。修法:内部持有 LocalUnifiedProvider
|
||
# 读本地 dbbardata/constituent_unified(与回测同口径)。
|
||
def _make_delegate_db(tmp_path) -> str:
|
||
"""dbbardata + constituent_unified 小样本库(委托 LocalUnifiedProvider 用)。"""
|
||
import sqlite3
|
||
|
||
db = tmp_path / "delegate.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)"
|
||
)
|
||
for i, dt in enumerate(["2024-06-18 00:00:00", "2024-06-19 00:00:00",
|
||
"2024-06-20 00:00:00"]):
|
||
c.execute(
|
||
"INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||
("600519", "SSE", dt, "d", 1000.0, 1e6, 0,
|
||
1000.0 + i, 1005.0 + i, 995.0 + i, 1000.0 + i),
|
||
)
|
||
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(?,?,?,?,?,?)",
|
||
[
|
||
("000985", "600519", "贵州茅台", "csindex", 1, 0),
|
||
("000985", "000858", "五粮液", "csindex", 0, 1), # 被踢也在并集
|
||
],
|
||
)
|
||
# 涨跌停/停牌样本(2026-08-22): 000858 06-18 close=10 → 06-19 close=11
|
||
# = 主板 +10% 涨停; 600000 06-19 volume=0 = 停牌日(baostock 停牌 bar 有行无量)
|
||
for i, close in enumerate([10.0, 11.0]):
|
||
c.execute(
|
||
"INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||
("000858", "SZSE", f"2024-06-1{8 + i} 00:00:00", "d",
|
||
2000.0, 1e6, 0, close, close + 0.2, close - 0.2, close),
|
||
)
|
||
for dt, vol in [("2024-06-18 00:00:00", 5000.0), ("2024-06-19 00:00:00", 0.0)]:
|
||
c.execute(
|
||
"INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||
("600000", "SSE", dt, "d", vol, vol * 1600.0, 0,
|
||
8.0, 8.05, 7.95, 8.0),
|
||
)
|
||
c.commit()
|
||
c.close()
|
||
return str(db)
|
||
|
||
|
||
@pytest.fixture
|
||
def delegate_provider(mock_xtquant, tmp_path):
|
||
"""配好本地库路径的 SanguoMiniQmtProvider(db_path/data_dir 透传统一 provider)。"""
|
||
db = _make_delegate_db(tmp_path)
|
||
return SanguoMiniQmtProvider({
|
||
"db_path": db, "data_dir": str(tmp_path), "auto_download": False,
|
||
})
|
||
|
||
|
||
class TestGetClosesPanelDelegation:
|
||
"""get_closes_panel/get_closes_panel_ex 委托本地 dbbardata(同回测口径)。"""
|
||
|
||
def test_returns_wide_table_from_local_db(self, delegate_provider):
|
||
panel = delegate_provider.get_closes_panel(
|
||
["600519.XSHG"], "2024-06-18", "2024-06-20", fq="raw",
|
||
)
|
||
assert isinstance(panel, pd.DataFrame)
|
||
assert list(panel.columns) == ["600519.XSHG"]
|
||
assert len(panel) == 3
|
||
assert abs(panel["600519.XSHG"].iloc[0] - 1000.0) < 1e-6
|
||
assert abs(panel["600519.XSHG"].iloc[-1] - 1002.0) < 1e-6
|
||
|
||
def test_ex_alias_returns_same_result(self, delegate_provider):
|
||
old = delegate_provider.get_closes_panel(
|
||
["600519.XSHG"], "2024-06-18", "2024-06-20", fq="raw",
|
||
)
|
||
ex = delegate_provider.get_closes_panel_ex(
|
||
["600519.XSHG"], "2024-06-18", "2024-06-20", fq="raw",
|
||
)
|
||
pd.testing.assert_frame_equal(old, ex)
|
||
|
||
def test_missing_symbol_returns_nan_column(self, delegate_provider):
|
||
panel = delegate_provider.get_closes_panel(
|
||
["600519.XSHG", "999999.XSHG"], "2024-06-18", "2024-06-20",
|
||
)
|
||
assert list(panel.columns) == ["600519.XSHG", "999999.XSHG"]
|
||
assert panel["999999.XSHG"].isna().all()
|
||
|
||
def test_pure_digit_codes_accepted(self, delegate_provider):
|
||
panel = delegate_provider.get_closes_panel(
|
||
["600519"], "2024-06-18", "2024-06-20",
|
||
)
|
||
assert abs(panel["600519"].iloc[-1] - 1002.0) < 1e-6
|
||
|
||
|
||
class TestGetIndexStocksDelegation:
|
||
"""get_index_stocks/get_constituent_ex 委托 constituent_unified(支持历史日期口径)。"""
|
||
|
||
def test_reads_constituent_unified_union(self, delegate_provider):
|
||
stocks = delegate_provider.get_index_stocks("000985.XSHG", "2024-06-19")
|
||
# 并集语义:在册 + 被踢(was_removed)都返回
|
||
assert set(stocks) == {"600519.XSHG", "000858.XSHE"}
|
||
|
||
def test_constituent_ex_delegates_same(self, delegate_provider):
|
||
old = delegate_provider.get_index_stocks("000985.XSHG", "2024-06-19")
|
||
ex = delegate_provider.get_constituent_ex("000985.XSHG", "2024-06-19")
|
||
assert old == ex
|
||
|
||
def test_unknown_index_falls_back_to_xt_latest(self, delegate_provider, mock_xtquant, caplog):
|
||
"""表里没有的指数 → WARNING + 回退 miniQMT 最新成分(宁可降级不空转)。"""
|
||
mock_xtquant["xtdata"].get_index_weight.return_value = {"600519.SH": 0.5}
|
||
with caplog.at_level("WARNING", logger="sanguo_portfolio.providers.sanguo_fundamentals"):
|
||
stocks = delegate_provider.get_index_stocks("399303.XSHE", "2024-06-19")
|
||
assert stocks == ["600519.XSHG"]
|
||
assert any("回退" in r.message for r in caplog.records)
|
||
|
||
|
||
class TestGetFundamentalsDfFields:
|
||
"""get_fundamentals_df 加 fields 契约(对齐 unified:keep = code + 请求列)。"""
|
||
|
||
def test_fields_filters_columns(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df(
|
||
["600519.XSHG"], date="2024-09-30", fields=["market_cap", "eps"],
|
||
)
|
||
assert list(df.columns) == ["code", "market_cap", "eps"]
|
||
assert 19000 < float(df.iloc[0]["market_cap"]) < 22000
|
||
|
||
def test_fields_none_keeps_all_columns(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
|
||
assert "roe" in df.columns # fields=None 全列(向后兼容)
|
||
|
||
def test_ex_alias_accepts_fields(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
df = provider.get_fundamentals_df_ex(
|
||
["600519.XSHG"], date="2024-09-30", fields=["eps"],
|
||
)
|
||
assert list(df.columns) == ["code", "eps"]
|
||
|
||
|
||
# ======================== 涨跌停/停牌批量(2026-08-22,策略session巡检实锤) ========================
|
||
# SanguoMiniQmtProvider 缺 get_limit_status_batch → 实盘策略 _get_limit_status
|
||
# getattr(...,None) 静默返 {} → filter_limitup/limitdown/paused 全走"无数据"分支
|
||
# = 实盘涨跌停/停牌过滤完全失效(涨停股照买/停牌股照挂单)。修法:历史日期委托
|
||
# unified(同回测口径) + 当日盘中单次批量 get_full_tick(线程超时)。
|
||
# ⚠️ 失败一律返"全清白 map"(每 code {False,False,False})而非 {}:filter_paused
|
||
# 对 map 内缺失 code 是剔除语义,{} 会让它全剔除(空转事故形态)。
|
||
from datetime import datetime as _dt
|
||
|
||
_CLEAR = {"is_limit_up": False, "is_limit_down": False, "is_paused": False}
|
||
|
||
|
||
def _today() -> str:
|
||
return _dt.now().strftime("%Y-%m-%d")
|
||
|
||
|
||
class TestGetLimitStatusBatchHistorical:
|
||
"""历史日期(<今日,实盘主路径=策略传 previous_date)委托本地 dbbardata。"""
|
||
|
||
def test_method_exists(self, mock_xtquant):
|
||
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
|
||
assert callable(getattr(provider, "get_limit_status_batch", None))
|
||
|
||
def test_normal_stock_all_false(self, delegate_provider):
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"], "2024-06-19")
|
||
assert out["600519.XSHG"] == dict(_CLEAR)
|
||
|
||
def test_ten_pct_limit_up_detected(self, delegate_provider):
|
||
# fixture: 000858 06-18 close=10.0 → 06-19 close=11.0 = 主板涨停
|
||
out = delegate_provider.get_limit_status_batch(["000858.XSHE"], "2024-06-19")
|
||
assert out["000858.XSHE"]["is_limit_up"] is True
|
||
assert out["000858.XSHE"]["is_paused"] is False
|
||
|
||
def test_zero_volume_marks_paused(self, delegate_provider):
|
||
# fixture: 600000 06-19 volume=0(停牌日 bar 存在)
|
||
out = delegate_provider.get_limit_status_batch(["600000.XSHG"], "2024-06-19")
|
||
assert out["600000.XSHG"]["is_paused"] is True
|
||
|
||
def test_no_bar_returns_none_entry(self, delegate_provider):
|
||
out = delegate_provider.get_limit_status_batch(["999999.XSHG"], "2024-06-19")
|
||
assert out["999999.XSHG"] is None # unified 语义:无 bar=None(filter_paused 剔除)
|
||
|
||
def test_delegation_failure_returns_all_clear(self, delegate_provider, monkeypatch, caplog):
|
||
"""unified 抛异常 → 全清白(不是 {}、不是 None):失败不产生误伤/空转。"""
|
||
def _boom(codes, date):
|
||
raise RuntimeError("db locked")
|
||
|
||
monkeypatch.setattr(
|
||
delegate_provider._unified, "get_limit_status_batch", _boom)
|
||
with caplog.at_level(
|
||
"WARNING", logger="sanguo_portfolio.providers.sanguo_fundamentals"):
|
||
out = delegate_provider.get_limit_status_batch(
|
||
["600519.XSHG", "000858.XSHE"], "2024-06-19")
|
||
assert out["600519.XSHG"]["is_paused"] is False
|
||
assert out["000858.XSHE"]["is_limit_up"] is False
|
||
assert any("全清白" in r.message for r in caplog.records)
|
||
|
||
def test_empty_codes_returns_empty_dict(self, delegate_provider):
|
||
assert delegate_provider.get_limit_status_batch([], "2024-06-19") == {}
|
||
|
||
def test_historical_path_never_calls_full_tick(self, delegate_provider, mock_xtquant):
|
||
delegate_provider.get_limit_status_batch(["600519.XSHG"], "2024-06-19")
|
||
mock_xtquant["xtdata"].get_full_tick.assert_not_called()
|
||
|
||
|
||
class TestGetLimitStatusBatchRealtime:
|
||
"""当日盘中(date=None 或=今日):单次批量 get_full_tick + lastClose 算涨跌停。"""
|
||
|
||
def test_today_limit_up_from_tick(self, delegate_provider, mock_xtquant):
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {
|
||
"600519.SH": {"lastPrice": 11.0, "lastClose": 10.0, "volume": 12345},
|
||
}
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"])
|
||
assert out["600519.XSHG"]["is_limit_up"] is True
|
||
assert out["600519.XSHG"]["is_paused"] is False
|
||
mock_xtquant["xtdata"].get_full_tick.assert_called_once()
|
||
|
||
def test_today_date_string_routes_realtime(self, delegate_provider, mock_xtquant):
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {
|
||
"600519.SH": {"lastPrice": 10.5, "lastClose": 10.0, "volume": 100},
|
||
}
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"], _today())
|
||
assert out["600519.XSHG"]["is_limit_up"] is False
|
||
|
||
def test_chinext_uses_twenty_pct_band(self, delegate_provider, mock_xtquant):
|
||
# 创业板 20%: last=11.0/prev=10.0 → 未到 12.0 上限 = 未涨停
|
||
# (若误用主板 10% 口径会误判涨停——本测试钉死板块感知)
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {
|
||
"300001.SZ": {"lastPrice": 11.0, "lastClose": 10.0, "volume": 100},
|
||
}
|
||
out = delegate_provider.get_limit_status_batch(["300001.XSHE"])
|
||
assert out["300001.XSHE"]["is_limit_up"] is False
|
||
|
||
def test_limit_down_from_tick(self, delegate_provider, mock_xtquant):
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {
|
||
"600519.SH": {"lastPrice": 9.0, "lastClose": 10.0, "volume": 100},
|
||
}
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"])
|
||
assert out["600519.XSHG"]["is_limit_down"] is True
|
||
|
||
def test_zero_volume_with_price_marks_paused(self, delegate_provider, mock_xtquant):
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {
|
||
"600519.SH": {"lastPrice": 10.0, "lastClose": 10.0, "volume": 0},
|
||
}
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"])
|
||
assert out["600519.XSHG"]["is_paused"] is True
|
||
|
||
def test_missing_tick_all_clear_not_none(self, delegate_provider, mock_xtquant):
|
||
"""tick 缺该 code → 全清白(绝不能 None:filter_paused 会剔除)。"""
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {}
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"])
|
||
assert out["600519.XSHG"] == dict(_CLEAR)
|
||
|
||
def test_invalid_price_all_clear(self, delegate_provider, mock_xtquant):
|
||
mock_xtquant["xtdata"].get_full_tick.return_value = {
|
||
"600519.SH": {"lastPrice": 0.0, "lastClose": 10.0, "volume": 0},
|
||
}
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG"])
|
||
assert out["600519.XSHG"] == dict(_CLEAR)
|
||
|
||
def test_tick_exception_returns_all_clear(self, delegate_provider, mock_xtquant):
|
||
mock_xtquant["xtdata"].get_full_tick.side_effect = RuntimeError("qmt busy")
|
||
out = delegate_provider.get_limit_status_batch(["600519.XSHG", "000858.XSHE"])
|
||
assert out["600519.XSHG"] == dict(_CLEAR)
|
||
assert out["000858.XSHE"] == dict(_CLEAR)
|
||
|
||
def test_timeout_returns_all_clear(self, delegate_provider, mock_xtquant):
|
||
"""get_full_tick 挂死 → 线程超时(2197fea 同款教训)全清白,不卡策略。"""
|
||
import time as _time
|
||
|
||
def _slow(codes):
|
||
_time.sleep(1.0)
|
||
return {}
|
||
|
||
mock_xtquant["xtdata"].get_full_tick.side_effect = _slow
|
||
out = delegate_provider._limit_status_realtime(["600519.XSHG"], timeout=0.2)
|
||
assert out["600519.XSHG"] == dict(_CLEAR)
|
||
|
||
|
||
class TestGetValueMetricsDelegation:
|
||
"""get_value_metrics(+batch) 委托本地三表 parquet(与回测同口径)。
|
||
|
||
2026-08-22 方法面对照扫出的同款「回测有实盘无」洞:value_selection(+_ex)
|
||
getattr 探测;该策略暂未入实盘舰队=提前堵(下次上舰不会再踩)。
|
||
"""
|
||
|
||
def test_delegates_to_unified(self, delegate_provider, monkeypatch):
|
||
def _fake(stock, date):
|
||
return {"roe": 0.3}
|
||
|
||
monkeypatch.setattr(
|
||
delegate_provider._unified, "get_value_metrics", _fake)
|
||
out = delegate_provider.get_value_metrics("600519.XSHG", "2024-06-19")
|
||
assert out == {"roe": 0.3}
|
||
|
||
def test_failure_returns_none(self, delegate_provider, monkeypatch):
|
||
def _boom(stock, date):
|
||
raise RuntimeError("parquet missing")
|
||
|
||
monkeypatch.setattr(
|
||
delegate_provider._unified, "get_value_metrics", _boom)
|
||
assert delegate_provider.get_value_metrics(
|
||
"600519.XSHG", "2024-06-19") is None
|
||
|
||
def test_batch_delegates(self, delegate_provider, monkeypatch):
|
||
def _fake(stocks, date):
|
||
return {s: {"roe": 0.3} for s in stocks}
|
||
|
||
monkeypatch.setattr(
|
||
delegate_provider._unified, "get_value_metrics_batch", _fake)
|
||
out = delegate_provider.get_value_metrics_batch(
|
||
["600519.XSHG", "000858.XSHE"], "2024-06-19")
|
||
assert out["000858.XSHE"] == {"roe": 0.3}
|
||
|
||
def test_batch_failure_returns_empty(self, delegate_provider, monkeypatch):
|
||
def _boom(stocks, date):
|
||
raise RuntimeError("parquet missing")
|
||
|
||
monkeypatch.setattr(
|
||
delegate_provider._unified, "get_value_metrics_batch", _boom)
|
||
assert delegate_provider.get_value_metrics_batch(
|
||
["600519.XSHG"], "2024-06-19") == {}
|
||
|
||
|
||
class TestFullMethodSurfaceAlignment:
|
||
"""裸名兜齐(2026-08-22 用户拍板:不论当前有无消费方,统一堵光同款洞)。
|
||
|
||
get_constituent = get_index_stocks 纯别名(unified spec §6 语义);
|
||
get_price_ex 转发 base get_price(签名/默认值对齐 unified,fq 默认 'raw')。
|
||
"""
|
||
|
||
def test_get_constituent_alias_equals_index_stocks(self, delegate_provider):
|
||
old = delegate_provider.get_index_stocks("000985.XSHG", "2024-06-19")
|
||
via = delegate_provider.get_constituent("000985.XSHG", "2024-06-19")
|
||
assert via == old
|
||
assert set(via) == {"600519.XSHG", "000858.XSHE"}
|
||
|
||
def test_get_price_ex_forwards_to_base_get_price(self, delegate_provider, monkeypatch):
|
||
from bullet_trade.data.providers.miniqmt import MiniQMTProvider
|
||
|
||
seen = {}
|
||
|
||
def _rec(self, security, **kw):
|
||
seen["security"] = security
|
||
seen.update(kw)
|
||
return "FAKE_DF"
|
||
|
||
monkeypatch.setattr(MiniQMTProvider, "get_price", _rec)
|
||
out = delegate_provider.get_price_ex(
|
||
"600519.XSHG", start_date="2024-06-18", end_date="2024-06-19")
|
||
assert out == "FAKE_DF"
|
||
assert seen["security"] == "600519.XSHG"
|
||
assert seen["start_date"] == "2024-06-18"
|
||
assert seen["end_date"] == "2024-06-19"
|
||
assert seen["fq"] == "raw" # 对齐 unified 默认(base 裸 get_price 是 'pre')
|
||
|
||
def test_get_price_ex_passthrough_kwargs(self, delegate_provider, monkeypatch):
|
||
from bullet_trade.data.providers.miniqmt import MiniQMTProvider
|
||
|
||
seen = {}
|
||
|
||
def _rec(self, security, **kw):
|
||
seen.update(kw)
|
||
return None
|
||
|
||
monkeypatch.setattr(MiniQMTProvider, "get_price", _rec)
|
||
delegate_provider.get_price_ex(
|
||
["600519.XSHG"], frequency="1m", fq="pre", count=5, panel=False)
|
||
assert seen["frequency"] == "1m"
|
||
assert seen["fq"] == "pre"
|
||
assert seen["count"] == 5
|
||
assert seen["panel"] is False
|
||
|
||
|
||
# 方法面钉死测试已迁至 test_provider_method_surface.py(单一真相,
|
||
# fail-not-skip 牙齿)——本模块的 requires_bullet_trade mark 会让它在
|
||
# bullet_trade 缺失时被 conftest 自动 skip(钉死虚标,issue #35),故移出。
|