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.
@@ -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={})