diegobeyl/backtesting
2
1"""
2Configuration Manager with Hot-Reload Support
3"""
4
5import json
6import os
7import sys
8from pathlib import Path
9from typing import Optional, Callable, List
10import threading
11import logging
12from watchdog.observers import Observer
13from watchdog.events import FileSystemEventHandler, FileModifiedEvent
14
15# Add parent directory for imports
16sys.path.insert(0, str(Path(__file__).parent.parent))
17
18from api.schemas import BacktestConfig
19
20logger = logging.getLogger(__name__)
21
22
23class ConfigFileHandler(FileSystemEventHandler):
24 """Handles file system events for config file changes"""
25
26 def __init__(self, config_manager: 'ConfigManager'):
27 self.config_manager = config_manager
28 self._last_modified = 0
29
30 def on_modified(self, event):
31 if isinstance(event, FileModifiedEvent):
32 if event.src_path.endswith('.json'):
33 # Debounce rapid changes
34 import time
35 current_time = time.time()
36 if current_time - self._last_modified > 1: # 1 second debounce
37 self._last_modified = current_time
38 logger.info(f"Config file changed: {event.src_path}")
39 self.config_manager.reload_config()
40
41
42class ConfigManager:
43 """
44 Manages configuration with hot-reload capability
45
46 Features:
47 - Load/save configuration from JSON
48 - Watch for file changes and auto-reload
49 - Notify listeners on config changes
50 - Thread-safe operations
51 """
52
53 def __init__(self, config_path: str = None):
54 # Determine config path
55 if config_path is None:
56 base_dir = Path(__file__).parent.parent
57 config_path = base_dir / "config" / "default_config.json"
58
59 self.config_path = Path(config_path)
60 self._config: Optional[BacktestConfig] = None
61 self._lock = threading.RLock()
62 self._listeners: List[Callable[[BacktestConfig], None]] = []
63 self._observer: Optional[Observer] = None
64
65 # Ensure config directory exists
66 self.config_path.parent.mkdir(parents=True, exist_ok=True)
67
68 # Load initial config
69 self.reload_config()
70
71 def reload_config(self):
72 """Reload configuration from file"""
73 with self._lock:
74 try:
75 if self.config_path.exists():
76 with open(self.config_path, 'r', encoding='utf-8') as f:
77 data = json.load(f)
78 self._config = BacktestConfig(**data)
79 logger.info(f"Config loaded from {self.config_path}")
80 else:
81 # Create default config
82 self._config = BacktestConfig()
83 self.save_config()
84 logger.info(f"Default config created at {self.config_path}")
85
86 # Notify listeners
87 self._notify_listeners()
88
89 except Exception as e:
90 logger.error(f"Error loading config: {e}")
91 if self._config is None:
92 self._config = BacktestConfig()
93
94 def save_config(self):
95 """Save current configuration to file"""
96 with self._lock:
97 try:
98 data = self._config.model_dump()
99 with open(self.config_path, 'w', encoding='utf-8') as f:
100 json.dump(data, f, indent=4)
101 logger.info(f"Config saved to {self.config_path}")
102 except Exception as e:
103 logger.error(f"Error saving config: {e}")
104
105 def get_config(self) -> BacktestConfig:
106 """Get current configuration"""
107 with self._lock:
108 if self._config is None:
109 self.reload_config()
110 return self._config
111
112 def update_config(self, new_config: BacktestConfig):
113 """Update configuration"""
114 with self._lock:
115 self._config = new_config
116 self.save_config()
117 self._notify_listeners()
118
119 def update_partial(self, updates: dict):
120 """Update specific config fields"""
121 with self._lock:
122 current = self._config.model_dump()
123 current.update(updates)
124 self._config = BacktestConfig(**current)
125 self.save_config()
126 self._notify_listeners()
127
128 def add_listener(self, callback: Callable[[BacktestConfig], None]):
129 """Add a listener for config changes"""
130 with self._lock:
131 self._listeners.append(callback)
132
133 def remove_listener(self, callback: Callable[[BacktestConfig], None]):
134 """Remove a config change listener"""
135 with self._lock:
136 if callback in self._listeners:
137 self._listeners.remove(callback)
138
139 def _notify_listeners(self):
140 """Notify all listeners of config change"""
141 for listener in self._listeners:
142 try:
143 listener(self._config)
144 except Exception as e:
145 logger.error(f"Error in config listener: {e}")
146
147 def start_watching(self):
148 """Start watching config file for changes"""
149 if self._observer is not None:
150 return
151
152 try:
153 self._observer = Observer()
154 handler = ConfigFileHandler(self)
155 self._observer.schedule(handler, str(self.config_path.parent), recursive=False)
156 self._observer.start()
157 logger.info(f"Started watching config at {self.config_path.parent}")
158 except Exception as e:
159 logger.error(f"Error starting config watcher: {e}")
160
161 def stop_watching(self):
162 """Stop watching config file"""
163 if self._observer is not None:
164 self._observer.stop()
165 self._observer.join()
166 self._observer = None
167 logger.info("Stopped watching config")
168
169 def reset_to_defaults(self):
170 """Reset configuration to defaults"""
171 with self._lock:
172 self._config = BacktestConfig()
173 self.save_config()
174 self._notify_listeners()
175
176
177# Singleton instance
178_manager: Optional[ConfigManager] = None
179
180
181def get_config_manager() -> ConfigManager:
182 """Get singleton config manager instance"""
183 global _manager
184 if _manager is None:
185 _manager = ConfigManager()
186 return _manager
187 