Coverage for src/cc_liquid/config.py: 54%

200 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-10-13 20:16 +0000

1"""Configuration management for cc-liquid.""" 

2 

3import os 

4from dataclasses import dataclass, field, is_dataclass 

5from typing import Any 

6 

7import yaml 

8from dotenv import load_dotenv 

9 

10DEFAULT_CONFIG_PATH = "cc-liquid-config.yaml" 

11 

12 

13@dataclass 

14class DataSourceConfig: 

15 """Data source configuration.""" 

16 

17 source: str = "crowdcent" # "crowdcent" or "local" 

18 path: str = "predictions.parquet" 

19 date_column: str = "release_date" 

20 asset_id_column: str = "id" 

21 prediction_column: str = "pred_10d" 

22 

23 

24@dataclass 

25class RebalancingConfig: 

26 """Rebalancing schedule configuration.""" 

27 

28 every_n_days: int = 10 # How often to rebalance (in days) 

29 at_time: str = "18:15" # What time to rebalance (UTC) 

30 

31 

32@dataclass 

33class PortfolioConfig: 

34 """Portfolio construction parameters.""" 

35 

36 num_long: int = 10 

37 num_short: int = 10 

38 target_leverage: float = 1.0 # Position sizing multiplier (1.0 = no leverage) 

39 weighting_scheme: str = "equal" # "equal" | "rank_power" 

40 rank_power: float = 1.5 

41 rebalancing: RebalancingConfig = field(default_factory=RebalancingConfig) 

42 

43 

44@dataclass 

45class TWAPConfig: 

46 """Native TWAP execution parameters.""" 

47 duration_minutes: int = 240 # 4 hours default 

48 randomize: bool = False # Evenly spaced vs randomized intervals 

49 check_interval_seconds: int = 60 # Polling frequency for status updates 

50 min_child_notional: float = 10.0 # Ensure each TWAP slice >= exchange min 

51 

52 

53@dataclass 

54class StopLossConfig: 

55 """Stop loss configuration for risk management.""" 

56 

57 enabled: bool = False # Master switch 

58 

59 # Short position stops (longs optional in future) 

60 short_stop_pct: float = 0.15 # 15% loss = trigger stop 

61 use_trailing_stop: bool = False 

62 trailing_distance_pct: float = 0.10 # 10% trailing distance 

63 

64 # Stop order settings 

65 stop_order_type: str = "market" # "market" | "limit" 

66 stop_limit_offset_pct: float = 0.01 # For limit stops, offset from trigger 

67 

68 # Re-entry protection 

69 cooldown_minutes: int = 60 # Don't re-enter same asset for 1hr after stop 

70 

71 

72@dataclass 

73class ExecutionConfig: 

74 """Order execution parameters.""" 

75 

76 strategy: str = "market" # "market" | "twap_native" 

77 slippage_tolerance: float = 0.005 

78 min_trade_value: float = 10.0 # Exchange minimum order notional in USD 

79 

80 # TWAP settings (only used when strategy="twap_native") 

81 twap: TWAPConfig = field(default_factory=TWAPConfig) 

82 

83 # Stop loss settings 

84 stop_loss: StopLossConfig = field(default_factory=StopLossConfig) 

85 

86 

87@dataclass 

88class Config: 

89 """ 

90 Manages configuration for the trading bot, loading from a YAML file 

91 and environment variables. 

92 """ 

93 

94 # Private Keys and API Credentials (from .env) 

95 CROWDCENT_API_KEY: str | None = None 

96 HYPERLIQUID_PRIVATE_KEY: str | None = ( 

97 None # Private key for signing (owner or approved agent wallet) 

98 ) 

99 

100 # Environment 

101 is_testnet: bool = False 

102 base_url: str = "https://api.hyperliquid.xyz" 

103 

104 # Profiles (addresses in config; secrets remain in env) 

105 active_profile: str | None = "default" 

106 profiles: dict[str, Any] = field(default_factory=dict) 

107 

