bquant 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bquant/__init__.py +31 -0
- bquant/analysis/__init__.py +297 -0
- bquant/analysis/candlestick/__init__.py +100 -0
- bquant/analysis/chart/__init__.py +98 -0
- bquant/analysis/statistical/__init__.py +414 -0
- bquant/analysis/statistical/hypothesis_testing.py +657 -0
- bquant/analysis/technical/__init__.py +98 -0
- bquant/analysis/timeseries/__init__.py +102 -0
- bquant/analysis/zones/__init__.py +547 -0
- bquant/analysis/zones/sequence_analysis.py +661 -0
- bquant/analysis/zones/zone_features.py +520 -0
- bquant/cli.py +160 -0
- bquant/core/__init__.py +112 -0
- bquant/core/cache.py +534 -0
- bquant/core/config.py +328 -0
- bquant/core/exceptions.py +351 -0
- bquant/core/logging_config.py +368 -0
- bquant/core/numpy_fix.py +99 -0
- bquant/core/performance.py +554 -0
- bquant/core/utils.py +327 -0
- bquant/data/__init__.py +85 -0
- bquant/data/loader.py +436 -0
- bquant/data/processor.py +644 -0
- bquant/data/samples/__init__.py +442 -0
- bquant/data/samples/datasets.py +246 -0
- bquant/data/samples/embedded/__init__.py +16 -0
- bquant/data/samples/embedded/mt_xauusd_m15.py +9018 -0
- bquant/data/samples/embedded/tv_xauusd_1h.py +17018 -0
- bquant/data/samples/utils.py +393 -0
- bquant/data/schemas.py +285 -0
- bquant/data/validator.py +498 -0
- bquant/indicators/__init__.py +115 -0
- bquant/indicators/base.py +487 -0
- bquant/indicators/calculators.py +404 -0
- bquant/indicators/library.py +514 -0
- bquant/indicators/loaders.py +413 -0
- bquant/indicators/macd.py +754 -0
- bquant/ml/__init__.py +19 -0
- bquant/visualization/__init__.py +316 -0
- bquant/visualization/charts.py +616 -0
- bquant/visualization/statistical.py +812 -0
- bquant/visualization/themes.py +599 -0
- bquant/visualization/zones.py +775 -0
- bquant-0.0.0.dist-info/METADATA +167 -0
- bquant-0.0.0.dist-info/RECORD +49 -0
- bquant-0.0.0.dist-info/WHEEL +5 -0
- bquant-0.0.0.dist-info/entry_points.txt +2 -0
- bquant-0.0.0.dist-info/licenses/LICENSE +21 -0
- bquant-0.0.0.dist-info/top_level.txt +1 -0
bquant/core/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BQuant Core Module
|
|
3
|
+
|
|
4
|
+
Core functionality including configuration, utilities, and base classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .config import (
|
|
8
|
+
get_data_path,
|
|
9
|
+
get_indicator_params,
|
|
10
|
+
validate_timeframe,
|
|
11
|
+
PROJECT_ROOT,
|
|
12
|
+
DATA_DIR,
|
|
13
|
+
DEFAULT_INDICATORS,
|
|
14
|
+
TIMEFRAME_MAPPING
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .utils import (
|
|
18
|
+
setup_project_logging,
|
|
19
|
+
calculate_returns,
|
|
20
|
+
normalize_data,
|
|
21
|
+
save_results,
|
|
22
|
+
validate_ohlcv_columns,
|
|
23
|
+
create_timestamp,
|
|
24
|
+
memory_usage_info,
|
|
25
|
+
ensure_directory
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
BQuantError,
|
|
30
|
+
DataError,
|
|
31
|
+
DataValidationError,
|
|
32
|
+
DataLoadingError,
|
|
33
|
+
DataProcessingError,
|
|
34
|
+
ConfigurationError,
|
|
35
|
+
InvalidTimeframeError,
|
|
36
|
+
InvalidIndicatorParametersError,
|
|
37
|
+
AnalysisError,
|
|
38
|
+
IndicatorCalculationError,
|
|
39
|
+
ZoneAnalysisError,
|
|
40
|
+
StatisticalAnalysisError,
|
|
41
|
+
VisualizationError,
|
|
42
|
+
MLError,
|
|
43
|
+
FileOperationError
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from .logging_config import (
|
|
47
|
+
setup_logging,
|
|
48
|
+
get_logger,
|
|
49
|
+
log_function_call,
|
|
50
|
+
log_performance,
|
|
51
|
+
LoggingContext
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
from .numpy_fix import (
|
|
55
|
+
apply_numpy_fixes,
|
|
56
|
+
check_numpy_compatibility,
|
|
57
|
+
ensure_numpy_compatibility,
|
|
58
|
+
NaN,
|
|
59
|
+
nan
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# Config
|
|
64
|
+
"get_data_path",
|
|
65
|
+
"get_indicator_params",
|
|
66
|
+
"validate_timeframe",
|
|
67
|
+
"PROJECT_ROOT",
|
|
68
|
+
"DATA_DIR",
|
|
69
|
+
"DEFAULT_INDICATORS",
|
|
70
|
+
"TIMEFRAME_MAPPING",
|
|
71
|
+
|
|
72
|
+
# Utils
|
|
73
|
+
"setup_project_logging",
|
|
74
|
+
"calculate_returns",
|
|
75
|
+
"normalize_data",
|
|
76
|
+
"save_results",
|
|
77
|
+
"validate_ohlcv_columns",
|
|
78
|
+
"create_timestamp",
|
|
79
|
+
"memory_usage_info",
|
|
80
|
+
"ensure_directory",
|
|
81
|
+
|
|
82
|
+
# Exceptions
|
|
83
|
+
"BQuantError",
|
|
84
|
+
"DataError",
|
|
85
|
+
"DataValidationError",
|
|
86
|
+
"DataLoadingError",
|
|
87
|
+
"DataProcessingError",
|
|
88
|
+
"ConfigurationError",
|
|
89
|
+
"InvalidTimeframeError",
|
|
90
|
+
"InvalidIndicatorParametersError",
|
|
91
|
+
"AnalysisError",
|
|
92
|
+
"IndicatorCalculationError",
|
|
93
|
+
"ZoneAnalysisError",
|
|
94
|
+
"StatisticalAnalysisError",
|
|
95
|
+
"VisualizationError",
|
|
96
|
+
"MLError",
|
|
97
|
+
"FileOperationError",
|
|
98
|
+
|
|
99
|
+
# Logging
|
|
100
|
+
"setup_logging",
|
|
101
|
+
"get_logger",
|
|
102
|
+
"log_function_call",
|
|
103
|
+
"log_performance",
|
|
104
|
+
"LoggingContext",
|
|
105
|
+
|
|
106
|
+
# Numpy fix
|
|
107
|
+
"apply_numpy_fixes",
|
|
108
|
+
"check_numpy_compatibility",
|
|
109
|
+
"ensure_numpy_compatibility",
|
|
110
|
+
"NaN",
|
|
111
|
+
"nan"
|
|
112
|
+
]
|
bquant/core/cache.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Система кэширования для BQuant
|
|
3
|
+
|
|
4
|
+
Модуль предоставляет различные стратегии кэширования для оптимизации производительности
|
|
5
|
+
расчетов индикаторов и анализа данных.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import pickle
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional, Callable, Union, Tuple
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import numpy as np
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
|
|
19
|
+
from .logging_config import get_logger
|
|
20
|
+
from .config import get_cache_config
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CacheEntry:
|
|
27
|
+
"""
|
|
28
|
+
Запись в кэше.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
data: Кэшированные данные
|
|
32
|
+
timestamp: Время создания записи
|
|
33
|
+
hits: Количество обращений к записи
|
|
34
|
+
expiry: Время истечения записи (None = не истекает)
|
|
35
|
+
size_bytes: Размер данных в байтах
|
|
36
|
+
metadata: Дополнительные метаданные
|
|
37
|
+
"""
|
|
38
|
+
data: Any
|
|
39
|
+
timestamp: datetime
|
|
40
|
+
hits: int = 0
|
|
41
|
+
expiry: Optional[datetime] = None
|
|
42
|
+
size_bytes: int = 0
|
|
43
|
+
metadata: Dict[str, Any] = None
|
|
44
|
+
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
if self.metadata is None:
|
|
47
|
+
self.metadata = {}
|
|
48
|
+
|
|
49
|
+
# Приблизительный расчет размера
|
|
50
|
+
if self.size_bytes == 0:
|
|
51
|
+
self.size_bytes = self._estimate_size()
|
|
52
|
+
|
|
53
|
+
def _estimate_size(self) -> int:
|
|
54
|
+
"""Приблизительная оценка размера данных."""
|
|
55
|
+
try:
|
|
56
|
+
if isinstance(self.data, pd.DataFrame):
|
|
57
|
+
return self.data.memory_usage(deep=True).sum()
|
|
58
|
+
elif isinstance(self.data, pd.Series):
|
|
59
|
+
return self.data.memory_usage(deep=True)
|
|
60
|
+
elif isinstance(self.data, np.ndarray):
|
|
61
|
+
return self.data.nbytes
|
|
62
|
+
else:
|
|
63
|
+
return len(pickle.dumps(self.data))
|
|
64
|
+
except Exception:
|
|
65
|
+
return 1024 # Fallback размер
|
|
66
|
+
|
|
67
|
+
def is_expired(self) -> bool:
|
|
68
|
+
"""Проверяет, истекла ли запись."""
|
|
69
|
+
if self.expiry is None:
|
|
70
|
+
return False
|
|
71
|
+
return datetime.now() > self.expiry
|
|
72
|
+
|
|
73
|
+
def touch(self):
|
|
74
|
+
"""Обновляет счетчик обращений."""
|
|
75
|
+
self.hits += 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MemoryCache:
|
|
79
|
+
"""
|
|
80
|
+
Кэш в памяти с поддержкой TTL и LRU эвикции.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, max_size: int = 100, default_ttl: int = 3600):
|
|
84
|
+
"""
|
|
85
|
+
Инициализация кэша.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
max_size: Максимальное количество записей
|
|
89
|
+
default_ttl: Время жизни записи по умолчанию (секунды)
|
|
90
|
+
"""
|
|
91
|
+
self.max_size = max_size
|
|
92
|
+
self.default_ttl = default_ttl
|
|
93
|
+
self._cache: Dict[str, CacheEntry] = {}
|
|
94
|
+
self._access_order: List[str] = []
|
|
95
|
+
self.logger = get_logger(f"{__name__}.MemoryCache")
|
|
96
|
+
|
|
97
|
+
# Статистика
|
|
98
|
+
self._hits = 0
|
|
99
|
+
self._misses = 0
|
|
100
|
+
self._evictions = 0
|
|
101
|
+
|
|
102
|
+
def _generate_key(self, func_name: str, args: tuple, kwargs: dict) -> str:
|
|
103
|
+
"""Генерирует ключ кэша на основе функции и аргументов."""
|
|
104
|
+
# Создаем строку для хэширования
|
|
105
|
+
key_parts = [func_name]
|
|
106
|
+
|
|
107
|
+
# Добавляем args
|
|
108
|
+
for arg in args:
|
|
109
|
+
if isinstance(arg, pd.DataFrame):
|
|
110
|
+
# Для DataFrame используем хэш данных
|
|
111
|
+
key_parts.append(pd.util.hash_pandas_object(arg).sum())
|
|
112
|
+
elif isinstance(arg, (pd.Series, np.ndarray)):
|
|
113
|
+
key_parts.append(str(hash(arg.tobytes() if hasattr(arg, 'tobytes') else str(arg))))
|
|
114
|
+
else:
|
|
115
|
+
key_parts.append(str(hash(str(arg))))
|
|
116
|
+
|
|
117
|
+
# Добавляем kwargs
|
|
118
|
+
sorted_kwargs = sorted(kwargs.items())
|
|
119
|
+
for k, v in sorted_kwargs:
|
|
120
|
+
key_parts.append(f"{k}={hash(str(v))}")
|
|
121
|
+
|
|
122
|
+
# Создаем хэш
|
|
123
|
+
key_string = "|".join(str(part) for part in key_parts)
|
|
124
|
+
return hashlib.md5(key_string.encode()).hexdigest()
|
|
125
|
+
|
|
126
|
+
def get(self, key: str) -> Optional[Any]:
|
|
127
|
+
"""Получить значение из кэша."""
|
|
128
|
+
if key not in self._cache:
|
|
129
|
+
self._misses += 1
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
entry = self._cache[key]
|
|
133
|
+
|
|
134
|
+
# Проверяем истечение
|
|
135
|
+
if entry.is_expired():
|
|
136
|
+
self.logger.debug(f"Cache entry expired: {key}")
|
|
137
|
+
del self._cache[key]
|
|
138
|
+
if key in self._access_order:
|
|
139
|
+
self._access_order.remove(key)
|
|
140
|
+
self._misses += 1
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# Обновляем статистику и порядок доступа
|
|
144
|
+
entry.touch()
|
|
145
|
+
self._hits += 1
|
|
146
|
+
|
|
147
|
+
# Перемещаем в конец списка (LRU)
|
|
148
|
+
if key in self._access_order:
|
|
149
|
+
self._access_order.remove(key)
|
|
150
|
+
self._access_order.append(key)
|
|
151
|
+
|
|
152
|
+
self.logger.debug(f"Cache hit: {key}")
|
|
153
|
+
return entry.data
|
|
154
|
+
|
|
155
|
+
def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
|
156
|
+
"""Сохранить значение в кэше."""
|
|
157
|
+
# Определяем время истечения
|
|
158
|
+
expiry = None
|
|
159
|
+
if ttl is not None or self.default_ttl > 0:
|
|
160
|
+
ttl_seconds = ttl if ttl is not None else self.default_ttl
|
|
161
|
+
expiry = datetime.now() + timedelta(seconds=ttl_seconds)
|
|
162
|
+
|
|
163
|
+
# Создаем запись
|
|
164
|
+
entry = CacheEntry(
|
|
165
|
+
data=value,
|
|
166
|
+
timestamp=datetime.now(),
|
|
167
|
+
expiry=expiry
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Проверяем, нужна ли эвикция
|
|
171
|
+
if len(self._cache) >= self.max_size and key not in self._cache:
|
|
172
|
+
self._evict_lru()
|
|
173
|
+
|
|
174
|
+
# Сохраняем запись
|
|
175
|
+
self._cache[key] = entry
|
|
176
|
+
|
|
177
|
+
# Обновляем порядок доступа
|
|
178
|
+
if key in self._access_order:
|
|
179
|
+
self._access_order.remove(key)
|
|
180
|
+
self._access_order.append(key)
|
|
181
|
+
|
|
182
|
+
self.logger.debug(f"Cache put: {key}, expires: {expiry}")
|
|
183
|
+
|
|
184
|
+
def _evict_lru(self) -> None:
|
|
185
|
+
"""Удаляет наименее недавно использованную запись."""
|
|
186
|
+
if not self._access_order:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
lru_key = self._access_order[0]
|
|
190
|
+
del self._cache[lru_key]
|
|
191
|
+
self._access_order.remove(lru_key)
|
|
192
|
+
self._evictions += 1
|
|
193
|
+
|
|
194
|
+
self.logger.debug(f"Evicted LRU entry: {lru_key}")
|
|
195
|
+
|
|
196
|
+
def invalidate(self, key: str) -> bool:
|
|
197
|
+
"""Удалить запись из кэша."""
|
|
198
|
+
if key in self._cache:
|
|
199
|
+
del self._cache[key]
|
|
200
|
+
if key in self._access_order:
|
|
201
|
+
self._access_order.remove(key)
|
|
202
|
+
self.logger.debug(f"Invalidated cache entry: {key}")
|
|
203
|
+
return True
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
def clear(self) -> None:
|
|
207
|
+
"""Очистить весь кэш."""
|
|
208
|
+
count = len(self._cache)
|
|
209
|
+
self._cache.clear()
|
|
210
|
+
self._access_order.clear()
|
|
211
|
+
self.logger.info(f"Cleared cache: {count} entries removed")
|
|
212
|
+
|
|
213
|
+
def cleanup_expired(self) -> int:
|
|
214
|
+
"""Удалить истекшие записи."""
|
|
215
|
+
expired_keys = []
|
|
216
|
+
for key, entry in self._cache.items():
|
|
217
|
+
if entry.is_expired():
|
|
218
|
+
expired_keys.append(key)
|
|
219
|
+
|
|
220
|
+
for key in expired_keys:
|
|
221
|
+
self.invalidate(key)
|
|
222
|
+
|
|
223
|
+
if expired_keys:
|
|
224
|
+
self.logger.info(f"Cleaned up {len(expired_keys)} expired entries")
|
|
225
|
+
|
|
226
|
+
return len(expired_keys)
|
|
227
|
+
|
|
228
|
+
def stats(self) -> Dict[str, Any]:
|
|
229
|
+
"""Получить статистику кэша."""
|
|
230
|
+
total_requests = self._hits + self._misses
|
|
231
|
+
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
|
|
232
|
+
|
|
233
|
+
total_size = sum(entry.size_bytes for entry in self._cache.values())
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
'entries': len(self._cache),
|
|
237
|
+
'max_size': self.max_size,
|
|
238
|
+
'hits': self._hits,
|
|
239
|
+
'misses': self._misses,
|
|
240
|
+
'hit_rate': hit_rate,
|
|
241
|
+
'evictions': self._evictions,
|
|
242
|
+
'total_size_bytes': total_size,
|
|
243
|
+
'avg_size_bytes': total_size / len(self._cache) if self._cache else 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class DiskCache:
|
|
248
|
+
"""
|
|
249
|
+
Кэш на диске для долгосрочного хранения результатов.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, cache_dir: Union[str, Path] = None):
|
|
253
|
+
"""
|
|
254
|
+
Инициализация дискового кэша.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
cache_dir: Директория для кэша (по умолчанию .cache/bquant)
|
|
258
|
+
"""
|
|
259
|
+
if cache_dir is None:
|
|
260
|
+
cache_dir = Path.home() / ".cache" / "bquant"
|
|
261
|
+
|
|
262
|
+
self.cache_dir = Path(cache_dir)
|
|
263
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
|
|
265
|
+
self.logger = get_logger(f"{__name__}.DiskCache")
|
|
266
|
+
self.logger.info(f"Disk cache initialized: {self.cache_dir}")
|
|
267
|
+
|
|
268
|
+
def _get_file_path(self, key: str) -> Path:
|
|
269
|
+
"""Получить путь к файлу кэша."""
|
|
270
|
+
return self.cache_dir / f"{key}.pkl"
|
|
271
|
+
|
|
272
|
+
def get(self, key: str) -> Optional[Any]:
|
|
273
|
+
"""Получить значение из дискового кэша."""
|
|
274
|
+
file_path = self._get_file_path(key)
|
|
275
|
+
|
|
276
|
+
if not file_path.exists():
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
with open(file_path, 'rb') as f:
|
|
281
|
+
entry = pickle.load(f)
|
|
282
|
+
|
|
283
|
+
if entry.is_expired():
|
|
284
|
+
file_path.unlink()
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
entry.touch()
|
|
288
|
+
return entry.data
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
self.logger.warning(f"Failed to load from disk cache {key}: {e}")
|
|
292
|
+
# Удаляем поврежденный файл
|
|
293
|
+
if file_path.exists():
|
|
294
|
+
file_path.unlink()
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def put(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
298
|
+
"""Сохранить значение в дисковый кэш."""
|
|
299
|
+
file_path = self._get_file_path(key)
|
|
300
|
+
|
|
301
|
+
# Определяем время истечения
|
|
302
|
+
expiry = None
|
|
303
|
+
if ttl is not None:
|
|
304
|
+
expiry = datetime.now() + timedelta(seconds=ttl)
|
|
305
|
+
|
|
306
|
+
# Создаем запись
|
|
307
|
+
entry = CacheEntry(
|
|
308
|
+
data=value,
|
|
309
|
+
timestamp=datetime.now(),
|
|
310
|
+
expiry=expiry
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
with open(file_path, 'wb') as f:
|
|
315
|
+
pickle.dump(entry, f)
|
|
316
|
+
|
|
317
|
+
self.logger.debug(f"Saved to disk cache: {key}")
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self.logger.error(f"Failed to save to disk cache {key}: {e}")
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
def invalidate(self, key: str) -> bool:
|
|
325
|
+
"""Удалить файл из дискового кэша."""
|
|
326
|
+
file_path = self._get_file_path(key)
|
|
327
|
+
|
|
328
|
+
if file_path.exists():
|
|
329
|
+
file_path.unlink()
|
|
330
|
+
self.logger.debug(f"Removed from disk cache: {key}")
|
|
331
|
+
return True
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
def clear(self) -> int:
|
|
335
|
+
"""Очистить весь дисковый кэш."""
|
|
336
|
+
count = 0
|
|
337
|
+
for file_path in self.cache_dir.glob("*.pkl"):
|
|
338
|
+
file_path.unlink()
|
|
339
|
+
count += 1
|
|
340
|
+
|
|
341
|
+
self.logger.info(f"Cleared disk cache: {count} files removed")
|
|
342
|
+
return count
|
|
343
|
+
|
|
344
|
+
def cleanup_expired(self) -> int:
|
|
345
|
+
"""Удалить истекшие файлы."""
|
|
346
|
+
expired_count = 0
|
|
347
|
+
|
|
348
|
+
for file_path in self.cache_dir.glob("*.pkl"):
|
|
349
|
+
try:
|
|
350
|
+
with open(file_path, 'rb') as f:
|
|
351
|
+
entry = pickle.load(f)
|
|
352
|
+
|
|
353
|
+
if entry.is_expired():
|
|
354
|
+
file_path.unlink()
|
|
355
|
+
expired_count += 1
|
|
356
|
+
|
|
357
|
+
except Exception:
|
|
358
|
+
# Удаляем поврежденные файлы
|
|
359
|
+
file_path.unlink()
|
|
360
|
+
expired_count += 1
|
|
361
|
+
|
|
362
|
+
if expired_count > 0:
|
|
363
|
+
self.logger.info(f"Cleaned up {expired_count} expired disk cache entries")
|
|
364
|
+
|
|
365
|
+
return expired_count
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class CacheManager:
|
|
369
|
+
"""
|
|
370
|
+
Менеджер кэширования, объединяющий память и диск.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(self, memory_size: int = 100, disk_cache: bool = True):
|
|
374
|
+
"""
|
|
375
|
+
Инициализация менеджера кэша.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
memory_size: Размер кэша в памяти
|
|
379
|
+
disk_cache: Использовать ли дисковый кэш
|
|
380
|
+
"""
|
|
381
|
+
self.memory_cache = MemoryCache(max_size=memory_size)
|
|
382
|
+
self.disk_cache = DiskCache() if disk_cache else None
|
|
383
|
+
self.logger = get_logger(f"{__name__}.CacheManager")
|
|
384
|
+
|
|
385
|
+
def get(self, key: str) -> Optional[Any]:
|
|
386
|
+
"""Получить значение из кэша (сначала память, потом диск)."""
|
|
387
|
+
# Сначала проверяем память
|
|
388
|
+
result = self.memory_cache.get(key)
|
|
389
|
+
if result is not None:
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
# Потом диск
|
|
393
|
+
if self.disk_cache:
|
|
394
|
+
result = self.disk_cache.get(key)
|
|
395
|
+
if result is not None:
|
|
396
|
+
# Сохраняем в память для быстрого доступа
|
|
397
|
+
self.memory_cache.put(key, result)
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
def put(self, key: str, value: Any, ttl: Optional[int] = None, disk: bool = True) -> None:
|
|
403
|
+
"""Сохранить значение в кэш."""
|
|
404
|
+
# Всегда сохраняем в память
|
|
405
|
+
self.memory_cache.put(key, value, ttl)
|
|
406
|
+
|
|
407
|
+
# Сохраняем на диск если разрешено
|
|
408
|
+
if disk and self.disk_cache:
|
|
409
|
+
self.disk_cache.put(key, value, ttl)
|
|
410
|
+
|
|
411
|
+
def invalidate(self, key: str) -> None:
|
|
412
|
+
"""Удалить из всех кэшей."""
|
|
413
|
+
self.memory_cache.invalidate(key)
|
|
414
|
+
if self.disk_cache:
|
|
415
|
+
self.disk_cache.invalidate(key)
|
|
416
|
+
|
|
417
|
+
def clear(self) -> None:
|
|
418
|
+
"""Очистить все кэши."""
|
|
419
|
+
self.memory_cache.clear()
|
|
420
|
+
if self.disk_cache:
|
|
421
|
+
self.disk_cache.clear()
|
|
422
|
+
|
|
423
|
+
def cleanup(self) -> Dict[str, int]:
|
|
424
|
+
"""Очистить истекшие записи."""
|
|
425
|
+
memory_cleaned = self.memory_cache.cleanup_expired()
|
|
426
|
+
disk_cleaned = self.disk_cache.cleanup_expired() if self.disk_cache else 0
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
'memory_cleaned': memory_cleaned,
|
|
430
|
+
'disk_cleaned': disk_cleaned
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
def stats(self) -> Dict[str, Any]:
|
|
434
|
+
"""Получить общую статистику."""
|
|
435
|
+
stats = {
|
|
436
|
+
'memory': self.memory_cache.stats()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if self.disk_cache:
|
|
440
|
+
disk_files = len(list(self.disk_cache.cache_dir.glob("*.pkl")))
|
|
441
|
+
stats['disk'] = {
|
|
442
|
+
'entries': disk_files,
|
|
443
|
+
'cache_dir': str(self.disk_cache.cache_dir)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return stats
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# Глобальный менеджер кэша
|
|
450
|
+
_global_cache_manager: Optional[CacheManager] = None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def get_cache_manager() -> CacheManager:
|
|
454
|
+
"""Получить глобальный менеджер кэша."""
|
|
455
|
+
global _global_cache_manager
|
|
456
|
+
|
|
457
|
+
if _global_cache_manager is None:
|
|
458
|
+
cache_config = get_cache_config()
|
|
459
|
+
_global_cache_manager = CacheManager(
|
|
460
|
+
memory_size=cache_config.get('memory_size', 100),
|
|
461
|
+
disk_cache=cache_config.get('enable_disk_cache', True)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return _global_cache_manager
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def cached(ttl: Optional[int] = None, disk: bool = True, key_prefix: str = ""):
|
|
468
|
+
"""
|
|
469
|
+
Декоратор для кэширования результатов функций.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
ttl: Время жизни кэша в секундах
|
|
473
|
+
disk: Сохранять ли на диск
|
|
474
|
+
key_prefix: Префикс для ключа кэша
|
|
475
|
+
"""
|
|
476
|
+
def decorator(func: Callable) -> Callable:
|
|
477
|
+
@wraps(func)
|
|
478
|
+
def wrapper(*args, **kwargs):
|
|
479
|
+
cache_manager = get_cache_manager()
|
|
480
|
+
|
|
481
|
+
# Генерируем ключ
|
|
482
|
+
func_name = f"{key_prefix}{func.__module__}.{func.__name__}"
|
|
483
|
+
key = cache_manager.memory_cache._generate_key(func_name, args, kwargs)
|
|
484
|
+
|
|
485
|
+
# Проверяем кэш
|
|
486
|
+
result = cache_manager.get(key)
|
|
487
|
+
if result is not None:
|
|
488
|
+
logger.debug(f"Cache hit for {func_name}")
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
# Вычисляем результат
|
|
492
|
+
logger.debug(f"Cache miss for {func_name}, calculating...")
|
|
493
|
+
result = func(*args, **kwargs)
|
|
494
|
+
|
|
495
|
+
# Сохраняем в кэш
|
|
496
|
+
cache_manager.put(key, result, ttl, disk)
|
|
497
|
+
|
|
498
|
+
return result
|
|
499
|
+
|
|
500
|
+
return wrapper
|
|
501
|
+
return decorator
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def cache_key(*args, **kwargs) -> str:
|
|
505
|
+
"""Генерирует ключ кэша для заданных аргументов."""
|
|
506
|
+
cache_manager = get_cache_manager()
|
|
507
|
+
return cache_manager.memory_cache._generate_key("manual_key", args, kwargs)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def clear_cache():
|
|
511
|
+
"""Очистить глобальный кэш."""
|
|
512
|
+
global _global_cache_manager
|
|
513
|
+
if _global_cache_manager:
|
|
514
|
+
_global_cache_manager.clear()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def cache_stats() -> Dict[str, Any]:
|
|
518
|
+
"""Получить статистику глобального кэша."""
|
|
519
|
+
cache_manager = get_cache_manager()
|
|
520
|
+
return cache_manager.stats()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# Экспорт
|
|
524
|
+
__all__ = [
|
|
525
|
+
'CacheEntry',
|
|
526
|
+
'MemoryCache',
|
|
527
|
+
'DiskCache',
|
|
528
|
+
'CacheManager',
|
|
529
|
+
'get_cache_manager',
|
|
530
|
+
'cached',
|
|
531
|
+
'cache_key',
|
|
532
|
+
'clear_cache',
|
|
533
|
+
'cache_stats'
|
|
534
|
+
]
|