binance-quant-engine 0.1.1__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.
- binance_quant_engine/__init__.py +3 -0
- binance_quant_engine/backtest/__init__.py +1 -0
- binance_quant_engine/backtest/vectorized.py +177 -0
- binance_quant_engine/data/__init__.py +1 -0
- binance_quant_engine/data/cache.py +540 -0
- binance_quant_engine/data/klines.py +43 -0
- binance_quant_engine/execution/__init__.py +1 -0
- binance_quant_engine/execution/algo_api.py +84 -0
- binance_quant_engine/execution/brackets.py +973 -0
- binance_quant_engine/execution/host.py +65 -0
- binance_quant_engine/execution/utils.py +46 -0
- binance_quant_engine/mcp/__init__.py +1 -0
- binance_quant_engine/mcp/server.py +115 -0
- binance_quant_engine/strategy/__init__.py +1 -0
- binance_quant_engine/strategy/demo_squeeze.py +183 -0
- binance_quant_engine/strategy/protocol.py +95 -0
- binance_quant_engine-0.1.1.dist-info/METADATA +159 -0
- binance_quant_engine-0.1.1.dist-info/RECORD +21 -0
- binance_quant_engine-0.1.1.dist-info/WHEEL +4 -0
- binance_quant_engine-0.1.1.dist-info/entry_points.txt +3 -0
- binance_quant_engine-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Systematic data caching system for cryptocurrency market data.
|
|
3
|
+
|
|
4
|
+
Provides intelligent caching with:
|
|
5
|
+
- Multiple timeframe support
|
|
6
|
+
- Automatic cache invalidation
|
|
7
|
+
- Efficient storage with compression
|
|
8
|
+
- Memory and disk caching layers
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import pickle
|
|
13
|
+
import gzip
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Callable, Dict, List, Optional, Any
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from threading import Lock
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("DataCache")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CacheEntry:
|
|
30
|
+
"""Represents a cached data entry."""
|
|
31
|
+
|
|
32
|
+
key: str
|
|
33
|
+
data: Any
|
|
34
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
35
|
+
expires_at: Optional[datetime] = None
|
|
36
|
+
metadata: Dict = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_expired(self) -> bool:
|
|
40
|
+
"""Check if cache entry is expired."""
|
|
41
|
+
if self.expires_at is None:
|
|
42
|
+
return False
|
|
43
|
+
return datetime.now(timezone.utc) > self.expires_at
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def age_hours(self) -> float:
|
|
47
|
+
"""Get age of cache entry in hours."""
|
|
48
|
+
delta = datetime.now(timezone.utc) - self.created_at
|
|
49
|
+
return delta.total_seconds() / 3600
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MemoryCache:
|
|
53
|
+
"""In-memory LRU cache layer."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, max_size: int = 100):
|
|
56
|
+
self.max_size = max_size
|
|
57
|
+
self._cache: Dict[str, CacheEntry] = {}
|
|
58
|
+
self._access_order: List[str] = []
|
|
59
|
+
self._lock = Lock()
|
|
60
|
+
|
|
61
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
62
|
+
"""Get entry from cache."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
if key in self._cache:
|
|
65
|
+
entry = self._cache[key]
|
|
66
|
+
if not entry.is_expired:
|
|
67
|
+
# Move to end (most recently used)
|
|
68
|
+
self._access_order.remove(key)
|
|
69
|
+
self._access_order.append(key)
|
|
70
|
+
return entry
|
|
71
|
+
else:
|
|
72
|
+
# Remove expired entry
|
|
73
|
+
del self._cache[key]
|
|
74
|
+
self._access_order.remove(key)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def put(self, entry: CacheEntry):
|
|
78
|
+
"""Put entry in cache."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
# Evict if at capacity
|
|
81
|
+
while len(self._cache) >= self.max_size:
|
|
82
|
+
oldest_key = self._access_order.pop(0)
|
|
83
|
+
del self._cache[oldest_key]
|
|
84
|
+
|
|
85
|
+
self._cache[entry.key] = entry
|
|
86
|
+
if entry.key in self._access_order:
|
|
87
|
+
self._access_order.remove(entry.key)
|
|
88
|
+
self._access_order.append(entry.key)
|
|
89
|
+
|
|
90
|
+
def remove(self, key: str):
|
|
91
|
+
"""Remove entry from cache."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
if key in self._cache:
|
|
94
|
+
del self._cache[key]
|
|
95
|
+
self._access_order.remove(key)
|
|
96
|
+
|
|
97
|
+
def clear(self):
|
|
98
|
+
"""Clear all cache entries."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._cache.clear()
|
|
101
|
+
self._access_order.clear()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def size(self) -> int:
|
|
105
|
+
"""Get current cache size."""
|
|
106
|
+
return len(self._cache)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DiskCache:
|
|
110
|
+
"""Disk-based cache layer with compression."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, cache_dir: str, compress: bool = True):
|
|
113
|
+
self.cache_dir = Path(cache_dir)
|
|
114
|
+
self.compress = compress
|
|
115
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
# Index file for metadata
|
|
118
|
+
self.index_file = self.cache_dir / "cache_index.json"
|
|
119
|
+
self._index = self._load_index()
|
|
120
|
+
|
|
121
|
+
def _load_index(self) -> Dict:
|
|
122
|
+
"""Load cache index from disk."""
|
|
123
|
+
if self.index_file.exists():
|
|
124
|
+
try:
|
|
125
|
+
with open(self.index_file, "r") as f:
|
|
126
|
+
return json.load(f)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.warning(f"Failed to load cache index: {e}")
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def _save_index(self):
|
|
132
|
+
"""Save cache index to disk."""
|
|
133
|
+
try:
|
|
134
|
+
with open(self.index_file, "w") as f:
|
|
135
|
+
json.dump(self._index, f, indent=2, default=str)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.error(f"Failed to save cache index: {e}")
|
|
138
|
+
|
|
139
|
+
def _get_filepath(self, key: str) -> Path:
|
|
140
|
+
"""Get filepath for a cache key."""
|
|
141
|
+
# Use hash for filename to avoid special characters
|
|
142
|
+
hash_key = hashlib.md5(key.encode()).hexdigest()
|
|
143
|
+
ext = ".pkl.gz" if self.compress else ".pkl"
|
|
144
|
+
return self.cache_dir / f"{hash_key}{ext}"
|
|
145
|
+
|
|
146
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
147
|
+
"""Get entry from disk cache."""
|
|
148
|
+
filepath = self._get_filepath(key)
|
|
149
|
+
|
|
150
|
+
if not filepath.exists():
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
# Check index for expiration
|
|
154
|
+
if key in self._index:
|
|
155
|
+
expires_at = self._index[key].get("expires_at")
|
|
156
|
+
if expires_at:
|
|
157
|
+
expires_dt = datetime.fromisoformat(expires_at)
|
|
158
|
+
if datetime.now(timezone.utc) > expires_dt:
|
|
159
|
+
self.remove(key)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
if self.compress:
|
|
164
|
+
with gzip.open(filepath, "rb") as f:
|
|
165
|
+
entry = pickle.load(f)
|
|
166
|
+
else:
|
|
167
|
+
with open(filepath, "rb") as f:
|
|
168
|
+
entry = pickle.load(f)
|
|
169
|
+
return entry
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Failed to load cache entry {key}: {e}")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def put(self, entry: CacheEntry):
|
|
175
|
+
"""Put entry in disk cache."""
|
|
176
|
+
filepath = self._get_filepath(entry.key)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
if self.compress:
|
|
180
|
+
with gzip.open(filepath, "wb") as f:
|
|
181
|
+
pickle.dump(entry, f)
|
|
182
|
+
else:
|
|
183
|
+
with open(filepath, "wb") as f:
|
|
184
|
+
pickle.dump(entry, f)
|
|
185
|
+
|
|
186
|
+
# Update index
|
|
187
|
+
self._index[entry.key] = {
|
|
188
|
+
"filepath": str(filepath),
|
|
189
|
+
"created_at": entry.created_at.isoformat(),
|
|
190
|
+
"expires_at": entry.expires_at.isoformat() if entry.expires_at else None,
|
|
191
|
+
"metadata": entry.metadata,
|
|
192
|
+
}
|
|
193
|
+
self._save_index()
|
|
194
|
+
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.error(f"Failed to save cache entry {entry.key}: {e}")
|
|
197
|
+
|
|
198
|
+
def remove(self, key: str):
|
|
199
|
+
"""Remove entry from disk cache."""
|
|
200
|
+
filepath = self._get_filepath(key)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
if filepath.exists():
|
|
204
|
+
filepath.unlink()
|
|
205
|
+
if key in self._index:
|
|
206
|
+
del self._index[key]
|
|
207
|
+
self._save_index()
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to remove cache entry {key}: {e}")
|
|
210
|
+
|
|
211
|
+
def clear(self):
|
|
212
|
+
"""Clear all cache entries."""
|
|
213
|
+
try:
|
|
214
|
+
for filepath in self.cache_dir.glob("*.pkl*"):
|
|
215
|
+
filepath.unlink()
|
|
216
|
+
self._index.clear()
|
|
217
|
+
self._save_index()
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(f"Failed to clear disk cache: {e}")
|
|
220
|
+
|
|
221
|
+
def cleanup_expired(self):
|
|
222
|
+
"""Remove expired entries."""
|
|
223
|
+
now = datetime.now(timezone.utc)
|
|
224
|
+
expired_keys = []
|
|
225
|
+
|
|
226
|
+
for key, info in self._index.items():
|
|
227
|
+
expires_at = info.get("expires_at")
|
|
228
|
+
if expires_at:
|
|
229
|
+
if now > datetime.fromisoformat(expires_at):
|
|
230
|
+
expired_keys.append(key)
|
|
231
|
+
|
|
232
|
+
for key in expired_keys:
|
|
233
|
+
self.remove(key)
|
|
234
|
+
|
|
235
|
+
logger.info(f"Cleaned up {len(expired_keys)} expired cache entries")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class DataCache:
|
|
239
|
+
"""
|
|
240
|
+
Multi-level caching system for market data.
|
|
241
|
+
|
|
242
|
+
Features:
|
|
243
|
+
- Two-tier caching (memory + disk)
|
|
244
|
+
- Automatic expiration
|
|
245
|
+
- Compression for disk storage
|
|
246
|
+
- Support for multiple timeframes
|
|
247
|
+
- Cache statistics and monitoring
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(
|
|
251
|
+
self,
|
|
252
|
+
cache_dir: str = "data/cache",
|
|
253
|
+
memory_size: int = 100,
|
|
254
|
+
default_expiry_hours: int = 24,
|
|
255
|
+
compress: bool = True,
|
|
256
|
+
):
|
|
257
|
+
self.cache_dir = cache_dir
|
|
258
|
+
self.default_expiry_hours = default_expiry_hours
|
|
259
|
+
|
|
260
|
+
# Initialize cache layers
|
|
261
|
+
self.memory_cache = MemoryCache(max_size=memory_size)
|
|
262
|
+
self.disk_cache = DiskCache(cache_dir, compress=compress)
|
|
263
|
+
|
|
264
|
+
# Statistics
|
|
265
|
+
self._stats = {"memory_hits": 0, "disk_hits": 0, "misses": 0, "writes": 0}
|
|
266
|
+
|
|
267
|
+
logger.info(f"DataCache initialized at {cache_dir}")
|
|
268
|
+
|
|
269
|
+
def _generate_key(self, symbol: str, interval: str, start: datetime, end: datetime) -> str:
|
|
270
|
+
"""Generate cache key for market data."""
|
|
271
|
+
start_str = start.strftime("%Y%m%d_%H%M")
|
|
272
|
+
end_str = end.strftime("%Y%m%d_%H%M")
|
|
273
|
+
return f"{symbol}_{interval}_{start_str}_{end_str}"
|
|
274
|
+
|
|
275
|
+
def get(self, symbol: str, interval: str, start: datetime, end: datetime) -> Optional[pd.DataFrame]:
|
|
276
|
+
"""
|
|
277
|
+
Get cached data for a symbol and timeframe.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
symbol: Trading symbol
|
|
281
|
+
interval: Time interval (e.g., '1h', '15m')
|
|
282
|
+
start: Start datetime
|
|
283
|
+
end: End datetime
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
DataFrame if cached, None otherwise
|
|
287
|
+
"""
|
|
288
|
+
key = self._generate_key(symbol, interval, start, end)
|
|
289
|
+
|
|
290
|
+
# Try memory cache first
|
|
291
|
+
entry = self.memory_cache.get(key)
|
|
292
|
+
if entry:
|
|
293
|
+
self._stats["memory_hits"] += 1
|
|
294
|
+
logger.debug(f"Memory cache hit: {key}")
|
|
295
|
+
return entry.data
|
|
296
|
+
|
|
297
|
+
# Try disk cache
|
|
298
|
+
entry = self.disk_cache.get(key)
|
|
299
|
+
if entry:
|
|
300
|
+
self._stats["disk_hits"] += 1
|
|
301
|
+
# Promote to memory cache
|
|
302
|
+
self.memory_cache.put(entry)
|
|
303
|
+
logger.debug(f"Disk cache hit: {key}")
|
|
304
|
+
return entry.data
|
|
305
|
+
|
|
306
|
+
self._stats["misses"] += 1
|
|
307
|
+
logger.debug(f"Cache miss: {key}")
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
def put(
|
|
311
|
+
self,
|
|
312
|
+
symbol: str,
|
|
313
|
+
interval: str,
|
|
314
|
+
start: datetime,
|
|
315
|
+
end: datetime,
|
|
316
|
+
data: pd.DataFrame,
|
|
317
|
+
expiry_hours: Optional[int] = None,
|
|
318
|
+
metadata: Optional[Dict] = None,
|
|
319
|
+
):
|
|
320
|
+
"""
|
|
321
|
+
Cache data for a symbol and timeframe.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
symbol: Trading symbol
|
|
325
|
+
interval: Time interval
|
|
326
|
+
start: Start datetime
|
|
327
|
+
end: End datetime
|
|
328
|
+
data: DataFrame to cache
|
|
329
|
+
expiry_hours: Cache expiration in hours
|
|
330
|
+
metadata: Additional metadata
|
|
331
|
+
"""
|
|
332
|
+
key = self._generate_key(symbol, interval, start, end)
|
|
333
|
+
expiry = expiry_hours or self.default_expiry_hours
|
|
334
|
+
|
|
335
|
+
entry = CacheEntry(
|
|
336
|
+
key=key,
|
|
337
|
+
data=data,
|
|
338
|
+
expires_at=datetime.now(timezone.utc) + timedelta(hours=expiry),
|
|
339
|
+
metadata=metadata
|
|
340
|
+
or {
|
|
341
|
+
"symbol": symbol,
|
|
342
|
+
"interval": interval,
|
|
343
|
+
"rows": len(data),
|
|
344
|
+
"start": start.isoformat(),
|
|
345
|
+
"end": end.isoformat(),
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Write to both caches
|
|
350
|
+
self.memory_cache.put(entry)
|
|
351
|
+
self.disk_cache.put(entry)
|
|
352
|
+
|
|
353
|
+
self._stats["writes"] += 1
|
|
354
|
+
logger.debug(f"Cached {len(data)} rows for {key}")
|
|
355
|
+
|
|
356
|
+
def get_or_fetch(
|
|
357
|
+
self,
|
|
358
|
+
symbol: str,
|
|
359
|
+
interval: str,
|
|
360
|
+
start: datetime,
|
|
361
|
+
end: datetime,
|
|
362
|
+
fetch_func: Callable[..., Any],
|
|
363
|
+
expiry_hours: Optional[int] = None,
|
|
364
|
+
) -> pd.DataFrame:
|
|
365
|
+
"""
|
|
366
|
+
Get cached data or fetch and cache if not available.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
symbol: Trading symbol
|
|
370
|
+
interval: Time interval
|
|
371
|
+
start: Start datetime
|
|
372
|
+
end: End datetime
|
|
373
|
+
fetch_func: Function to fetch data if not cached
|
|
374
|
+
expiry_hours: Cache expiration in hours
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
DataFrame with market data
|
|
378
|
+
"""
|
|
379
|
+
# Try cache first
|
|
380
|
+
data = self.get(symbol, interval, start, end)
|
|
381
|
+
if data is not None:
|
|
382
|
+
return data
|
|
383
|
+
|
|
384
|
+
# Fetch data
|
|
385
|
+
logger.info(f"Fetching data for {symbol} {interval} {start} to {end}")
|
|
386
|
+
data = fetch_func(symbol, interval, start, end)
|
|
387
|
+
|
|
388
|
+
# Cache the result
|
|
389
|
+
if data is not None and len(data) > 0:
|
|
390
|
+
self.put(symbol, interval, start, end, data, expiry_hours)
|
|
391
|
+
|
|
392
|
+
return data
|
|
393
|
+
|
|
394
|
+
def get_multi(self, symbols: List[str], interval: str, start: datetime, end: datetime) -> Dict[str, pd.DataFrame]:
|
|
395
|
+
"""
|
|
396
|
+
Get cached data for multiple symbols.
|
|
397
|
+
|
|
398
|
+
Returns dict of symbol -> DataFrame (only for cached symbols)
|
|
399
|
+
"""
|
|
400
|
+
result = {}
|
|
401
|
+
for symbol in symbols:
|
|
402
|
+
data = self.get(symbol, interval, start, end)
|
|
403
|
+
if data is not None:
|
|
404
|
+
result[symbol] = data
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
def put_multi(
|
|
408
|
+
self,
|
|
409
|
+
data: Dict[str, pd.DataFrame],
|
|
410
|
+
interval: str,
|
|
411
|
+
start: datetime,
|
|
412
|
+
end: datetime,
|
|
413
|
+
expiry_hours: Optional[int] = None,
|
|
414
|
+
):
|
|
415
|
+
"""
|
|
416
|
+
Cache data for multiple symbols.
|
|
417
|
+
"""
|
|
418
|
+
for symbol, df in data.items():
|
|
419
|
+
self.put(symbol, interval, start, end, df, expiry_hours)
|
|
420
|
+
|
|
421
|
+
def invalidate(self, symbol: Optional[str] = None, interval: Optional[str] = None):
|
|
422
|
+
"""
|
|
423
|
+
Invalidate cache entries.
|
|
424
|
+
|
|
425
|
+
If symbol and interval provided, invalidates specific entries.
|
|
426
|
+
Otherwise clears all cache.
|
|
427
|
+
"""
|
|
428
|
+
if symbol is None and interval is None:
|
|
429
|
+
self.memory_cache.clear()
|
|
430
|
+
self.disk_cache.clear()
|
|
431
|
+
logger.info("Cleared all cache")
|
|
432
|
+
else:
|
|
433
|
+
# Would need to iterate through index to find matching entries
|
|
434
|
+
# For now, just clear all
|
|
435
|
+
self.memory_cache.clear()
|
|
436
|
+
logger.info(f"Invalidated cache for {symbol or '*'}/{interval or '*'}")
|
|
437
|
+
|
|
438
|
+
def cleanup(self):
|
|
439
|
+
"""Clean up expired entries."""
|
|
440
|
+
self.disk_cache.cleanup_expired()
|
|
441
|
+
|
|
442
|
+
def get_stats(self) -> Dict:
|
|
443
|
+
"""Get cache statistics."""
|
|
444
|
+
total_hits = self._stats["memory_hits"] + self._stats["disk_hits"]
|
|
445
|
+
total_requests = total_hits + self._stats["misses"]
|
|
446
|
+
hit_rate = total_hits / total_requests if total_requests > 0 else 0
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
**self._stats,
|
|
450
|
+
"total_hits": total_hits,
|
|
451
|
+
"total_requests": total_requests,
|
|
452
|
+
"hit_rate": hit_rate,
|
|
453
|
+
"hit_rate_pct": hit_rate * 100,
|
|
454
|
+
"memory_cache_size": self.memory_cache.size,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
def preload(
|
|
458
|
+
self, symbols: List[str], intervals: List[str], days: int = 30, fetch_func: Callable[..., Any] | None = None
|
|
459
|
+
):
|
|
460
|
+
"""
|
|
461
|
+
Preload cache with historical data.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
symbols: List of symbols to cache
|
|
465
|
+
intervals: List of intervals to cache
|
|
466
|
+
days: Number of days of history
|
|
467
|
+
fetch_func: Function to fetch data
|
|
468
|
+
"""
|
|
469
|
+
if not fetch_func:
|
|
470
|
+
logger.warning("No fetch function provided for preload")
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
end = datetime.now(timezone.utc)
|
|
474
|
+
start = end - timedelta(days=days)
|
|
475
|
+
|
|
476
|
+
total = len(symbols) * len(intervals)
|
|
477
|
+
loaded = 0
|
|
478
|
+
|
|
479
|
+
for symbol in symbols:
|
|
480
|
+
for interval in intervals:
|
|
481
|
+
try:
|
|
482
|
+
self.get_or_fetch(symbol, interval, start, end, fetch_func)
|
|
483
|
+
loaded += 1
|
|
484
|
+
logger.info(f"Preloaded {loaded}/{total}: {symbol} {interval}")
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logger.error(f"Failed to preload {symbol} {interval}: {e}")
|
|
487
|
+
|
|
488
|
+
logger.info(f"Preload complete: {loaded}/{total} entries cached")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class TimeframedCache:
|
|
492
|
+
"""
|
|
493
|
+
Cache manager that handles multiple timeframes efficiently.
|
|
494
|
+
|
|
495
|
+
Automatically manages different cache expiry based on timeframe.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
# Default expiry hours based on timeframe
|
|
499
|
+
EXPIRY_MAP = {
|
|
500
|
+
"1m": 1,
|
|
501
|
+
"5m": 4,
|
|
502
|
+
"15m": 12,
|
|
503
|
+
"30m": 24,
|
|
504
|
+
"1h": 48,
|
|
505
|
+
"4h": 168, # 1 week
|
|
506
|
+
"1d": 720, # 1 month
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
def __init__(self, base_dir: str = "data/cache"):
|
|
510
|
+
self.base_dir = base_dir
|
|
511
|
+
self._caches: Dict[str, DataCache] = {}
|
|
512
|
+
|
|
513
|
+
def _get_cache(self, interval: str) -> DataCache:
|
|
514
|
+
"""Get or create cache for interval."""
|
|
515
|
+
if interval not in self._caches:
|
|
516
|
+
cache_dir = os.path.join(self.base_dir, interval)
|
|
517
|
+
expiry = self.EXPIRY_MAP.get(interval, 24)
|
|
518
|
+
self._caches[interval] = DataCache(cache_dir=cache_dir, default_expiry_hours=expiry)
|
|
519
|
+
return self._caches[interval]
|
|
520
|
+
|
|
521
|
+
def get(self, symbol: str, interval: str, start: datetime, end: datetime) -> Optional[pd.DataFrame]:
|
|
522
|
+
"""Get data from appropriate timeframe cache."""
|
|
523
|
+
cache = self._get_cache(interval)
|
|
524
|
+
return cache.get(symbol, interval, start, end)
|
|
525
|
+
|
|
526
|
+
def put(self, symbol: str, interval: str, start: datetime, end: datetime, data: pd.DataFrame):
|
|
527
|
+
"""Put data in appropriate timeframe cache."""
|
|
528
|
+
cache = self._get_cache(interval)
|
|
529
|
+
cache.put(symbol, interval, start, end, data)
|
|
530
|
+
|
|
531
|
+
def get_or_fetch(
|
|
532
|
+
self, symbol: str, interval: str, start: datetime, end: datetime, fetch_func: Callable[..., Any]
|
|
533
|
+
) -> pd.DataFrame:
|
|
534
|
+
"""Get or fetch data from appropriate timeframe cache."""
|
|
535
|
+
cache = self._get_cache(interval)
|
|
536
|
+
return cache.get_or_fetch(symbol, interval, start, end, fetch_func)
|
|
537
|
+
|
|
538
|
+
def get_all_stats(self) -> Dict[str, Dict]:
|
|
539
|
+
"""Get statistics for all timeframe caches."""
|
|
540
|
+
return {interval: cache.get_stats() for interval, cache in self._caches.items()}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Kline (candlestick) loading and a synthetic generator for offline demos.
|
|
2
|
+
|
|
3
|
+
:func:`load_csv` reads a standard OHLCV CSV; :func:`synth_ohlcv` fabricates a
|
|
4
|
+
deterministic series with alternating low- and high-volatility regimes so the
|
|
5
|
+
bundled demo runs end-to-end with no network, keys, or vendor data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_csv(path: str | Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
17
|
+
"""Load ``high, low, close`` arrays from an OHLCV CSV (case-insensitive)."""
|
|
18
|
+
df = pd.read_csv(path)
|
|
19
|
+
cols = {c.lower(): c for c in df.columns}
|
|
20
|
+
return (
|
|
21
|
+
df[cols["high"]].to_numpy(float),
|
|
22
|
+
df[cols["low"]].to_numpy(float),
|
|
23
|
+
df[cols["close"]].to_numpy(float),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def synth_ohlcv(
|
|
28
|
+
n: int = 2000, seed: int = 7, start: float = 100.0
|
|
29
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
30
|
+
"""Generate a deterministic OHLC series with volatility regime switches.
|
|
31
|
+
|
|
32
|
+
Volatility cycles between a quiet "squeeze" phase and an expansion phase, so
|
|
33
|
+
a squeeze strategy has something to react to. Returns ``(high, low, close)``.
|
|
34
|
+
"""
|
|
35
|
+
rng = np.random.default_rng(seed)
|
|
36
|
+
vol = np.where((np.arange(n) // 120) % 2 == 0, 0.004, 0.018) # quiet ↔ wild
|
|
37
|
+
rets = rng.normal(0, 1, n) * vol
|
|
38
|
+
close = start * np.exp(np.cumsum(rets))
|
|
39
|
+
# Intrabar range scaled by the regime's volatility.
|
|
40
|
+
span = close * vol * rng.uniform(0.5, 1.5, n)
|
|
41
|
+
high = close + span * rng.uniform(0.2, 1.0, n)
|
|
42
|
+
low = close - span * rng.uniform(0.2, 1.0, n)
|
|
43
|
+
return high, low, close
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Live execution layer (server-side bracket orders, Binance Algo API)."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Algo Order API client — wraps Binance private _request_futures_api calls.
|
|
2
|
+
|
|
3
|
+
Phase 3.3 (refactor/mega-v2): extracts all Algo API calls into a single
|
|
4
|
+
class so the rest of the codebase doesn't depend on python-binance internals.
|
|
5
|
+
If python-binance adds official Algo API support, only this file needs updating.
|
|
6
|
+
|
|
7
|
+
Binance Algo Order API endpoints (undocumented in python-binance):
|
|
8
|
+
POST /fapi/v1/algoOrder — place conditional/trailing order
|
|
9
|
+
DELETE /fapi/v1/algoOrder — cancel by algoId
|
|
10
|
+
GET /fapi/v1/algoOrder — query single order status
|
|
11
|
+
GET /fapi/v1/openAlgoOrders — list all open algo orders
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("Scalper")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AlgoApiClient:
|
|
23
|
+
"""Thin wrapper around Binance Algo Order API.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
client: binance.client.Client instance with _request_futures_api method.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Any) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
def place_order(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
33
|
+
"""Place an algo order (STOP, TAKE_PROFIT, TRAILING_STOP_MARKET).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
params: Full request params dict including algoType, symbol, side, etc.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Response dict with algoId on success.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
Exception: On API error (caller handles error codes like -2021).
|
|
43
|
+
"""
|
|
44
|
+
return self._client._request_futures_api("post", "algoOrder", signed=True, data=params)
|
|
45
|
+
|
|
46
|
+
def cancel_order(self, algo_id: int) -> dict[str, Any]:
|
|
47
|
+
"""Cancel an algo order by ID.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
algo_id: The algoId returned from place_order.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Response dict on success.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
Exception: On API error (caller handles benign errors like -2011).
|
|
57
|
+
"""
|
|
58
|
+
return self._client._request_futures_api("delete", "algoOrder", signed=True, data={"algoId": algo_id})
|
|
59
|
+
|
|
60
|
+
def get_order(self, algo_id: int) -> dict[str, Any]:
|
|
61
|
+
"""Query status of a single algo order.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
algo_id: The algoId to query.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Response dict with algoStatus, orderType, etc.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
Exception: On API error.
|
|
71
|
+
"""
|
|
72
|
+
return self._client._request_futures_api("get", "algoOrder", signed=True, data={"algoId": algo_id})
|
|
73
|
+
|
|
74
|
+
def list_open_orders(self) -> dict[str, Any] | list[Any]:
|
|
75
|
+
"""List all open algo orders on the account.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Response dict with 'orders' list, or a list directly
|
|
79
|
+
(Binance response format varies).
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
Exception: On API error.
|
|
83
|
+
"""
|
|
84
|
+
return self._client._request_futures_api("get", "openAlgoOrders", signed=True, data={})
|