108 # Resolved addresses from active profile (owner/vault) 

109 HYPERLIQUID_ADDRESS: str | None = None 

110 HYPERLIQUID_VAULT_ADDRESS: str | None = None 

111 

112 # Nested Configs 

113 data: DataSourceConfig = field(default_factory=DataSourceConfig) 

114 portfolio: PortfolioConfig = field(default_factory=PortfolioConfig) 

115 execution: ExecutionConfig = field(default_factory=ExecutionConfig) 

116 

117 def __post_init__(self): 

118 """Load environment variables and YAML config after initialization.""" 

119 self._load_env_vars() 

120 self._load_yaml_config() 

121 self._resolve_profile() # Must come AFTER loading YAML (which loads profiles) 

122 self._set_base_url() 

123 self._validate() 

124 

125 def _load_env_vars(self): 

126 """Load secrets-only from .env; addresses come from YAML/profiles.""" 

127 load_dotenv() 

128 self.CROWDCENT_API_KEY = os.getenv("CROWDCENT_API_KEY") 

129 # Don't load private key here - will be resolved based on profile's signer_env 

130 

131 def _load_yaml_config(self, config_path: str | None = None): 

132 """Loads and overrides config from a YAML file.""" 

133 path = config_path or DEFAULT_CONFIG_PATH 

134 if os.path.exists(path): 

135 with open(path) as f: 

136 yaml_config: dict[str, Any] = yaml.safe_load(f) or {} 

137 

138 for key, value in yaml_config.items(): 

139 if hasattr(self, key): 

140 # Handle nested dataclasses 

141 if isinstance(value, dict) and is_dataclass(getattr(self, key)): 

142 nested_config_obj = getattr(self, key) 

143 for nested_key, nested_value in value.items(): 

144 if hasattr(nested_config_obj, nested_key): 

145 # Handle double nested dataclasses 

146 if isinstance(nested_value, dict) and is_dataclass( 

147 getattr(nested_config_obj, nested_key) 

148 ): 

149 nested_dataclass = getattr( 

150 nested_config_obj, nested_key 

151 ) 

152 for deep_key, deep_value in nested_value.items(): 

153 if hasattr(nested_dataclass, deep_key): 

154 setattr( 

155 nested_dataclass, deep_key, deep_value 

156 ) 

157 else: 

158 setattr(nested_config_obj, nested_key, nested_value) 

159 else: 

160 # Direct assignment for non-dataclass fields (like profiles dict) 

161 setattr(self, key, value) 

162 

163 def _set_base_url(self): 

164 """Sets the base URL based on the is_testnet flag.""" 

165 if self.is_testnet: 

166 self.base_url = "https://api.hyperliquid-testnet.xyz" 

167 

168 def _resolve_profile(self): 

169 """Resolve owner/vault addresses and signer key from the active profile.""" 

170 # If no profiles defined, skip resolution 

171 if not self.profiles: 

172 return 

173 

174 active = self.active_profile or "default" 

175 profile = self.profiles.get(active, {}) 

176 

177 # Extract owner and vault from profile 

178 owner = profile.get("owner") 

179 vault = profile.get("vault") 

180 

181 # Set addresses (owner is required, vault is optional) 

182 self.HYPERLIQUID_ADDRESS = owner 

183 self.HYPERLIQUID_VAULT_ADDRESS = vault 

184 

185 # Resolve signer key from environment based on profile's signer_env 

186 signer_env = profile.get("signer_env", "HYPERLIQUID_PRIVATE_KEY") 

187 self.HYPERLIQUID_PRIVATE_KEY = os.getenv(signer_env) 

188 

189 # Fallback to default env var if custom signer_env not found 

190 if not self.HYPERLIQUID_PRIVATE_KEY and signer_env != "HYPERLIQUID_PRIVATE_KEY": 

191 self.HYPERLIQUID_PRIVATE_KEY = os.getenv("HYPERLIQUID_PRIVATE_KEY") 

192 

193 def refresh_runtime(self): 

