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
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
1"""Configuration management for cc-liquid."""
3import os
4from dataclasses import dataclass, field, is_dataclass
5from typing import Any
7import yaml
8from dotenv import load_dotenv
10DEFAULT_CONFIG_PATH = "cc-liquid-config.yaml"
13@dataclass
14class DataSourceConfig:
15 """Data source configuration."""
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"
24@dataclass
25class RebalancingConfig:
26 """Rebalancing schedule configuration."""
28 every_n_days: int = 10 # How often to rebalance (in days)
29 at_time: str = "18:15" # What time to rebalance (UTC)
32@dataclass
33class PortfolioConfig:
34 """Portfolio construction parameters."""
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)
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
53@dataclass
54class StopLossConfig:
55 """Stop loss configuration for risk management."""
57 enabled: bool = False # Master switch
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
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
68 # Re-entry protection
69 cooldown_minutes: int = 60 # Don't re-enter same asset for 1hr after stop
72@dataclass
73class ExecutionConfig:
74 """Order execution parameters."""
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
80 # TWAP settings (only used when strategy="twap_native")
81 twap: TWAPConfig = field(default_factory=TWAPConfig)
83 # Stop loss settings
84 stop_loss: StopLossConfig = field(default_factory=StopLossConfig)
87@dataclass
88class Config:
89 """
90 Manages configuration for the trading bot, loading from a YAML file
91 and environment variables.
92 """
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 )
100 # Environment
101 is_testnet: bool = False
102 base_url: str = "https://api.hyperliquid.xyz"
104 # Profiles (addresses in config; secrets remain in env)
105 active_profile: str | None = "default"
106 profiles: dict[str, Any] = field(default_factory=dict)
108 # Resolved addresses from active profile (owner/vault)
109 HYPERLIQUID_ADDRESS: str | None = None
110 HYPERLIQUID_VAULT_ADDRESS: str | None = None
112 # Nested Configs
113 data: DataSourceConfig = field(default_factory=DataSourceConfig)
114 portfolio: PortfolioConfig = field(default_factory=PortfolioConfig)
115 execution: ExecutionConfig = field(default_factory=ExecutionConfig)
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()
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
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 {}
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)
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"
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
174 active = self.active_profile or "default"
175 profile = self.profiles.get(active, {})
177 # Extract owner and vault from profile
178 owner = profile.get("owner")
179 vault = profile.get("vault")
181 # Set addresses (owner is required, vault is optional)
182 self.HYPERLIQUID_ADDRESS = owner
183 self.HYPERLIQUID_VAULT_ADDRESS = vault
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)
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")
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()
199 def _validate(self):
200 """Validate that required configuration is present.
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 )
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
216 def validate_for_trading(self):
217 """Strict validation for trading operations.
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 )
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 )
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__
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 }
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 }
269def parse_cli_overrides(set_overrides):
270 """Parse --set key=value pairs into a nested dictionary.
272 Args:
273 set_overrides: List of strings like ["data.source=numerai", "portfolio.num_long=10"]
275 Returns:
276 Nested dictionary suitable for config override
277 """
278 overrides = {}
280 for override in set_overrides:
281 if "=" not in override:
282 raise ValueError(f"Invalid --set format: {override}. Use key=value format.")
284 key, value = override.split("=", 1)
285 key_parts = key.split(".")
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]
294 # Set the final value, attempting type conversion
295 final_key = key_parts[-1]
296 current[final_key] = _convert_value(value)
298 return overrides
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
309 # Try float
310 try:
311 return float(value_str)
312 except ValueError:
313 pass
315 # Try boolean
316 if value_str.lower() in ("true", "false"):
317 return value_str.lower() == "true"
319 # Return as string
320 return value_str
323def apply_cli_overrides(config_obj, set_overrides):
324 """Apply --set overrides to config using the same logic as YAML loading.
326 Returns:
327 List of override keys that were actually applied.
328 """
329 if not set_overrides:
330 return []
332 try:
333 # Parse the overrides into nested dict
334 overrides = parse_cli_overrides(set_overrides)
335 applied = []
337 # Apply smart defaults for data source changes
338 _apply_data_source_defaults(overrides, applied)
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}")
367 # Refresh runtime configuration after overrides
368 config_obj.refresh_runtime()
370 return applied
372 except Exception as e:
373 raise ValueError(f"Error applying overrides: {e}")
376def _apply_data_source_defaults(overrides, applied):
377 """Apply smart defaults when data source is changed.
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
387 source = data_section["source"]
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 }
403 if source in source_defaults:
404 defaults = source_defaults[source]
406 # Ensure data section exists
407 if "data" not in overrides:
408 overrides["data"] = {}
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 )
419config = Config()