|
|
|
@ -51,8 +51,8 @@ def storage_manager_mock():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_sqlite_market_data(db, symbol="AG2606", data_type="futures"):
|
|
|
|
def _seed_sqlite_market_data(db, symbol="AG2606", data_type="futures"):
|
|
|
|
"""在 SQLite 中写入行情数据。"""
|
|
|
|
"""在 SQLite 中写入行情数据(使用当前时间确保缓存未过期)。"""
|
|
|
|
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
|
|
|
|
fetched_at = datetime.now()
|
|
|
|
db.add_all(
|
|
|
|
db.add_all(
|
|
|
|
[
|
|
|
|
[
|
|
|
|
MarketData(
|
|
|
|
MarketData(
|
|
|
|
@ -99,7 +99,7 @@ class TestSaveMarketData:
|
|
|
|
"""save_market_data 双写与降级测试。"""
|
|
|
|
"""save_market_data 双写与降级测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_uses_storage_manager_when_available(self, db, sample_data, storage_manager_mock):
|
|
|
|
def test_uses_storage_manager_when_available(self, db, sample_data, storage_manager_mock):
|
|
|
|
"""StorageManager 可用时优先写入存储层,并同步更新时间戳。"""
|
|
|
|
"""StorageManager 可用时优先写入存储层,并同步更新时间戳,返回代表记录。"""
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
from app.services import cache
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
@ -108,7 +108,10 @@ class TestSaveMarketData:
|
|
|
|
storage_manager_mock.is_available.assert_called_once()
|
|
|
|
storage_manager_mock.is_available.assert_called_once()
|
|
|
|
storage_manager_mock.save_market_data.assert_called_once_with("AG2606", sample_data)
|
|
|
|
storage_manager_mock.save_market_data.assert_called_once_with("AG2606", sample_data)
|
|
|
|
storage_manager_mock.save_symbol_timestamp.assert_called_once()
|
|
|
|
storage_manager_mock.save_symbol_timestamp.assert_called_once()
|
|
|
|
assert result is None
|
|
|
|
assert isinstance(result, MarketData)
|
|
|
|
|
|
|
|
assert result.symbol == "AG2606"
|
|
|
|
|
|
|
|
assert result.data_type == "futures"
|
|
|
|
|
|
|
|
assert result.current_price == 123.45
|
|
|
|
|
|
|
|
|
|
|
|
sqlite_records = db.query(MarketData).filter_by(symbol="AG2606").all()
|
|
|
|
sqlite_records = db.query(MarketData).filter_by(symbol="AG2606").all()
|
|
|
|
assert len(sqlite_records) == 0
|
|
|
|
assert len(sqlite_records) == 0
|
|
|
|
@ -164,26 +167,30 @@ class TestGetCachedData:
|
|
|
|
"""get_cached_data 读取与降级测试。"""
|
|
|
|
"""get_cached_data 读取与降级测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_uses_storage_manager_and_merges_periods(self, db, storage_manager_mock):
|
|
|
|
def test_uses_storage_manager_and_merges_periods(self, db, storage_manager_mock):
|
|
|
|
"""StorageManager 返回各周期数据时,合并为统一格式。"""
|
|
|
|
"""StorageManager 返回合并后的多周期数据。"""
|
|
|
|
fetched_at = datetime.now()
|
|
|
|
fetched_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
def mock_get_market_data(symbol, data_type, period):
|
|
|
|
storage_manager_mock.get_market_data.return_value = {
|
|
|
|
return {
|
|
|
|
"symbol": "AG2606",
|
|
|
|
"current_price": 123.45,
|
|
|
|
"type": "futures",
|
|
|
|
"timestamp": fetched_at.isoformat(),
|
|
|
|
"current_price": 123.45,
|
|
|
|
"candles": [{"datetime": f"2026-07-04T09:{50 if period == '5min' else 45}:00", "open": 100, "close": 110}],
|
|
|
|
"timestamp": fetched_at.isoformat(),
|
|
|
|
}
|
|
|
|
"fetched_at": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"timeframes": {
|
|
|
|
storage_manager_mock.get_market_data.side_effect = mock_get_market_data
|
|
|
|
"5min": [{"datetime": "2026-07-04T09:55:00", "open": 100, "close": 110}],
|
|
|
|
|
|
|
|
"15min": [{"datetime": "2026-07-04T09:45:00", "open": 105, "close": 115}],
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"is_fresh": True,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
from app.services import cache
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
result = cache.get_cached_data(db, "AG2606", "futures", ["5min", "15min"])
|
|
|
|
result = cache.get_cached_data(db, "AG2606", "futures", ["5min", "15min"])
|
|
|
|
|
|
|
|
|
|
|
|
assert storage_manager_mock.get_market_data.call_count == 2
|
|
|
|
storage_manager_mock.get_market_data.assert_called_once_with(
|
|
|
|
storage_manager_mock.get_market_data.assert_any_call("AG2606", "futures", "5min")
|
|
|
|
"AG2606", "futures", ["5min", "15min"], end_time=None, max_candles=100
|
|
|
|
storage_manager_mock.get_market_data.assert_any_call("AG2606", "futures", "15min")
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
assert result is not None
|
|
|
|
assert result["symbol"] == "AG2606"
|
|
|
|
assert result["symbol"] == "AG2606"
|
|
|
|
@ -226,6 +233,182 @@ class TestGetCachedData:
|
|
|
|
assert "StorageManager 读取失败" in caplog.text
|
|
|
|
assert "StorageManager 读取失败" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_passes_end_time_and_max_candles_to_storage(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""get_cached_data 将 end_time / max_candles 透传给 StorageManager。"""
|
|
|
|
|
|
|
|
end_time = datetime(2026, 7, 4, 10, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
cache.get_cached_data(db, "AG2606", "futures", ["5min"], end_time=end_time, max_candles=50)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.assert_called_once_with(
|
|
|
|
|
|
|
|
"AG2606", "futures", ["5min"], end_time=end_time, max_candles=50
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_applies_end_time_and_max_candles_to_storage_results(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 返回的数据会按 end_time / max_candles 过滤截断。"""
|
|
|
|
|
|
|
|
fetched_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mock_get_market_data(symbol, data_type, periods, end_time=None, max_candles=100):
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
|
|
"type": data_type,
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"fetched_at": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"timeframes": {
|
|
|
|
|
|
|
|
"5min": [
|
|
|
|
|
|
|
|
{"datetime": "2026-07-04T09:50:00", "open": 100, "close": 101},
|
|
|
|
|
|
|
|
{"datetime": "2026-07-04T09:55:00", "open": 101, "close": 102},
|
|
|
|
|
|
|
|
{"datetime": "2026-07-04T10:05:00", "open": 102, "close": 103},
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"is_fresh": True,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.side_effect = mock_get_market_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
result = cache.get_cached_data(
|
|
|
|
|
|
|
|
db, "AG2606", "futures", ["5min"],
|
|
|
|
|
|
|
|
end_time=datetime(2026, 7, 4, 10, 0, 0),
|
|
|
|
|
|
|
|
max_candles=1,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
|
|
|
candles = result["timeframes"]["5min"]
|
|
|
|
|
|
|
|
# 过滤掉 10:05 之后保留 09:50/09:55,再截断为最新 1 条
|
|
|
|
|
|
|
|
assert len(candles) == 1
|
|
|
|
|
|
|
|
assert candles[0]["datetime"] == "2026-07-04T09:55:00"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_queries_all_periods_when_periods_is_none(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""periods=None 时优先走 StorageManager 查询全部周期。"""
|
|
|
|
|
|
|
|
fetched_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mock_get_market_data(symbol, data_type, periods=None, end_time=None, max_candles=100):
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
|
|
"type": data_type,
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"fetched_at": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"timeframes": {"5min": [], "15min": []},
|
|
|
|
|
|
|
|
"is_fresh": True,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.side_effect = mock_get_market_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
result = cache.get_cached_data(db, "AG2606", "futures", periods=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.assert_called_once_with(
|
|
|
|
|
|
|
|
"AG2606", "futures", None, end_time=None, max_candles=100
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
|
|
|
assert "5min" in result["timeframes"]
|
|
|
|
|
|
|
|
assert "15min" in result["timeframes"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCacheStatus:
|
|
|
|
|
|
|
|
"""is_cache_valid / check_cache_status / get_latest_cached 存储层兼容测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_merged_result(self, symbol="AG2606", data_type="futures", periods=None, age_seconds=0):
|
|
|
|
|
|
|
|
fetched_at = datetime.now() - __import__("datetime").timedelta(seconds=age_seconds)
|
|
|
|
|
|
|
|
timeframes = {}
|
|
|
|
|
|
|
|
for p in (periods or ["5min", "15min"]):
|
|
|
|
|
|
|
|
timeframes[p] = [{"datetime": fetched_at.isoformat(), "open": 100, "close": 110}]
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
|
|
"type": data_type,
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"fetched_at": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"timeframes": timeframes,
|
|
|
|
|
|
|
|
"is_fresh": True,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_cache_valid_uses_storage_when_available(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 可用时优先判断缓存有效性。"""
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.return_value = self._make_merged_result(age_seconds=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert cache.is_cache_valid(db, "AG2606", "futures", "5min") is True
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.assert_called_once_with(
|
|
|
|
|
|
|
|
"AG2606", "futures", ["5min"]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_cache_valid_falls_back_to_sqlite(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 无数据时降级到 SQLite 判断缓存有效性。"""
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.return_value = None
|
|
|
|
|
|
|
|
_seed_sqlite_market_data(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert cache.is_cache_valid(db, "AG2606", "futures", "5min") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_check_cache_status_uses_storage_when_available(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 可用时优先检查多周期缓存状态。"""
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.return_value = self._make_merged_result()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status = cache.check_cache_status(db, "AG2606", "futures", ["5min", "15min"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert status["all_valid"] is True
|
|
|
|
|
|
|
|
assert set(status["valid_periods"]) == {"5min", "15min"}
|
|
|
|
|
|
|
|
assert status["missing_periods"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_check_cache_status_falls_back_to_sqlite(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 无数据时降级到 SQLite 检查多周期缓存状态。"""
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.return_value = None
|
|
|
|
|
|
|
|
_seed_sqlite_market_data(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status = cache.check_cache_status(db, "AG2606", "futures", ["5min", "15min"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert status["all_valid"] is True
|
|
|
|
|
|
|
|
assert set(status["valid_periods"]) == {"5min", "15min"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_latest_cached_uses_storage_when_available(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 可用时优先获取最新缓存记录。"""
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.return_value = self._make_merged_result()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
records = cache.get_latest_cached(db, "AG2606", "futures")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert len(records) == 2
|
|
|
|
|
|
|
|
periods = {r.period for r in records}
|
|
|
|
|
|
|
|
assert periods == {"5min", "15min"}
|
|
|
|
|
|
|
|
assert all(isinstance(r, MarketData) for r in records)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_latest_cached_falls_back_to_sqlite(self, db, storage_manager_mock):
|
|
|
|
|
|
|
|
"""StorageManager 无数据时降级到 SQLite 获取最新缓存记录。"""
|
|
|
|
|
|
|
|
storage_manager_mock.get_market_data.return_value = None
|
|
|
|
|
|
|
|
_seed_sqlite_market_data(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
|
|
|
|
|
|
|
|
from app.services import cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
records = cache.get_latest_cached(db, "AG2606", "futures")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert len(records) == 2
|
|
|
|
|
|
|
|
periods = {r.period for r in records}
|
|
|
|
|
|
|
|
assert periods == {"5min", "15min"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSymbolTimestamp:
|
|
|
|
class TestSymbolTimestamp:
|
|
|
|
"""合约时间戳函数封装测试。"""
|
|
|
|
"""合约时间戳函数封装测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
|