194 """Refresh runtime configuration after changes (e.g., CLI overrides).""" 

195 self._set_base_url() 

196 self._resolve_profile() 

197 self._validate() 

198 

199 def _validate(self): 

200 """Validate that required configuration is present. 

201 

202 Note: This is lenient during initial module load to allow CLI commands 

203 like 'profile list' to work even without complete setup. 

204 """ 

205 # Check if active profile exists 

206 if self.profiles and self.active_profile: 

207 if self.active_profile not in self.profiles: 

208 available = ", ".join(sorted(self.profiles.keys())) 

209 raise ValueError( 

210 f"Active profile '{self.active_profile}' not found. Available profiles: {available}" 

211 ) 

212 

213 # Don't validate addresses/keys during module import - let individual commands handle it 

214 # This allows 'profile list', 'config', etc to work without full setup 

215 

216 def validate_for_trading(self): 

217 """Strict validation for trading operations. 

218 

219 Call this before any trading operations to ensure all required config is present. 

220 """ 

221 # Validate we have required addresses from profile 

222 if not self.HYPERLIQUID_ADDRESS and not self.HYPERLIQUID_VAULT_ADDRESS: 

223 raise ValueError( 

224 "Profile must specify 'owner' or 'vault' address in cc-liquid-config.yaml" 

225 ) 

226 

227 # Validate we have a private key 

228 if not self.HYPERLIQUID_PRIVATE_KEY: 

229 # Better error message showing which env var is expected 

230 signer_env = "HYPERLIQUID_PRIVATE_KEY" 

231 if self.profiles and self.active_profile: 

232 profile = self.profiles.get(self.active_profile, {}) 

233 signer_env = profile.get("signer_env", "HYPERLIQUID_PRIVATE_KEY") 

234 raise ValueError( 

235 f"Private key not found. Set '{signer_env}' in your .env file." 

236 ) 

237 

238 def to_dict(self) -> dict[str, Any]: 

239 """Return a dictionary representation of the config.""" 

240 portfolio_dict = self.portfolio.__dict__.copy() 

241 # Convert nested dataclass to dict 

242 if hasattr(self.portfolio, "rebalancing"): 

243 portfolio_dict["rebalancing"] = self.portfolio.rebalancing.__dict__ 

244 

245 # Profile summary (non-secret) 

246 active_profile = self.active_profile 

247 prof = self.profiles.get(active_profile) if self.profiles else {} 

248 signer_env_name = ( 

249 prof.get("signer_env", "HYPERLIQUID_PRIVATE_KEY") 

250 if prof 

251 else "HYPERLIQUID_PRIVATE_KEY" 

252 ) 

253 profile_dict = { 

254 "active": active_profile, 

255 "owner": self.HYPERLIQUID_ADDRESS, 

256 "vault": self.HYPERLIQUID_VAULT_ADDRESS, 

257 "signer_env": signer_env_name, 

258 } 

259 

260 return { 

261 "is_testnet": self.is_testnet, 

262 "profile": profile_dict, 

263 "data": self.data.__dict__, 

264 "portfolio": portfolio_dict, 

265 "execution": self.execution.__dict__, 

266 } 

267 

268 

269def parse_cli_overrides(set_overrides): 

270 """Parse --set key=value pairs into a nested dictionary. 

271 

272 Args: 

273 set_overrides: List of strings like ["data.source=numerai", "portfolio.num_long=10"] 

274 

275 Returns: 

276 Nested dictionary suitable for config override 

277 """ 

278 overrides = {} 

279 

280 for override in set_overrides: 

281 if "=" not in override: 

282 raise ValueError(f"Invalid --set format: {override}. Use key=value format.") 

283 

284 key, value = override.split("=", 1) 

285 key_parts = key.split(".") 

286 

287 # Navigate/create nested dictionary structure 

288 current = overrides 

289 for part in key_parts[:-1]: 

290 if part not in current: 

291 current[part] = {} 

292 current = current[part] 

293 

294 # Set the final value, attempting type conversion 

