-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfig_loader.py
More file actions
333 lines (273 loc) · 11.8 KB
/
Copy pathconfig_loader.py
File metadata and controls
333 lines (273 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
"""
Configuration loader for the GIPS-Compliant Returns Calculator.
Loads and validates configuration from config.yaml file.
"""
import os
import yaml
import logging
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from datetime import datetime
@dataclass
class BrokerageConfig:
"""Configuration for a single brokerage."""
name: str
module: str
enabled: bool = True
@dataclass
class FeeConfig:
"""Fee structure configuration."""
management_fee_quarterly: Optional[float] = 0.0025 # 0.25% per quarter (1% annual)
performance_fee_rate: float = 0.25 # 25% of gains above hurdle
hurdle_rate_annual: float = 0.06 # 6% annual hurdle
# Annual fee is derived from quarterly for reporting (fees are crystallized quarterly).
management_fee_annual: Optional[float] = None
def __post_init__(self):
if self.management_fee_quarterly is None and self.management_fee_annual is None:
self.management_fee_quarterly = 0.0025
if self.management_fee_quarterly is None and self.management_fee_annual is not None:
self.management_fee_quarterly = self.management_fee_annual / 4
if self.management_fee_quarterly is not None:
derived_annual = self.management_fee_quarterly * 4
if self.management_fee_annual is None or abs(self.management_fee_annual - derived_annual) > 1e-9:
self.management_fee_annual = derived_annual
@property
def management_fee_monthly(self) -> float:
"""Convert annual fee to monthly equivalent: (1 + r)^(1/12) - 1"""
return (1 + self.management_fee_annual) ** (1/12) - 1
@property
def hurdle_rate_quarterly(self) -> float:
"""Quarterly hurdle rate (annual / 4)."""
return self.hurdle_rate_annual / 4
@dataclass
class ThresholdsConfig:
"""Threshold configuration."""
large_cash_flow_pct: float = 0.10 # 10% of portfolio
@dataclass
class PeriodsConfig:
"""Reporting periods configuration."""
# Default fiscal year starts in January to match calendar-year reporting.
fiscal_year_start_month: int = 1 # January
reporting_years: List[int] = field(default_factory=lambda: [2022, 2023, 2024, 2025])
def get_period_windows(self) -> Dict[str, Tuple[str, str]]:
"""
Generate period windows based on fiscal year config.
Returns dict like:
{
'2022': ('2022-02-01', '2023-01-31'),
'2023': ('2023-02-01', '2024-01-31'),
...
}
"""
windows = {}
start_month = self.fiscal_year_start_month
for year in self.reporting_years:
# Fiscal year starts in start_month of year and ends in (start_month - 1) of year + 1
start_date = f"{year}-{start_month:02d}-01"
# End month is start_month - 1, or 12 if start_month is 1
end_month = start_month - 1 if start_month > 1 else 12
end_year = year + 1 if start_month > 1 else year
# Get last day of end month
if end_month in [1, 3, 5, 7, 8, 10, 12]:
end_day = 31
elif end_month in [4, 6, 9, 11]:
end_day = 30
else: # February
# Check for leap year
end_day = 29 if (end_year % 4 == 0 and (end_year % 100 != 0 or end_year % 400 == 0)) else 28
end_date = f"{end_year}-{end_month:02d}-{end_day:02d}"
# Use year as key, or year_ytd if it's the current/future period
current_year = datetime.now().year
if year >= current_year:
windows[f'{year}_ytd'] = (start_date, end_date)
else:
windows[str(year)] = (start_date, end_date)
return windows
@dataclass
class PathsConfig:
"""Path configuration."""
input_dir: str = 'input'
output_dir: str = 'results'
log_dir: str = 'logs'
@dataclass
class CurrencyConfig:
"""Currency configuration."""
base_currency: str = 'EUR'
report_currency: Optional[str] = None
flow_columns: List[str] = field(default_factory=lambda: ['Adjusted EUR', 'EUR equivalent'])
fx_rates_file: Optional[str] = None
fx_rates_sheet: Optional[str] = None
fx_date_column: str = 'TIME_PERIOD'
fx_rate_column: Optional[str] = None
fx_fill_method: str = 'ffill'
@dataclass
class NavAdjustmentConfig:
"""NAV adjustment applied from a start date onward."""
brokerage: str
clients: List[str]
start_date: str
amount: float
currency: Optional[str] = None
distribute: str = 'equal' # 'equal', 'per_account', or 'weights'
weights: Dict[str, float] = field(default_factory=dict)
@dataclass
class Config:
"""Main configuration class."""
brokerages: List[BrokerageConfig]
fees: FeeConfig
thresholds: ThresholdsConfig
periods: PeriodsConfig
paths: PathsConfig
currency: CurrencyConfig
nav_adjustments: List[NavAdjustmentConfig] = field(default_factory=list)
def get_enabled_brokerages(self) -> List[BrokerageConfig]:
"""Return list of enabled brokerages."""
return [b for b in self.brokerages if b.enabled]
def get_brokerage_names(self) -> List[str]:
"""Return list of enabled brokerage names."""
return [b.name for b in self.get_enabled_brokerages()]
def load_config(config_path: str = 'config.yaml') -> Config:
"""
Load configuration from YAML file.
Args:
config_path: Path to the configuration file
Returns:
Config object with validated configuration
Raises:
FileNotFoundError: If config file doesn't exist
ValueError: If configuration is invalid
"""
if not os.path.exists(config_path):
logging.warning(f"Config file {config_path} not found, using defaults")
return _get_default_config()
with open(config_path, 'r') as f:
raw_config = yaml.safe_load(f)
return _parse_config(raw_config)
def _get_default_config() -> Config:
"""Return default configuration."""
return Config(
brokerages=[
BrokerageConfig(name='IBKR', module='IBKR', enabled=True),
BrokerageConfig(name='Exante', module='Exante', enabled=True),
],
fees=FeeConfig(),
thresholds=ThresholdsConfig(),
periods=PeriodsConfig(),
paths=PathsConfig(),
currency=CurrencyConfig(),
nav_adjustments=[],
)
def _parse_config(raw: dict) -> Config:
"""Parse raw YAML dict into Config object."""
# Parse brokerages
brokerages = []
for b in raw.get('brokerages', []):
brokerages.append(BrokerageConfig(
name=b.get('name', ''),
module=b.get('module', ''),
enabled=b.get('enabled', True),
))
# Parse fees
fees_raw = raw.get('fees', {})
fees = FeeConfig(
management_fee_quarterly=fees_raw.get('management_fee_quarterly'),
management_fee_annual=fees_raw.get('management_fee_annual'),
performance_fee_rate=fees_raw.get('performance_fee_rate', 0.25),
hurdle_rate_annual=fees_raw.get('hurdle_rate_annual', 0.06),
)
# Parse thresholds
thresholds_raw = raw.get('thresholds', {})
thresholds = ThresholdsConfig(
large_cash_flow_pct=thresholds_raw.get('large_cash_flow_pct', 0.10),
)
# Parse periods
periods_raw = raw.get('periods', {})
# Default to January if not specified to align with calendar-year reporting.
periods = PeriodsConfig(
fiscal_year_start_month=periods_raw.get('fiscal_year_start_month', 1),
reporting_years=periods_raw.get('reporting_years', [2022, 2023, 2024, 2025]),
)
# Parse paths
paths_raw = raw.get('paths', {})
paths = PathsConfig(
input_dir=paths_raw.get('input_dir', 'input'),
output_dir=paths_raw.get('output_dir', 'results'),
log_dir=paths_raw.get('log_dir', 'logs'),
)
# Parse currency
currency_raw = raw.get('currency', {})
currency = CurrencyConfig(
base_currency=currency_raw.get('base_currency', 'EUR'),
report_currency=currency_raw.get('report_currency'),
flow_columns=currency_raw.get('flow_columns', ['Adjusted EUR', 'EUR equivalent']),
fx_rates_file=currency_raw.get('fx_rates_file'),
fx_rates_sheet=currency_raw.get('fx_rates_sheet'),
fx_date_column=currency_raw.get('fx_date_column', 'TIME_PERIOD'),
fx_rate_column=currency_raw.get('fx_rate_column'),
fx_fill_method=currency_raw.get('fx_fill_method', 'ffill'),
)
# Parse NAV adjustments
nav_adjustments_raw = raw.get('nav_adjustments', []) or []
nav_adjustments: List[NavAdjustmentConfig] = []
for adj in nav_adjustments_raw:
try:
nav_adjustments.append(NavAdjustmentConfig(
brokerage=adj.get('brokerage', ''),
clients=adj.get('clients', []) or [],
start_date=str(adj.get('start_date', '')).strip(),
amount=float(adj.get('amount', 0.0)),
currency=adj.get('currency'),
distribute=str(adj.get('distribute', 'equal')).strip().lower(),
weights=adj.get('weights', {}) or {},
))
except Exception:
logging.warning(f"Skipping invalid nav_adjustment entry: {adj}")
continue
return Config(
brokerages=brokerages,
fees=fees,
thresholds=thresholds,
periods=periods,
paths=paths,
currency=currency,
nav_adjustments=nav_adjustments,
)
def validate_config(config: Config) -> List[str]:
"""
Validate configuration and return list of warnings/errors.
Args:
config: Configuration to validate
Returns:
List of warning/error messages (empty if valid)
"""
issues = []
# Validate fees
if config.fees.management_fee_annual < 0 or config.fees.management_fee_annual > 0.10:
issues.append(f"Management fee {config.fees.management_fee_annual} seems unusual (expected 0-10%)")
if config.fees.management_fee_quarterly is not None:
if config.fees.management_fee_quarterly < 0 or config.fees.management_fee_quarterly > 0.05:
issues.append(
f"Quarterly management fee {config.fees.management_fee_quarterly} seems unusual (expected 0-5%)"
)
derived_annual = config.fees.management_fee_quarterly * 4
if abs(config.fees.management_fee_annual - derived_annual) > 1e-6:
issues.append(
"Management fee annual does not match quarterly * 4; annual is derived from quarterly for reporting"
)
if config.fees.performance_fee_rate < 0 or config.fees.performance_fee_rate > 0.50:
issues.append(f"Performance fee rate {config.fees.performance_fee_rate} seems unusual (expected 0-50%)")
if config.fees.hurdle_rate_annual < 0 or config.fees.hurdle_rate_annual > 0.20:
issues.append(f"Hurdle rate {config.fees.hurdle_rate_annual} seems unusual (expected 0-20%)")
# Validate thresholds
if config.thresholds.large_cash_flow_pct < 0.01 or config.thresholds.large_cash_flow_pct > 0.50:
issues.append(f"Large cash flow threshold {config.thresholds.large_cash_flow_pct} seems unusual (expected 1-50%)")
# Validate periods
if config.periods.fiscal_year_start_month < 1 or config.periods.fiscal_year_start_month > 12:
issues.append(f"Invalid fiscal year start month: {config.periods.fiscal_year_start_month}")
# Validate brokerages have required fields
for b in config.brokerages:
if not b.name:
issues.append("Brokerage missing name")
if not b.module:
issues.append(f"Brokerage {b.name} missing module")
return issues