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