|
|
|
@ -237,6 +237,62 @@ class TestStorageManagerReadLogic:
|
|
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_market_data_falls_back_to_mysql_when_redis_read_fails(self):
|
|
|
|
|
|
|
|
"""Redis 读取失败但 MySQL 可用时,应降级到 MySQL 并回填 Redis。"""
|
|
|
|
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|
|
|
|
redis_client.get.side_effect = Exception("redis read failed")
|
|
|
|
|
|
|
|
mysql_engine = MagicMock()
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
|
|
|
|
|
|
|
|
record = MagicMock()
|
|
|
|
|
|
|
|
record.current_price = 123.45
|
|
|
|
|
|
|
|
record.fetched_at = fetched_at
|
|
|
|
|
|
|
|
record.candles_json = json.dumps([{"open": 100, "close": 110}])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_query = MagicMock()
|
|
|
|
|
|
|
|
mock_query.filter_by.return_value.first.return_value = record
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
|
|
mock_session.return_value = mock_session
|
|
|
|
|
|
|
|
mock_session.query.return_value = mock_query
|
|
|
|
|
|
|
|
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
mock_session.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_sessionmaker = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
|
|
|
|
|
|
|
|
result = manager.get_market_data("AG2606", "futures", "5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expected = {
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": "2026-07-04T10:00:00",
|
|
|
|
|
|
|
|
"candles": [{"open": 100, "close": 110}],
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
assert result == expected
|
|
|
|
|
|
|
|
redis_client.setex.assert_called_once_with(
|
|
|
|
|
|
|
|
"market_data:AG2606:5min", 2592000, json.dumps(expected, ensure_ascii=False)
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_market_data_returns_none_when_mysql_read_fails(self):
|
|
|
|
|
|
|
|
"""Redis 不可用且 MySQL 读取失败时返回 None。"""
|
|
|
|
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|
|
|
|
redis_client.ping.side_effect = Exception("redis down")
|
|
|
|
|
|
|
|
mysql_engine = MagicMock()
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
|
|
mock_session.return_value = mock_session
|
|
|
|
|
|
|
|
mock_session.query.side_effect = Exception("mysql read failed")
|
|
|
|
|
|
|
|
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
mock_session.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_sessionmaker = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
|
|
|
|
|
|
|
|
result = manager.get_market_data("AG2606", "futures", "5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_symbol_timestamp_returns_redis_value_on_cache_hit(self):
|
|
|
|
def test_get_symbol_timestamp_returns_redis_value_on_cache_hit(self):
|
|
|
|
"""Redis 命中时直接返回合约时间戳数据,不访问 MySQL。"""
|
|
|
|
"""Redis 命中时直接返回合约时间戳数据,不访问 MySQL。"""
|
|
|
|
redis_client = MagicMock()
|
|
|
|
redis_client = MagicMock()
|
|
|
|
@ -442,6 +498,206 @@ class TestStorageManagerWriteLogic:
|
|
|
|
mock_session.commit.assert_called_once()
|
|
|
|
mock_session.commit.assert_called_once()
|
|
|
|
assert "Redis 更新失败" in caplog.text
|
|
|
|
assert "Redis 更新失败" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_market_data_uses_input_timestamp(self):
|
|
|
|
|
|
|
|
"""save_market_data 应优先使用输入数据中的 timestamp,而非 datetime.now()。"""
|
|
|
|
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|
|
|
|
mysql_engine = MagicMock()
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
input_timestamp = datetime(2026, 1, 15, 8, 30, 0)
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
|
|
|
"type": "futures",
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": input_timestamp.isoformat(),
|
|
|
|
|
|
|
|
"timeframes": {"5min": [{"open": 100, "close": 110}]},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
records = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mock_filter_by(symbol=None, data_type=None, period=None):
|
|
|
|
|
|
|
|
existing = next(
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
|
|
r
|
|
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
|
|
if r.symbol == symbol and r.data_type == data_type and r.period == period
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
None,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
fb_query = MagicMock()
|
|
|
|
|
|
|
|
fb_query.first.return_value = existing
|
|
|
|
|
|
|
|
return fb_query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_query = MagicMock()
|
|
|
|
|
|
|
|
mock_query.filter_by.side_effect = mock_filter_by
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
|
|
mock_session.return_value = mock_session
|
|
|
|
|
|
|
|
mock_session.query.return_value = mock_query
|
|
|
|
|
|
|
|
mock_session.add.side_effect = records.append
|
|
|
|
|
|
|
|
mock_session.commit = MagicMock()
|
|
|
|
|
|
|
|
mock_session.rollback = MagicMock()
|
|
|
|
|
|
|
|
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
mock_session.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_sessionmaker = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
|
|
|
|
|
|
|
|
result = manager.save_market_data("AG2606", data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
assert len(records) == 1
|
|
|
|
|
|
|
|
assert records[0].fetched_at == input_timestamp
|
|
|
|
|
|
|
|
redis_client.setex.assert_called_once_with(
|
|
|
|
|
|
|
|
"market_data:AG2606:5min",
|
|
|
|
|
|
|
|
2592000,
|
|
|
|
|
|
|
|
json.dumps(
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": input_timestamp.isoformat(),
|
|
|
|
|
|
|
|
"candles": [{"open": 100, "close": 110}],
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_market_data_falls_back_to_now_on_invalid_timestamp(self):
|
|
|
|
|
|
|
|
"""timestamp 无法解析时应回退到当前时间。"""
|
|
|
|
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|
|
|
|
mysql_engine = MagicMock()
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fallback_time = datetime(2026, 3, 1, 12, 0, 0)
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
|
|
|
"type": "futures",
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": "not-a-valid-timestamp",
|
|
|
|
|
|
|
|
"timeframes": {"5min": [{"open": 100, "close": 110}]},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
records = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mock_filter_by(symbol=None, data_type=None, period=None):
|
|
|
|
|
|
|
|
existing = next(
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
|
|
r
|
|
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
|
|
if r.symbol == symbol and r.data_type == data_type and r.period == period
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
None,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
fb_query = MagicMock()
|
|
|
|
|
|
|
|
fb_query.first.return_value = existing
|
|
|
|
|
|
|
|
return fb_query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_query = MagicMock()
|
|
|
|
|
|
|
|
mock_query.filter_by.side_effect = mock_filter_by
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
|
|
mock_session.return_value = mock_session
|
|
|
|
|
|
|
|
mock_session.query.return_value = mock_query
|
|
|
|
|
|
|
|
mock_session.add.side_effect = records.append
|
|
|
|
|
|
|
|
mock_session.commit = MagicMock()
|
|
|
|
|
|
|
|
mock_session.rollback = MagicMock()
|
|
|
|
|
|
|
|
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
mock_session.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_sessionmaker = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
|
|
|
|
|
|
|
|
with patch("app.storage_manager._datetime.datetime") as mock_datetime:
|
|
|
|
|
|
|
|
mock_datetime.now.return_value = fallback_time
|
|
|
|
|
|
|
|
mock_datetime.fromisoformat.side_effect = ValueError(
|
|
|
|
|
|
|
|
"Invalid isoformat string"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
result = manager.save_market_data("AG2606", data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
assert len(records) == 1
|
|
|
|
|
|
|
|
assert records[0].fetched_at == fallback_time
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_market_data_writes_mysql_when_redis_unavailable(self):
|
|
|
|
|
|
|
|
"""Redis 不可用但 MySQL 可用时,save_market_data 应直接写入 MySQL 并返回 True。"""
|
|
|
|
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|
|
|
|
redis_client.ping.side_effect = Exception("redis down")
|
|
|
|
|
|
|
|
mysql_engine = MagicMock()
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
|
|
|
"type": "futures",
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": fetched_at.isoformat(),
|
|
|
|
|
|
|
|
"timeframes": {"5min": [{"open": 100, "close": 110}]},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
records = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mock_filter_by(symbol=None, data_type=None, period=None):
|
|
|
|
|
|
|
|
existing = next(
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
|
|
r
|
|
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
|
|
if r.symbol == symbol and r.data_type == data_type and r.period == period
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
None,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
fb_query = MagicMock()
|
|
|
|
|
|
|
|
fb_query.first.return_value = existing
|
|
|
|
|
|
|
|
return fb_query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_query = MagicMock()
|
|
|
|
|
|
|
|
mock_query.filter_by.side_effect = mock_filter_by
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
|
|
mock_session.return_value = mock_session
|
|
|
|
|
|
|
|
mock_session.query.return_value = mock_query
|
|
|
|
|
|
|
|
mock_session.add.side_effect = records.append
|
|
|
|
|
|
|
|
mock_session.commit = MagicMock()
|
|
|
|
|
|
|
|
mock_session.rollback = MagicMock()
|
|
|
|
|
|
|
|
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
mock_session.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_sessionmaker = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
|
|
|
|
|
|
|
|
result = manager.save_market_data("AG2606", data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
assert len(records) == 1
|
|
|
|
|
|
|
|
assert records[0].symbol == "AG2606"
|
|
|
|
|
|
|
|
redis_client.delete.assert_not_called()
|
|
|
|
|
|
|
|
redis_client.setex.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_market_data_returns_true_when_timeframes_empty(self):
|
|
|
|
|
|
|
|
"""timeframes 为空时,save_market_data 应直接返回 True 且不操作缓存。"""
|
|
|
|
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|
|
|
|
mysql_engine = MagicMock()
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
|
|
|
"type": "futures",
|
|
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
|
|
"timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
|
|
|
|
|
|
|
|
"timeframes": {},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
|
|
mock_session.return_value = mock_session
|
|
|
|
|
|
|
|
mock_session.commit = MagicMock()
|
|
|
|
|
|
|
|
mock_session.rollback = MagicMock()
|
|
|
|
|
|
|
|
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
mock_session.__exit__ = MagicMock(return_value=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mock_sessionmaker = MagicMock(return_value=mock_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
|
|
|
|
|
|
|
|
result = manager.save_market_data("AG2606", data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
mock_session.commit.assert_called_once()
|
|
|
|
|
|
|
|
redis_client.delete.assert_not_called()
|
|
|
|
|
|
|
|
redis_client.setex.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_symbol_timestamp_deletes_cache_writes_mysql_and_updates_redis(self):
|
|
|
|
def test_save_symbol_timestamp_deletes_cache_writes_mysql_and_updates_redis(self):
|
|
|
|
"""合约时间戳双写成功时,应先删缓存、再写 MySQL、最后回填 Redis。"""
|
|
|
|
"""合约时间戳双写成功时,应先删缓存、再写 MySQL、最后回填 Redis。"""
|
|
|
|
redis_client = MagicMock()
|
|
|
|
redis_client = MagicMock()
|
|
|
|
|