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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
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"])