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

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

2

3 import os

4 from dataclasses import dataclass, field, is_dataclass

5 from typing import Any

6

7 import yaml

8 from dotenv import load_dotenv

9

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

11

12

13@dataclass

14 class 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

25 class 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

33 class 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

45 class 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

54 class 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

73 class 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

88 class 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

269 def 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

301 def _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

323 def 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

376 def _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

419 config = Config()