295 final_key = key_parts[-1] 

296 current[final_key] = _convert_value(value) 

297 

298 return overrides 

299 

300 

301def _convert_value(value_str): 

302 """Convert string value to appropriate Python type.""" 

303 # Try int 

304 try: 

305 return int(value_str) 

306 except ValueError: 

307 pass 

308 

309 # Try float 

310 try: 

311 return float(value_str) 

312 except ValueError: 

313 pass 

314 

315 # Try boolean 

316 if value_str.lower() in ("true", "false"): 

317 return value_str.lower() == "true" 

318 

319 # Return as string 

320 return value_str 

321 

322 

323def apply_cli_overrides(config_obj, set_overrides): 

324 """Apply --set overrides to config using the same logic as YAML loading. 

325 

326 Returns: 

327 List of override keys that were actually applied. 

328 """ 

329 if not set_overrides: 

330 return [] 

331 

332 try: 

333 # Parse the overrides into nested dict 

334 overrides = parse_cli_overrides(set_overrides) 

335 applied = [] 

336 

337 # Apply smart defaults for data source changes 

338 _apply_data_source_defaults(overrides, applied) 

339 

340 # Reuse the same logic as _load_yaml_config for consistency 

341 for key, value in overrides.items(): 

342 if hasattr(config_obj, key) and isinstance(value, dict): 

343 nested_config_obj = getattr(config_obj, key) 

344 if is_dataclass(nested_config_obj): 

345 for nested_key, nested_value in value.items(): 

346 if hasattr(nested_config_obj, nested_key): 

347 # Handle double nested dataclasses (if any exist in future) 

348 if isinstance(nested_value, dict) and is_dataclass( 

349 getattr(nested_config_obj, nested_key) 

350 ): 

351 nested_dataclass = getattr( 

352 nested_config_obj, nested_key 

353 ) 

354 for deep_key, deep_value in nested_value.items(): 

355 if hasattr(nested_dataclass, deep_key): 

356 setattr(nested_dataclass, deep_key, deep_value) 

357 applied.append( 

358 f"{key}.{nested_key}.{deep_key}={deep_value}" 

359 ) 

360 else: 

361 setattr(nested_config_obj, nested_key, nested_value) 

362 applied.append(f"{key}.{nested_key}={nested_value}") 

363 elif hasattr(config_obj, key): 

364 setattr(config_obj, key, value) 

365 applied.append(f"{key}={value}") 

366 

367 # Refresh runtime configuration after overrides 

368 config_obj.refresh_runtime() 

369 

370 return applied 

371 

372 except Exception as e: 

373 raise ValueError(f"Error applying overrides: {e}") 

374 

375 

376def _apply_data_source_defaults(overrides, applied): 

377 """Apply smart defaults when data source is changed. 

378 

379 When switching to numerai, automatically apply numerai column defaults 

380 unless explicitly overridden. 

381 """ 

382 # Check if data.source is being changed 

383 data_section = overrides.get("data", {}) 

384 if "source" not in data_section: 

385 return 

386 

387 source = data_section["source"] 

388 

389 # Define defaults for each data source 

390 source_defaults = { 

391 "numerai": { 

392 "date_column": "date", 

393 "asset_id_column": "symbol", 

394 "prediction_column": "meta_model", 

395 }, 

396 "crowdcent": { 

397 "date_column": "release_date", 

398 "asset_id_column": "id", 

399 }, 

400 # local doesn't need defaults - user provides their own columns 

401 } 

402 

403 if source in source_defaults: 

404 defaults = source_defaults[source] 

405 

406 # Ensure data section exists 

407 if "data" not in overrides: 

408 overrides["data"] = {} 

409 

410 # Apply defaults only if not explicitly set 

411 for key, default_value in defaults.items(): 

412 if key not in overrides["data"]: 

413 overrides["data"][key] = default_value 

414 applied.append( 

415 f"data.{key}={default_value} (auto-applied for {source})" 

416 ) 

417 

418 

419config = Config()