Spaces:
Running on Zero
Running on Zero
Download src/drift_quant/api/repository.py from deveshc23/drift-quant: direct link, hf CLI and curl.
- Browser
- Download file 29.6 kB
-
https://huggingface.co/spaces/deveshc23/drift-quant/resolve/main/src/drift_quant/api/repository.py
- Command line
-
hf download hf://spaces/deveshc23/drift-quant/src/drift_quant/api/repository.py
-
curl -L -o repository.py https://huggingface.co/spaces/deveshc23/drift-quant/resolve/main/src/drift_quant/api/repository.py
29.6 kB
| """Safe, bounded access to locally persisted research artifacts, caches, and market data.""" | |
| import bisect | |
| import json | |
| import sqlite3 | |
| from collections import defaultdict | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| from drift_quant.nse_data.loader import LoadedNseDataset, NseBar | |
| _IN_MEMORY_BARS: dict[str, list[NseBar]] = {} | |
| _IN_MEMORY_TIMESTAMPS: dict[str, list[datetime]] = {} | |
| SECTOR_MAP: dict[str, str] = { | |
| # Banking & Financial Services | |
| "HDFCBANK": "Banking", | |
| "ICICIBANK": "Banking", | |
| "SBIN": "Banking", | |
| "AXISBANK": "Banking", | |
| "KOTAKBANK": "Banking", | |
| "INDUSINDBK": "Banking", | |
| "BANKBARODA": "Banking", | |
| "PNB": "Banking", | |
| "CANBK": "Banking", | |
| "BAJFINANCE": "Financial Services", | |
| "BAJAJFINSV": "Financial Services", | |
| "CHOLAFIN": "Financial Services", | |
| "HDFCLIFE": "Financial Services", | |
| "SBILIFE": "Financial Services", | |
| "MUTHOOTFIN": "Financial Services", | |
| "SHRIRAMFIN": "Financial Services", | |
| "360ONE": "Financial Services", | |
| # Information Technology | |
| "TCS": "IT", | |
| "INFY": "IT", | |
| "WIPRO": "IT", | |
| "HCLTECH": "IT", | |
| "TECHM": "IT", | |
| "LTIM": "IT", | |
| "PERSISTENT": "IT", | |
| "COFORGE": "IT", | |
| "MPHASIS": "IT", | |
| "LTTS": "IT", | |
| "TATAELXSI": "IT", | |
| # Energy, Oil & Gas | |
| "RELIANCE": "Energy", | |
| "ONGC": "Energy", | |
| "BPCL": "Energy", | |
| "IOC": "Energy", | |
| "POWERGRID": "Energy", | |
| "NTPC": "Energy", | |
| "TATAPOWER": "Energy", | |
| "COALINDIA": "Energy", | |
| "ADANIGREEN": "Energy", | |
| "ADANIPOWER": "Energy", | |
| "GAIL": "Energy", | |
| # FMCG & Consumer Goods | |
| "ITC": "FMCG", | |
| "HINDUNILVR": "FMCG", | |
| "NESTLEIND": "FMCG", | |
| "BRITANNIA": "FMCG", | |
| "DABUR": "FMCG", | |
| "MARICO": "FMCG", | |
| "GODREJCP": "FMCG", | |
| "COLPAL": "FMCG", | |
| "TATACONSUM": "FMCG", | |
| "VBL": "FMCG", | |
| # Automobiles | |
| "TATAMOTORS": "Automobile", | |
| "MARUTI": "Automobile", | |
| "M&M": "Automobile", | |
| "BAJAJ-AUTO": "Automobile", | |
| "HEROMOTOCO": "Automobile", | |
| "EICHERMOT": "Automobile", | |
| "TVSMOTOR": "Automobile", | |
| "BHARATFORG": "Automobile", | |
| # Metals & Mining | |
| "TATASTEEL": "Metals", | |
| "JSWSTEEL": "Metals", | |
| "HINDALCO": "Metals", | |
| "VEDL": "Metals", | |
| "JINDALSTEL": "Metals", | |
| "NMDC": "Metals", | |
| "SAIL": "Metals", | |
| # Pharmaceuticals & Healthcare | |
| "SUNPHARMA": "Pharma", | |
| "DRREDDY": "Pharma", | |
| "CIPLA": "Pharma", | |
| "DIVISLAB": "Pharma", | |
| "APOLLOHOSP": "Healthcare", | |
| "LUPIN": "Pharma", | |
| "AUROPHARMA": "Pharma", | |
| "TORNTPHARM": "Pharma", | |
| "MANKIND": "Pharma", | |
| # Infrastructure, Industrials & Telecom | |
| "LT": "Infrastructure", | |
| "BHARTIARTL": "Telecom", | |
| "ULTRACEMCO": "Cement", | |
| "GRASIM": "Conglomerate", | |
| "ADANIENT": "Conglomerate", | |
| "ADANIPORTS": "Infrastructure", | |
| "TITAN": "Consumer Goods", | |
| "ASIANPAINT": "Consumer Goods", | |
| "PIDILITIND": "Chemicals", | |
| "SIEMENS": "Industrials", | |
| "ABB": "Industrials", | |
| "BEL": "Defense", | |
| "HAL": "Defense", | |
| } | |
| CORE_SYMBOL_BASE_PRICES: dict[str, float] = { | |
| "RELIANCE": 2748.50, | |
| "TCS": 3892.40, | |
| "HDFCBANK": 1684.20, | |
| "INFY": 1840.10, | |
| "ICICIBANK": 1210.60, | |
| "ITC": 482.30, | |
| "TATAMOTORS": 965.80, | |
| "SUNPHARMA": 1712.40, | |
| "MARUTI": 11850.00, | |
| "BAJFINANCE": 6940.00, | |
| "LT": 3620.00, | |
| "BHARTIARTL": 1420.00, | |
| "AXISBANK": 1150.00, | |
| "KOTAKBANK": 1780.00, | |
| "WIPRO": 540.00, | |
| "HINDUNILVR": 2450.00, | |
| "TATASTEEL": 155.00, | |
| "ONGC": 280.00, | |
| "CIPLA": 1520.00, | |
| "TITAN": 3400.00, | |
| "BAJAJ-AUTO": 9200.00, | |
| "SBIN": 820.00, | |
| "HCLTECH": 1650.00, | |
| "TECHM": 1480.00, | |
| "M&M": 2850.00, | |
| "NTPC": 390.00, | |
| "POWERGRID": 320.00, | |
| "COALINDIA": 480.00, | |
| "ASIANPAINT": 2850.00, | |
| "DRREDDY": 6600.00, | |
| } | |
| class ArtifactNotFoundError(FileNotFoundError): | |
| """Raised when a requested persisted experiment or artifact does not exist.""" | |
| class ArtifactRepository: | |
| """Repository providing safe, bounded access to persisted research artifacts.""" | |
| def __init__(self, artifacts_directory: Path) -> None: | |
| self._root = artifacts_directory.resolve() | |
| self._experiments = self._root / "experiments" | |
| self._cache = self._root / "cache" | |
| def sqlite_db_path(self) -> Path: | |
| return self._cache / "market_data.sqlite" | |
| def health(self) -> dict[str, object]: | |
| index = self._index() | |
| sqlite_ok = self.sqlite_db_path.is_file() | |
| sqlite_bars = 0 | |
| if sqlite_ok: | |
| try: | |
| conn = sqlite3.connect(self.sqlite_db_path) | |
| sqlite_bars = conn.cursor().execute("SELECT count(*) FROM bars").fetchone()[0] | |
| conn.close() | |
| except Exception: | |
| sqlite_ok = False | |
| return { | |
| "status": "ok", | |
| "artifacts_directory": str(self._root), | |
| "completed_experiments": len(index), | |
| "dataset_audit_available": (self._root / "dataset_audit.json").is_file(), | |
| "sqlite_market_data_available": sqlite_ok, | |
| "cached_sqlite_bars": sqlite_bars, | |
| "currency": "INR", | |
| } | |
| def dataset_audit(self) -> dict[str, Any]: | |
| audit_path = self._root / "dataset_audit.json" | |
| if not audit_path.is_file(): | |
| raise ArtifactNotFoundError("Dataset audit does not exist.") | |
| return self._read_json(audit_path) | |
| def dataset_summary(self) -> dict[str, Any]: | |
| audit = self.dataset_audit() | |
| return { | |
| "archive_bytes": audit.get("archive_bytes", 260079380), | |
| "archive_sha256": audit.get("archive_sha256", ""), | |
| "total_rows": audit.get("rows", 17808743), | |
| "total_symbols": audit.get("symbols", 488), | |
| "first_timestamp": audit.get("first_timestamp", "2019-01-01 09:15:00"), | |
| "last_timestamp": audit.get("last_timestamp", "2025-07-14 11:00:00"), | |
| "zero_volume_rows": audit.get("zero_volume_rows", 34867), | |
| "invalid_ohlc_rows": audit.get("invalid_ohlc_rows", 72), | |
| "intraday_unexpected_gaps": audit.get("intraday_unexpected_gaps", 4894), | |
| "duplicate_timestamps": audit.get("duplicate_timestamps", 0), | |
| "timestamp_regressions": audit.get("timestamp_regressions", 0), | |
| "frequency": "15-minute", | |
| "currency": "INR", | |
| } | |
| def symbols_catalog( | |
| self, search: str | None = None, sector: str | None = None | |
| ) -> list[dict[str, Any]]: | |
| audit = self.dataset_audit() | |
| symbol_stats = audit.get("symbol_stats", {}) | |
| # Check which symbols are cached in SQLite | |
| cached_symbols: set[str] = set() | |
| if self.sqlite_db_path.is_file(): | |
| try: | |
| conn = sqlite3.connect(self.sqlite_db_path) | |
| rows = conn.cursor().execute("SELECT DISTINCT symbol FROM bars").fetchall() | |
| cached_symbols = {r[0] for r in rows} | |
| conn.close() | |
| except Exception: | |
| pass | |
| results = [] | |
| for sym, stats in symbol_stats.items(): | |
| sym_sector = SECTOR_MAP.get(sym, "Diversified / Other") | |
| if sector and sym_sector.lower() != sector.lower(): | |
| continue | |
| if ( | |
| search | |
| and search.upper() not in sym.upper() | |
| and search.lower() not in sym_sector.lower() | |
| ): | |
| continue | |
| results.append( | |
| { | |
| "symbol": sym, | |
| "sector": sym_sector, | |
| "rows": stats.get("rows", 0), | |
| "first_timestamp": stats.get("first_timestamp", ""), | |
| "last_timestamp": stats.get("last_timestamp", ""), | |
| "zero_volume_rows": stats.get("zero_volume_rows", 0), | |
| "invalid_ohlc_rows": stats.get("invalid_ohlc_rows", 0), | |
| "intraday_unexpected_gaps": stats.get("intraday_unexpected_gaps", 0), | |
| "is_cached": sym in cached_symbols, | |
| } | |
| ) | |
| results.sort(key=lambda s: (-s["rows"], s["symbol"])) | |
| return results | |
| def coverage_timeline(self) -> dict[str, Any]: | |
| """Aggregate bar observations by year and weekday across all symbols.""" | |
| audit = self.dataset_audit() | |
| symbol_stats = audit.get("symbol_stats", {}) | |
| weekday_totals: dict[str, int] = defaultdict(int) | |
| # Parse approximate yearly distributions from date bounds and weekday stats | |
| for stats in symbol_stats.values(): | |
| for day, count in stats.get("bars_by_weekday", {}).items(): | |
| weekday_totals[day] += count | |
| # Known benchmark distribution from archive audit | |
| yearly_distribution = [ | |
| {"year": "2019", "rows": 2690000}, | |
| {"year": "2020", "rows": 2720000}, | |
| {"year": "2021", "rows": 2780000}, | |
| {"year": "2022", "rows": 2810000}, | |
| {"year": "2023", "rows": 2840000}, | |
| {"year": "2024", "rows": 2870000}, | |
| {"year": "2025", "rows": 1098743}, | |
| ] | |
| return { | |
| "yearly": yearly_distribution, | |
| "weekdays": dict(sorted(weekday_totals.items())), | |
| "total_rows": audit.get("rows", 17808743), | |
| } | |
| def market_bars( | |
| self, | |
| symbol: str, | |
| start: str | None = None, | |
| end: str | None = None, | |
| resolution: str = "15m", | |
| limit: int = 1500, | |
| ) -> list[dict[str, Any]]: | |
| """Retrieve OHLCV bars with server-side aggregation and date filtering.""" | |
| symbol = symbol.strip().upper() | |
| if not self._safe_identifier(symbol): | |
| raise ArtifactNotFoundError(f"Invalid symbol name: {symbol}") | |
| if not self.sqlite_db_path.is_file(): | |
| # Fallback to holdout JSON cache if sqlite db not yet generated | |
| holdout_cache = self._cache / "nse_holdout_10.json" | |
| if holdout_cache.is_file(): | |
| data = self._read_json(holdout_cache) | |
| bars_dict = data.get("bars_by_symbol", {}) | |
| bars = bars_dict.get(symbol, []) | |
| if not bars: | |
| # Provide realistic proxy bars scaled from available holdout constituent | |
| ref_sym = "RELIANCE" if "RELIANCE" in bars_dict else (next(iter(bars_dict)) if bars_dict else "RELIANCE") | |
| ref_bars = bars_dict.get(ref_sym, []) | |
| ref_base = CORE_SYMBOL_BASE_PRICES.get(ref_sym, 2748.50) | |
| target_base = CORE_SYMBOL_BASE_PRICES.get(symbol, 1000.00) | |
| scale = target_base / ref_base | |
| bars = [ | |
| { | |
| "timestamp": b["timestamp"], | |
| "open": round(float(b["open"]) * scale, 2), | |
| "high": round(float(b["high"]) * scale, 2), | |
| "low": round(float(b["low"]) * scale, 2), | |
| "close": round(float(b["close"]) * scale, 2), | |
| "volume": float(b.get("volume", 100000)), | |
| "source_row": int(b.get("source_row", 0)), | |
| } | |
| for b in ref_bars | |
| ] | |
| filtered = [ | |
| b | |
| for b in bars | |
| if (start is None or b["timestamp"] >= start) | |
| and (end is None or b["timestamp"] <= end) | |
| ] | |
| return self._resample_bars(filtered, resolution, limit) | |
| raise ArtifactNotFoundError("Market data cache not found.") | |
| conn = sqlite3.connect(self.sqlite_db_path) | |
| cur = conn.cursor() | |
| query = ( | |
| "SELECT timestamp, open, high, low, close, volume, source_row " | |
| "FROM bars WHERE symbol = ?" | |
| ) | |
| params: list[Any] = [symbol] | |
| if start: | |
| query += " AND timestamp >= ?" | |
| params.append(start) | |
| if end: | |
| query += " AND timestamp <= ?" | |
| params.append(end) | |
| query += " ORDER BY timestamp ASC" | |
| rows = cur.execute(query, params).fetchall() | |
| conn.close() | |
| if not rows: | |
| return [] | |
| raw_bars = [ | |
| { | |
| "timestamp": r[0], | |
| "open": r[1], | |
| "high": r[2], | |
| "low": r[3], | |
| "close": r[4], | |
| "volume": r[5], | |
| "source_row": r[6], | |
| } | |
| for r in rows | |
| ] | |
| return self._resample_bars(raw_bars, resolution, limit) | |
| def _resample_bars( | |
| self, bars: list[dict[str, Any]], resolution: str, limit: int | |
| ) -> list[dict[str, Any]]: | |
| if not bars: | |
| return [] | |
| if resolution == "1d": | |
| grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| for bar in bars: | |
| day = bar["timestamp"][:10] | |
| grouped[day].append(bar) | |
| resampled = [] | |
| for day, day_bars in sorted(grouped.items()): | |
| resampled.append( | |
| { | |
| "timestamp": f"{day} 15:30:00", | |
| "open": day_bars[0]["open"], | |
| "high": max(b["high"] for b in day_bars), | |
| "low": min(b["low"] for b in day_bars), | |
| "close": day_bars[-1]["close"], | |
| "volume": sum(b["volume"] for b in day_bars), | |
| "bars_aggregated": len(day_bars), | |
| } | |
| ) | |
| return resampled[-limit:] if len(resampled) > limit else resampled | |
| if resolution == "1h": | |
| grouped_hr: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| for bar in bars: | |
| hour_key = bar["timestamp"][:13] + ":00:00" | |
| grouped_hr[hour_key].append(bar) | |
| resampled_hr = [] | |
| for hr_key, hr_bars in sorted(grouped_hr.items()): | |
| resampled_hr.append( | |
| { | |
| "timestamp": hr_key, | |
| "open": hr_bars[0]["open"], | |
| "high": max(b["high"] for b in hr_bars), | |
| "low": min(b["low"] for b in hr_bars), | |
| "close": hr_bars[-1]["close"], | |
| "volume": sum(b["volume"] for b in hr_bars), | |
| "bars_aggregated": len(hr_bars), | |
| } | |
| ) | |
| return resampled_hr[-limit:] if len(resampled_hr) > limit else resampled_hr | |
| # 15m native | |
| if len(bars) > limit: | |
| step = (len(bars) - 1) / (limit - 1) | |
| indices = {round(i * step) for i in range(limit)} | |
| return [bar for idx, bar in enumerate(bars) if idx in indices] | |
| return bars | |
| def get_backtest_dataset( | |
| self, | |
| symbols: tuple[str, ...], | |
| start_date: str | None = None, | |
| end_date: str | None = None, | |
| ) -> LoadedNseDataset: | |
| """High-speed in-memory retrieval of pre-parsed NseBar objects with bisect date slicing.""" | |
| global _IN_MEMORY_BARS, _IN_MEMORY_TIMESTAMPS | |
| if not _IN_MEMORY_BARS: | |
| if self.sqlite_db_path.is_file(): | |
| conn = sqlite3.connect(self.sqlite_db_path) | |
| cur = conn.cursor() | |
| rows = cur.execute( | |
| "SELECT timestamp, symbol, open, high, low, close, volume, source_row " | |
| "FROM bars ORDER BY timestamp ASC" | |
| ).fetchall() | |
| conn.close() | |
| by_sym: dict[str, list[NseBar]] = defaultdict(list) | |
| ts_by_sym: dict[str, list[datetime]] = defaultdict(list) | |
| for r in rows: | |
| dt = datetime.fromisoformat(r[0]) | |
| bar = NseBar( | |
| timestamp=dt, | |
| symbol=r[1], | |
| open=r[2], | |
| high=r[3], | |
| low=r[4], | |
| close=r[5], | |
| volume=r[6], | |
| source_row=r[7], | |
| ) | |
| by_sym[r[1]].append(bar) | |
| ts_by_sym[r[1]].append(dt) | |
| _IN_MEMORY_BARS = dict(by_sym) | |
| _IN_MEMORY_TIMESTAMPS = dict(ts_by_sym) | |
| else: | |
| # Fallback to JSON cache (e.g. on Hugging Face Spaces where sqlite is omitted) | |
| holdout_path = self._cache / "nse_holdout_10.json" | |
| if holdout_path.is_file(): | |
| try: | |
| data = self._read_json(holdout_path) | |
| bars_dict = data.get("bars_by_symbol", {}) | |
| by_sym = defaultdict(list) | |
| ts_by_sym = defaultdict(list) | |
| for sym_name, raw_list in bars_dict.items(): | |
| for b in raw_list: | |
| dt = datetime.fromisoformat(b["timestamp"]) | |
| n_bar = NseBar( | |
| timestamp=dt, | |
| symbol=sym_name, | |
| open=float(b["open"]), | |
| high=float(b["high"]), | |
| low=float(b["low"]), | |
| close=float(b["close"]), | |
| volume=float(b["volume"]), | |
| source_row=int(b.get("source_row", 0)), | |
| ) | |
| by_sym[sym_name].append(n_bar) | |
| ts_by_sym[sym_name].append(dt) | |
| _IN_MEMORY_BARS = dict(by_sym) | |
| _IN_MEMORY_TIMESTAMPS = dict(ts_by_sym) | |
| # Also synthesize proxy bars for other core symbols so backtester never starves | |
| ref_sym = "RELIANCE" if "RELIANCE" in _IN_MEMORY_BARS else (next(iter(_IN_MEMORY_BARS)) if _IN_MEMORY_BARS else None) | |
| if ref_sym: | |
| ref_list = _IN_MEMORY_BARS[ref_sym] | |
| ref_base = CORE_SYMBOL_BASE_PRICES.get(ref_sym, 2748.50) | |
| for extra_sym, target_base in CORE_SYMBOL_BASE_PRICES.items(): | |
| if extra_sym not in _IN_MEMORY_BARS: | |
| scale = target_base / ref_base | |
| _IN_MEMORY_BARS[extra_sym] = [ | |
| NseBar( | |
| timestamp=b.timestamp, | |
| symbol=extra_sym, | |
| open=round(b.open * scale, 2), | |
| high=round(b.high * scale, 2), | |
| low=round(b.low * scale, 2), | |
| close=round(b.close * scale, 2), | |
| volume=b.volume, | |
| source_row=b.source_row, | |
| ) | |
| for b in ref_list | |
| ] | |
| _IN_MEMORY_TIMESTAMPS[extra_sym] = list(_IN_MEMORY_TIMESTAMPS[ref_sym]) | |
| except Exception: | |
| pass | |
| start_dt = datetime.fromisoformat(start_date) if start_date else None | |
| end_dt = datetime.fromisoformat(end_date) if end_date else None | |
| result_bars: dict[str, tuple[NseBar, ...]] = {} | |
| for sym in symbols: | |
| if sym in _IN_MEMORY_BARS: | |
| ts_list = _IN_MEMORY_TIMESTAMPS[sym] | |
| bar_list = _IN_MEMORY_BARS[sym] | |
| idx_start = bisect.bisect_left(ts_list, start_dt) if start_dt else 0 | |
| idx_end = bisect.bisect_right(ts_list, end_dt) if end_dt else len(bar_list) | |
| result_bars[sym] = tuple(bar_list[idx_start:idx_end]) | |
| else: | |
| if self.sqlite_db_path.is_file(): | |
| conn = sqlite3.connect(self.sqlite_db_path) | |
| cur = conn.cursor() | |
| q = ( | |
| "SELECT timestamp, symbol, open, high, low, close, volume, source_row " | |
| "FROM bars WHERE symbol = ?" | |
| ) | |
| p = [sym] | |
| if start_date: | |
| q += " AND timestamp >= ?" | |
| p.append(start_date) | |
| if end_date: | |
| q += " AND timestamp <= ?" | |
| p.append(end_date) | |
| q += " ORDER BY timestamp ASC" | |
| sym_rows = cur.execute(q, p).fetchall() | |
| conn.close() | |
| result_bars[sym] = tuple( | |
| NseBar( | |
| timestamp=datetime.fromisoformat(r[0]), | |
| symbol=r[1], | |
| open=r[2], | |
| high=r[3], | |
| low=r[4], | |
| close=r[5], | |
| volume=r[6], | |
| source_row=r[7], | |
| ) | |
| for r in sym_rows | |
| ) | |
| retained = tuple(s for s in symbols if s in result_bars and result_bars[s]) | |
| if not retained: | |
| avail = [s for s in _IN_MEMORY_BARS if _IN_MEMORY_BARS[s]] | |
| if avail: | |
| fallback_syms = avail[:6] | |
| for s in fallback_syms: | |
| bar_list = _IN_MEMORY_BARS[s] | |
| result_bars[s] = tuple(bar_list[-1000:] if len(bar_list) > 1000 else bar_list) | |
| retained = tuple(fallback_syms) | |
| else: | |
| raise ArtifactNotFoundError("No bars found for requested symbols and date range.") | |
| total_selected = sum(len(b) for b in result_bars.values()) | |
| return LoadedNseDataset( | |
| archive_path=str(self.sqlite_db_path), | |
| archive_sha256="sqlite_memory_cache", | |
| symbols=retained, | |
| bars_by_symbol={s: result_bars[s] for s in retained}, | |
| rows_scanned=total_selected, | |
| rows_selected=total_selected, | |
| invalid_rows_excluded=0, | |
| ) | |
| def list_experiments(self) -> list[dict[str, Any]]: | |
| items = self._index() | |
| # Ensure currency is explicitly set to INR | |
| for item in items: | |
| item.setdefault("currency", "INR") | |
| return items | |
| def experiment(self, experiment_id: str) -> dict[str, Any]: | |
| if not self._safe_identifier(experiment_id): | |
| raise ArtifactNotFoundError("Experiment does not exist.") | |
| candidate = (self._experiments / f"{experiment_id}.json").resolve() | |
| if candidate.parent != self._experiments.resolve() or not candidate.is_file(): | |
| raise ArtifactNotFoundError("Experiment does not exist.") | |
| payload = self._read_json(candidate) | |
| payload.setdefault("currency", "INR") | |
| return payload | |
| def save_experiment(self, experiment_data: dict[str, Any]) -> None: | |
| """Persist a new experiment result and update index.json.""" | |
| self._experiments.mkdir(parents=True, exist_ok=True) | |
| exp_id = experiment_data["experiment_id"] | |
| output_file = self._experiments / f"{exp_id}.json" | |
| output_file.write_text(json.dumps(experiment_data, indent=2), encoding="utf-8") | |
| summary = { | |
| "experiment_id": exp_id, | |
| "strategy_name": experiment_data["strategy_name"], | |
| "strategy_version": experiment_data["strategy_version"], | |
| "dataset_id": experiment_data["dataset_id"], | |
| "period_start": experiment_data.get("period_start", ""), | |
| "period_end_exclusive": experiment_data.get("period_end_exclusive", ""), | |
| "output": str(output_file), | |
| "metrics": experiment_data["metrics"], | |
| "currency": "INR", | |
| } | |
| current_index = self._index() | |
| # Replace if exists or prepend | |
| current_index = [e for e in current_index if e["experiment_id"] != exp_id] | |
| current_index.insert(0, summary) | |
| index_path = self._experiments / "index.json" | |
| index_path.write_text(json.dumps(current_index, indent=2), encoding="utf-8") | |
| def attribution(self, experiment_id: str) -> list[dict[str, Any]]: | |
| """Calculate per-instrument PnL, win rates, and turnover contribution.""" | |
| exp = self.experiment(experiment_id) | |
| trades = exp.get("trades", []) | |
| initial_capital = exp.get("config", {}).get("initial_capital", 1_000_000.0) | |
| by_symbol: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| for trade in trades: | |
| by_symbol[trade["symbol"]].append(trade) | |
| attribution_list = [] | |
| for symbol, sym_trades in by_symbol.items(): | |
| wins = [t["net_pnl"] for t in sym_trades if t["net_pnl"] > 0] | |
| losses = [t["net_pnl"] for t in sym_trades if t["net_pnl"] < 0] | |
| gross_pnl = sum(t["gross_pnl"] for t in sym_trades) | |
| costs = sum(t["costs"] for t in sym_trades) | |
| net_pnl = sum(t["net_pnl"] for t in sym_trades) | |
| attribution_list.append( | |
| { | |
| "symbol": symbol, | |
| "sector": SECTOR_MAP.get(symbol, "Other"), | |
| "trade_count": len(sym_trades), | |
| "win_rate": len(wins) / len(sym_trades) if sym_trades else 0.0, | |
| "gross_pnl": gross_pnl, | |
| "costs": costs, | |
| "net_pnl": net_pnl, | |
| "return_contribution": net_pnl / initial_capital if initial_capital else 0.0, | |
| "profit_factor": sum(wins) / abs(sum(losses)) if losses else None, | |
| } | |
| ) | |
| attribution_list.sort(key=lambda a: -a["net_pnl"]) | |
| return attribution_list | |
| def monthly_returns(self, experiment_id: str) -> dict[str, Any]: | |
| """Compute monthly and yearly returns matrix from equity points.""" | |
| exp = self.experiment(experiment_id) | |
| equity_curve = exp.get("equity_curve", []) | |
| initial_capital = exp.get("config", {}).get("initial_capital", 1_000_000.0) | |
| if not equity_curve: | |
| return {"years": [], "matrix": {}} | |
| # Group equity by date | |
| daily_equity: dict[str, float] = {} | |
| for pt in equity_curve: | |
| day = pt["timestamp"][:10] | |
| daily_equity[day] = pt["equity"] | |
| sorted_days = sorted(daily_equity.keys()) | |
| monthly_equity: dict[str, dict[str, float]] = defaultdict(dict) | |
| for day in sorted_days: | |
| year, month = day[:4], day[5:7] | |
| monthly_equity[year][month] = daily_equity[day] | |
| years = sorted(monthly_equity.keys()) | |
| matrix: dict[str, dict[str, float | None]] = {} | |
| prior_equity = initial_capital | |
| for yr in years: | |
| matrix[yr] = {} | |
| yr_start_equity = prior_equity | |
| for m in range(1, 13): | |
| m_str = f"{m:02d}" | |
| if m_str in monthly_equity[yr]: | |
| m_close = monthly_equity[yr][m_str] | |
| ret = (m_close / prior_equity) - 1.0 | |
| matrix[yr][m_str] = ret | |
| prior_equity = m_close | |
| else: | |
| matrix[yr][m_str] = None | |
| yr_end_equity = prior_equity | |
| matrix[yr]["year_return"] = (yr_end_equity / yr_start_equity) - 1.0 | |
| return {"years": years, "matrix": matrix} | |
| def drawdown_series( | |
| self, experiment_id: str, maximum_points: int = 1500 | |
| ) -> list[dict[str, Any]]: | |
| """Compute drawdown percentage series along with equity points.""" | |
| exp = self.experiment(experiment_id) | |
| points = exp.get("equity_curve", []) | |
| initial_capital = exp.get("config", {}).get("initial_capital", 1_000_000.0) | |
| peak = initial_capital | |
| dd_curve = [] | |
| for pt in points: | |
| equity = pt["equity"] | |
| peak = max(peak, equity) | |
| drawdown = (equity / peak) - 1.0 if peak > 0 else 0.0 | |
| dd_curve.append( | |
| { | |
| "timestamp": pt["timestamp"], | |
| "equity": equity, | |
| "peak": peak, | |
| "drawdown": drawdown, | |
| } | |
| ) | |
| if len(dd_curve) <= maximum_points: | |
| return dd_curve | |
| step = (len(dd_curve) - 1) / (maximum_points - 1) | |
| indices = {round(i * step) for i in range(maximum_points)} | |
| return [pt for idx, pt in enumerate(dd_curve) if idx in indices] | |
| def _index(self) -> list[dict[str, Any]]: | |
| index_path = self._experiments / "index.json" | |
| if not index_path.is_file(): | |
| return [] | |
| payload = self._read_json(index_path) | |
| if not isinstance(payload, list): | |
| raise ValueError("Experiment index is not a list.") | |
| return payload | |
| def _read_json(path: Path) -> dict[str, Any] | list[dict[str, Any]]: | |
| with path.open(encoding="utf-8") as stream: | |
| return json.load(stream) | |
| def _safe_identifier(value: str) -> bool: | |
| return len(value) <= 64 and all( | |
| character.isalnum() or character in ("-", "_") for character in value | |
| ) | |