fix(storage): use input timestamp in save and expand test coverage

refactor3.0
Lxy 2 weeks ago
parent 8f3c2b6fdb
commit 59e461a163

@ -1,3 +1,4 @@
import datetime as _datetime
import json
import logging
import time
@ -64,6 +65,22 @@ class StorageManager:
self.last_mysql_check = now
return self.mysql_available
def _parse_fetched_at(self, timestamp_raw):
"""优先使用输入 timestamp解析失败时回退到当前时间。"""
if not timestamp_raw:
return _datetime.datetime.now()
if isinstance(timestamp_raw, str):
try:
return _datetime.datetime.fromisoformat(timestamp_raw)
except Exception:
return _datetime.datetime.now()
if hasattr(timestamp_raw, "isoformat"):
return timestamp_raw
return _datetime.datetime.now()
def is_available(self):
"""判断 Redis 或 MySQL 至少一个可用。"""
return self.check_redis() or self.check_mysql()
@ -225,7 +242,7 @@ class StorageManager:
data_type = data.get("type", "futures")
timeframes = data.get("timeframes", {})
current_price = data.get("current_price")
fetched_at = datetime.now()
fetched_at = self._parse_fetched_at(data.get("timestamp"))
cache_keys = [f"market_data:{symbol}:{period}" for period in timeframes.keys()]

@ -237,6 +237,62 @@ class TestStorageManagerReadLogic:
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):
"""Redis 命中时直接返回合约时间戳数据,不访问 MySQL。"""
redis_client = MagicMock()
@ -442,6 +498,206 @@ class TestStorageManagerWriteLogic:
mock_session.commit.assert_called_once()
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):
"""合约时间戳双写成功时,应先删缓存、再写 MySQL、最后回填 Redis。"""
redis_client = MagicMock()

Loading…
Cancel
Save