You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

687 lines
22 KiB

"""
v2.2 K 线服务单元测试
测试覆盖率目标: >80%
"""
import pytest
from datetime import datetime, date, timedelta
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from decimal import Decimal
# 导入被测试模块
from app.models.kline import (
Frequency, AdjustType,
StockKLineItem, StockKLineData, StockSymbolInfo, StockAdjustFactor,
FuturesKLineItem, FuturesKLineData, FuturesSymbolInfo, FuturesContractInfo
)
from app.services.kline.stock_service import StockKLineService, STOCK_FREQUENCIES
from app.services.kline.futures_service import FuturesKLineService, FUTURES_FREQUENCIES
from app.services.kline.adjustment_service import AdjustmentService
from app.repositories.kline.stock_repository import StockKLineRepository
from app.repositories.kline.futures_repository import FuturesKLineRepository
# ==================== 测试 fixtures ====================
@pytest.fixture
def mock_db():
"""模拟数据库会话"""
return Mock()
@pytest.fixture
def stock_repository(mock_db):
"""股票仓库实例"""
return StockKLineRepository(mock_db)
@pytest.fixture
def futures_repository(mock_db):
"""期货仓库实例"""
return FuturesKLineRepository(mock_db)
@pytest.fixture
def stock_service(mock_db):
"""股票服务实例"""
return StockKLineService(mock_db)
@pytest.fixture
def futures_service(mock_db):
"""期货服务实例"""
return FuturesKLineService(mock_db)
@pytest.fixture
def adjustment_service(mock_db):
"""复权服务实例"""
return AdjustmentService(mock_db)
@pytest.fixture
def sample_stock_kline_items():
"""示例股票K线数据"""
return [
StockKLineItem(
symbol="000001.SZ",
time=datetime(2026, 4, 1, 9, 30),
open=10.50,
high=10.80,
low=10.40,
close=10.65,
volume=1500000,
amount=15975000.00,
trade_date=date(2026, 4, 1),
is_limit_up=False,
is_limit_down=False,
total_market_cap=250000000000.00,
float_market_cap=200000000000.00
),
StockKLineItem(
symbol="000001.SZ",
time=datetime(2026, 4, 2, 9, 30),
open=10.60,
high=10.90,
low=10.55,
close=10.75,
volume=1600000,
amount=17120000.00,
trade_date=date(2026, 4, 2),
is_limit_up=False,
is_limit_down=False
),
]
@pytest.fixture
def sample_futures_kline_items():
"""示例期货K线数据"""
return [
FuturesKLineItem(
symbol="IF2406",
time=datetime(2026, 4, 1, 9, 30),
open=3850.0,
high=3880.0,
low=3840.0,
close=3870.0,
volume=125000,
open_interest=85000,
settlement_price=3865.0,
trade_date=date(2026, 4, 1)
),
FuturesKLineItem(
symbol="IF2406",
time=datetime(2026, 4, 2, 9, 30),
open=3870.0,
high=3890.0,
low=3860.0,
close=3880.0,
volume=130000,
open_interest=88000,
settlement_price=3875.0,
trade_date=date(2026, 4, 2)
),
]
@pytest.fixture
def sample_adjust_factors():
"""示例复权因子"""
return [
StockAdjustFactor(
symbol="000001.SZ",
ex_date=date(2025, 6, 1),
adjust_factor=1.1,
dividend_ratio=0.5,
split_ratio=1.0
),
StockAdjustFactor(
symbol="000001.SZ",
ex_date=date(2024, 6, 1),
adjust_factor=1.2,
dividend_ratio=0.8,
split_ratio=1.0
),
]
# ==================== 枚举测试 ====================
class TestEnums:
"""枚举类型测试"""
def test_frequency_values(self):
"""测试周期枚举值"""
assert Frequency.FREQ_1M.value == "1m"
assert Frequency.FREQ_5M.value == "5m"
assert Frequency.FREQ_1D.value == "1d"
assert Frequency.FREQ_1W.value == "1w"
assert Frequency.FREQ_1MONTH.value == "1month"
def test_adjust_type_values(self):
"""测试复权类型枚举值"""
assert AdjustType.NONE.value == ""
assert AdjustType.QFQ.value == "qfq"
assert AdjustType.HFQ.value == "hfq"
def test_stock_frequencies_count(self):
"""测试股票支持的周期数量"""
assert len(STOCK_FREQUENCIES) == 8
def test_futures_frequencies_count(self):
"""测试期货支持的周期数量"""
assert len(FUTURES_FREQUENCIES) == 8
# ==================== 数据模型测试 ====================
class TestModels:
"""数据模型测试"""
def test_stock_kline_item_creation(self, sample_stock_kline_items):
"""测试股票K线项创建"""
item = sample_stock_kline_items[0]
assert item.symbol == "000001.SZ"
assert item.open == 10.50
assert item.high == 10.80
assert item.low == 10.40
assert item.close == 10.65
assert item.volume == 1500000
def test_futures_kline_item_creation(self, sample_futures_kline_items):
"""测试期货K线项创建"""
item = sample_futures_kline_items[0]
assert item.symbol == "IF2406"
assert item.open_interest == 85000
assert item.settlement_price == 3865.0
def test_stock_kline_data_creation(self, sample_stock_kline_items):
"""测试股票K线数据响应创建"""
data = StockKLineData(
symbol="000001.SZ",
name="平安银行",
freq=Frequency.FREQ_1D,
adjust=AdjustType.NONE,
count=len(sample_stock_kline_items),
items=sample_stock_kline_items
)
assert data.symbol == "000001.SZ"
assert data.count == 2
def test_futures_kline_data_creation(self, sample_futures_kline_items):
"""测试期货K线数据响应创建"""
data = FuturesKLineData(
symbol="IF2406",
name="股指期货2406",
freq=Frequency.FREQ_1D,
count=len(sample_futures_kline_items),
items=sample_futures_kline_items
)
assert data.symbol == "IF2406"
assert data.count == 2
# ==================== 股票仓库测试 ====================
class TestStockRepository:
"""股票数据仓库测试"""
def test_table_map_exists(self, stock_repository):
"""测试表映射存在"""
assert Frequency.FREQ_1D in stock_repository.TABLE_MAP
assert stock_repository.TABLE_MAP[Frequency.FREQ_1D] == "stock_klines_1d"
def test_get_klines_empty_result(self, stock_repository, mock_db):
"""测试查询空结果"""
mock_db.execute.return_value = []
items = stock_repository.get_klines(
"000001.SZ",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
assert items == []
def test_get_klines_with_data(self, stock_repository, mock_db, sample_stock_kline_items):
"""测试查询有数据"""
# 模拟数据库返回
mock_result = [
Mock(
symbol_id="000001.SZ",
ts=datetime(2026, 4, 1),
open=Decimal("10.50"),
high=Decimal("10.80"),
low=Decimal("10.40"),
close=Decimal("10.65"),
volume=1500000,
amount=Decimal("15975000.00"),
trade_date=date(2026, 4, 1),
is_limit_up=False,
is_limit_down=False,
total_market_cap=None,
float_market_cap=None,
inst_holding_ratio=None,
trading_days=None
)
]
mock_db.execute.return_value = mock_result
items = stock_repository.get_klines(
"000001.SZ",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
assert len(items) >= 0 # 根据mock实现可能返回不同结果
def test_unsupported_frequency(self, stock_repository):
"""测试不支持的周期"""
# Frequency 枚举中没有这个值的情况
result = stock_repository.get_klines(
"000001.SZ",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
# 应该返回结果(表映射存在)
assert isinstance(result, list)
# ==================== 期货仓库测试 ====================
class TestFuturesRepository:
"""期货数据仓库测试"""
def test_table_map_exists(self, futures_repository):
"""测试表映射存在"""
assert Frequency.FREQ_1D in futures_repository.TABLE_MAP
assert futures_repository.TABLE_MAP[Frequency.FREQ_1D] == "futures_klines_1d"
def test_get_klines_empty(self, futures_repository, mock_db):
"""测试查询空结果"""
mock_db.execute.return_value = []
items = futures_repository.get_klines(
"IF2406",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
assert items == []
def test_get_main_contract_none(self, futures_repository, mock_db):
"""测试无主力合约"""
mock_db.execute.return_value.first.return_value = None
result = futures_repository.get_main_contract("IF")
assert result is None
# ==================== 股票服务测试 ====================
class TestStockService:
"""股票K线服务测试"""
def test_service_creation(self, stock_service):
"""测试服务创建"""
assert stock_service is not None
assert stock_service.repository is not None
def test_validate_params_empty_symbol(self, stock_service):
"""测试空代码验证"""
with pytest.raises(ValueError, match="股票代码不能为空"):
stock_service._validate_params(
"",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
def test_validate_params_invalid_time_range(self, stock_service):
"""测试无效时间范围"""
with pytest.raises(ValueError, match="开始时间必须早于结束时间"):
stock_service._validate_params(
"000001.SZ",
Frequency.FREQ_1D,
datetime(2026, 4, 5),
datetime(2026, 4, 1)
)
def test_validate_params_exceeds_max_days(self, stock_service):
"""测试超过最大天数"""
start = datetime(2020, 1, 1)
end = datetime(2026, 4, 1)
# 1分钟周期最大30天
with pytest.raises(ValueError, match="最多查询"):
stock_service._validate_params(
"000001.SZ",
Frequency.FREQ_1M,
start,
end
)
@pytest.mark.asyncio
async def test_query_klines_unsupported_freq(self, stock_service):
"""测试不支持的周期"""
# 创建一个不在 STOCK_FREQUENCIES 中的周期
with pytest.raises(ValueError, match="不支持的股票"):
await stock_service.query_klines(
"000001.SZ",
Frequency.FREQ_1M, # 这个在 STOCK_FREQUENCIES 中
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
# ==================== 期货服务测试 ====================
class TestFuturesService:
"""期货K线服务测试"""
def test_service_creation(self, futures_service):
"""测试服务创建"""
assert futures_service is not None
assert futures_service.repository is not None
def test_validate_params_empty_symbol(self, futures_service):
"""测试空代码验证"""
with pytest.raises(ValueError, match="合约代码不能为空"):
futures_service._validate_params(
"",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
def test_validate_params_invalid_time_range(self, futures_service):
"""测试无效时间范围"""
with pytest.raises(ValueError, match="开始时间必须早于结束时间"):
futures_service._validate_params(
"IF2406",
Frequency.FREQ_1D,
datetime(2026, 4, 5),
datetime(2026, 4, 1)
)
def test_batch_limit(self, futures_service):
"""测试批量查询限制"""
symbols = [f"IF{i}" for i in range(101)]
with pytest.raises(ValueError, match="最多支持 100"):
futures_service._validate_params(
symbols[0],
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
# ==================== 复权服务测试 ====================
class TestAdjustmentService:
"""复权计算服务测试"""
def test_service_creation(self, adjustment_service):
"""测试服务创建"""
assert adjustment_service is not None
def test_no_adjustment(self, adjustment_service, sample_stock_kline_items):
"""测试不复权"""
result = adjustment_service.apply_adjustment(
"000001.SZ",
sample_stock_kline_items,
AdjustType.NONE,
[]
)
assert result == sample_stock_kline_items
def test_no_factors(self, adjustment_service, sample_stock_kline_items):
"""测试无复权因子"""
result = adjustment_service.apply_adjustment(
"000001.SZ",
sample_stock_kline_items,
AdjustType.QFQ,
[]
)
# 无因子时返回原始数据
assert result == sample_stock_kline_items
def test_adjust_price_none(self, adjustment_service):
"""测试空价格调整"""
result = adjustment_service._adjust_price(None, 1.5)
assert result is None
def test_adjust_price_value(self, adjustment_service):
"""测试价格调整"""
result = adjustment_service._adjust_price(10.0, 1.5)
assert result == 15.0
def test_calculate_adjust_factor_dividend(self, adjustment_service):
"""测试分红因子计算"""
# 分红0.5元前收盘价10元
factor = adjustment_service.calculate_adjust_factor(
dividend_ratio=0.5,
前收盘价=10.0
)
assert factor == 0.95 # (10 - 0.5) / 10
def test_calculate_adjust_factor_split(self, adjustment_service):
"""测试拆股因子计算"""
# 1股拆成2股
factor = adjustment_service.calculate_adjust_factor(
split_ratio=2.0,
前收盘价=10.0
)
assert factor == 0.5 # 1 / 2
def test_build_factor_map(self, adjustment_service, sample_adjust_factors):
"""测试因子映射构建"""
factor_map = adjustment_service._build_factor_map(sample_adjust_factors)
assert date(2025, 6, 1) in factor_map
assert date(2024, 6, 1) in factor_map
assert factor_map[date(2025, 6, 1)].adjust_factor == 1.1
def test_qfq_cumulative_factors(self, adjustment_service, sample_adjust_factors):
"""测试前复权累计因子计算"""
cumulative = adjustment_service._calculate_qfq_cumulative_factors(
sample_adjust_factors
)
# 应该有累计因子
assert len(cumulative) > 0
def test_hfq_cumulative_factors(self, adjustment_service, sample_adjust_factors):
"""测试后复权累计因子计算"""
cumulative = adjustment_service._calculate_hfq_cumulative_factors(
sample_adjust_factors
)
# 应该有累计因子
assert len(cumulative) > 0
# ==================== API 测试 ====================
class TestAPIEndpoints:
"""API 接口测试"""
@pytest.mark.asyncio
async def test_health_endpoint(self):
"""测试健康检查"""
from app.api.v2.kline_v2_2 import health_check
result = await health_check()
assert result.code == 0
assert result.data["status"] == "healthy"
assert result.data["version"] == "2.2.0"
@pytest.mark.asyncio
async def test_freqs_endpoint(self):
"""测试周期列表"""
from app.api.v2.kline_v2_2 import get_supported_freqs
result = await get_supported_freqs()
assert result.code == 0
assert len(result.data["stock"]) == 8
assert len(result.data["futures"]) == 8
# ==================== 集成测试 ====================
class TestIntegration:
"""集成测试"""
@pytest.mark.asyncio
async def test_full_stock_kline_flow(self, mock_db):
"""测试完整股票K线流程"""
# 创建服务
service = StockKLineService(mock_db)
# 模拟仓库返回空数据
service.repository.get_klines = Mock(return_value=[])
service.repository.get_symbol_info = Mock(return_value=None)
# 验证参数
try:
service._validate_params(
"000001.SZ",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
# 参数验证应该通过
assert True
except ValueError:
pytest.fail("参数验证不应失败")
@pytest.mark.asyncio
async def test_full_futures_kline_flow(self, mock_db):
"""测试完整期货K线流程"""
service = FuturesKLineService(mock_db)
# 验证参数
try:
service._validate_params(
"IF2406",
Frequency.FREQ_1D,
datetime(2026, 4, 1),
datetime(2026, 4, 5)
)
assert True
except ValueError:
pytest.fail("参数验证不应失败")
# ==================== 边界测试 ====================
class TestBoundaryCases:
"""边界条件测试"""
def test_zero_price(self, adjustment_service):
"""测试零价格"""
# 前收盘价为0时返回1.0
factor = adjustment_service.calculate_adjust_factor(前收盘价=0)
assert factor == 1.0
def test_empty_items_list(self, adjustment_service):
"""测试空数据列表"""
result = adjustment_service.apply_adjustment(
"000001.SZ",
[],
AdjustType.QFQ,
[]
)
assert result == []
def test_large_volume(self):
"""测试大成交量"""
item = StockKLineItem(
symbol="000001.SZ",
time=datetime(2026, 4, 1),
open=10.0,
high=10.5,
low=9.5,
close=10.2,
volume=9999999999999, # 超大成交量
amount=100000000000000,
trade_date=date(2026, 4, 1)
)
assert item.volume == 9999999999999
def test_negative_price_not_allowed(self):
"""测试负价格(应该被数据验证拒绝)"""
# Pydantic 模型通常允许负值,但业务逻辑应该验证
# 这里测试模型是否能创建(实际业务中应该有验证)
item = StockKLineItem(
symbol="000001.SZ",
time=datetime(2026, 4, 1),
open=-10.0, # 负价格
high=-10.5,
low=-9.5,
close=-10.2,
volume=1000,
amount=10000,
trade_date=date(2026, 4, 1)
)
# 模型允许创建,业务验证应该在服务层
assert item.open == -10.0
# ==================== 性能测试 ====================
class TestPerformance:
"""性能相关测试"""
def test_large_batch_items(self):
"""测试大批量数据处理"""
items = []
for i in range(1000):
items.append(StockKLineItem(
symbol="000001.SZ",
time=datetime(2026, 4, 1) + timedelta(days=i),
open=10.0 + i * 0.01,
high=10.5 + i * 0.01,
low=9.5 + i * 0.01,
close=10.2 + i * 0.01,
volume=1000000,
amount=10000000,
trade_date=date(2026, 4, 1) + timedelta(days=i)
))
assert len(items) == 1000
def test_adjustment_performance(self, adjustment_service):
"""测试复权计算性能"""
# 创建1000条测试数据
items = []
for i in range(100):
items.append(StockKLineItem(
symbol="000001.SZ",
time=datetime(2025, 1, 1) + timedelta(days=i),
open=10.0,
high=10.5,
low=9.5,
close=10.2,
volume=1000000,
amount=10000000,
trade_date=date(2025, 1, 1) + timedelta(days=i)
))
# 应用复权
factors = [
StockAdjustFactor(
symbol="000001.SZ",
ex_date=date(2025, 6, 1),
adjust_factor=1.1,
dividend_ratio=0.5,
split_ratio=1.0
)
]
result = adjustment_service.apply_adjustment(
"000001.SZ",
items,
AdjustType.QFQ,
factors
)
assert len(result) == 100
# ==================== 运行测试 ====================
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])