portfolio-risk-engine 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. portfolio_risk_engine-0.1.0/.gitignore +10 -0
  2. portfolio_risk_engine-0.1.0/LICENSE +21 -0
  3. portfolio_risk_engine-0.1.0/PKG-INFO +18 -0
  4. portfolio_risk_engine-0.1.0/README.md +3 -0
  5. portfolio_risk_engine-0.1.0/portfolio_risk_engine/__init__.py +27 -0
  6. portfolio_risk_engine-0.1.0/portfolio_risk_engine/_fmp_provider.py +91 -0
  7. portfolio_risk_engine-0.1.0/portfolio_risk_engine/_logging.py +57 -0
  8. portfolio_risk_engine-0.1.0/portfolio_risk_engine/_ticker.py +59 -0
  9. portfolio_risk_engine-0.1.0/portfolio_risk_engine/_vendor.py +76 -0
  10. portfolio_risk_engine-0.1.0/portfolio_risk_engine/asset_class_performance.py +103 -0
  11. portfolio_risk_engine-0.1.0/portfolio_risk_engine/config.py +143 -0
  12. portfolio_risk_engine-0.1.0/portfolio_risk_engine/config_adapters.py +129 -0
  13. portfolio_risk_engine-0.1.0/portfolio_risk_engine/constants.py +127 -0
  14. portfolio_risk_engine-0.1.0/portfolio_risk_engine/data_loader.py +419 -0
  15. portfolio_risk_engine-0.1.0/portfolio_risk_engine/data_objects.py +1478 -0
  16. portfolio_risk_engine-0.1.0/portfolio_risk_engine/exceptions.py +110 -0
  17. portfolio_risk_engine-0.1.0/portfolio_risk_engine/exit_signals.py +229 -0
  18. portfolio_risk_engine-0.1.0/portfolio_risk_engine/factor_utils.py +622 -0
  19. portfolio_risk_engine-0.1.0/portfolio_risk_engine/income_projection.py +534 -0
  20. portfolio_risk_engine-0.1.0/portfolio_risk_engine/optimization.py +212 -0
  21. portfolio_risk_engine-0.1.0/portfolio_risk_engine/performance_analysis.py +174 -0
  22. portfolio_risk_engine-0.1.0/portfolio_risk_engine/performance_metrics_engine.py +234 -0
  23. portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_config.py +370 -0
  24. portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_optimizer.py +1399 -0
  25. portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_risk.py +1809 -0
  26. portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_risk_score.py +1930 -0
  27. portfolio_risk_engine-0.1.0/portfolio_risk_engine/providers.py +49 -0
  28. portfolio_risk_engine-0.1.0/portfolio_risk_engine/results.py +59 -0
  29. portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_flags.py +122 -0
  30. portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_helpers.py +371 -0
  31. portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_profiles.py +167 -0
  32. portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_summary.py +193 -0
  33. portfolio_risk_engine-0.1.0/portfolio_risk_engine/scenario_analysis.py +222 -0
  34. portfolio_risk_engine-0.1.0/portfolio_risk_engine/stock_analysis.py +323 -0
  35. portfolio_risk_engine-0.1.0/pyproject.toml +22 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .DS_Store
8
+ *.egg
9
+ .venv/
10
+ venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Chien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: portfolio-risk-engine
3
+ Version: 0.1.0
4
+ Summary: Standalone portfolio risk analytics engine
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: cvxpy
8
+ Requires-Dist: numpy
9
+ Requires-Dist: pandas
10
+ Requires-Dist: pyarrow
11
+ Requires-Dist: pyyaml
12
+ Requires-Dist: requests
13
+ Requires-Dist: statsmodels
14
+ Description-Content-Type: text/markdown
15
+
16
+ # portfolio-risk-engine
17
+
18
+ Standalone portfolio risk analytics engine extracted from the risk_module monorepo.
@@ -0,0 +1,3 @@
1
+ # portfolio-risk-engine
2
+
3
+ Standalone portfolio risk analytics engine extracted from the risk_module monorepo.
@@ -0,0 +1,27 @@
1
+ """Public API for portfolio_risk_engine."""
2
+
3
+ from portfolio_risk_engine.portfolio_risk import (
4
+ build_portfolio_view,
5
+ normalize_weights,
6
+ calculate_portfolio_performance_metrics,
7
+ )
8
+ from portfolio_risk_engine.providers import (
9
+ PriceProvider,
10
+ FXProvider,
11
+ set_price_provider,
12
+ get_price_provider,
13
+ set_fx_provider,
14
+ get_fx_provider,
15
+ )
16
+
17
+ __all__ = [
18
+ "build_portfolio_view",
19
+ "normalize_weights",
20
+ "calculate_portfolio_performance_metrics",
21
+ "PriceProvider",
22
+ "FXProvider",
23
+ "set_price_provider",
24
+ "get_price_provider",
25
+ "set_fx_provider",
26
+ "get_fx_provider",
27
+ ]
@@ -0,0 +1,91 @@
1
+ """Default lazy FMP-backed providers for standalone or monorepo usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import pandas as pd
8
+
9
+ from portfolio_risk_engine._ticker import select_fmp_symbol
10
+
11
+
12
+ class FMPPriceProvider:
13
+ """Thin adapter over fmp.compat with lazy imports."""
14
+
15
+ def fetch_monthly_close(self, ticker, start_date=None, end_date=None, **kw) -> pd.Series:
16
+ from fmp.compat import fetch_monthly_close as _fn # type: ignore
17
+
18
+ return _fn(ticker, start_date, end_date, **kw)
19
+
20
+ def fetch_monthly_total_return_price(self, ticker, start_date=None, end_date=None, **kw) -> pd.Series:
21
+ from fmp.compat import fetch_monthly_total_return_price as _fn # type: ignore
22
+
23
+ return _fn(ticker, start_date, end_date, **kw)
24
+
25
+ def fetch_monthly_treasury_rates(self, maturity: str, start_date=None, end_date=None) -> pd.Series:
26
+ from fmp.compat import fetch_monthly_treasury_rates as _fn # type: ignore
27
+
28
+ return _fn(maturity, start_date, end_date)
29
+
30
+ def fetch_dividend_history(self, ticker, start_date=None, end_date=None, **kw) -> pd.DataFrame:
31
+ from fmp.compat import fetch_dividend_history as _fn # type: ignore
32
+
33
+ return _fn(ticker, start_date, end_date, **kw)
34
+
35
+ def fetch_current_dividend_yield(self, ticker, **kw) -> float:
36
+ # Keep this lightweight and consistent with existing implementation:
37
+ # compute from dividend history + latest month-end close.
38
+ fmp_symbol = select_fmp_symbol(
39
+ ticker,
40
+ fmp_ticker=kw.get("fmp_ticker"),
41
+ fmp_ticker_map=kw.get("fmp_ticker_map"),
42
+ )
43
+
44
+ lookback_months = int((kw.get("lookback_months") or 12))
45
+ end_month = (pd.Timestamp.today().to_period("M") - 1).to_timestamp("M")
46
+ start_month = end_month - pd.DateOffset(months=lookback_months - 1)
47
+
48
+ div_df = self.fetch_dividend_history(
49
+ fmp_symbol,
50
+ start_month,
51
+ end_month,
52
+ fmp_ticker=fmp_symbol,
53
+ )
54
+ if isinstance(div_df, pd.Series):
55
+ div_df = div_df.to_frame(name="adjDividend")
56
+ if div_df is None or div_df.empty:
57
+ return 0.0
58
+
59
+ annual_dividends = pd.to_numeric(
60
+ div_df.get("adjDividend", pd.Series(dtype=float)),
61
+ errors="coerce",
62
+ ).fillna(0.0).sum()
63
+
64
+ prices = self.fetch_monthly_close(
65
+ fmp_symbol,
66
+ None,
67
+ end_month.date().isoformat(),
68
+ fmp_ticker=fmp_symbol,
69
+ )
70
+ if prices is None or prices.dropna().empty:
71
+ return 0.0
72
+
73
+ current_price = float(prices.dropna().iloc[-1])
74
+ if current_price <= 0 or annual_dividends <= 0:
75
+ return 0.0
76
+
77
+ return round(float((annual_dividends / current_price) * 100.0), 4)
78
+
79
+
80
+ class FMPFXProvider:
81
+ """Optional FX adapter over fmp.fx."""
82
+
83
+ def adjust_returns_for_fx(self, returns: pd.Series, currency: str, **kw):
84
+ from fmp.fx import adjust_returns_for_fx as _fn # type: ignore
85
+
86
+ return _fn(returns, currency, **kw)
87
+
88
+ def get_fx_rate(self, currency: str) -> float:
89
+ from fmp.fx import get_fx_rate as _fn # type: ignore
90
+
91
+ return float(_fn(currency))
@@ -0,0 +1,57 @@
1
+ """Logging shim.
2
+
3
+ Uses monorepo logging when available. Falls back to stdlib logging and no-op
4
+ instrumentation decorators in standalone mode.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import logging
11
+ from typing import Any, Callable
12
+
13
+
14
+ try: # pragma: no cover - preferred in monorepo
15
+ from utils.logging import ( # type: ignore
16
+ portfolio_logger,
17
+ log_operation,
18
+ log_timing,
19
+ log_errors,
20
+ log_portfolio_operation,
21
+ log_critical_alert,
22
+ log_service_health,
23
+ )
24
+ except Exception: # pragma: no cover - standalone fallback
25
+ portfolio_logger = logging.getLogger("portfolio_risk_engine")
26
+
27
+ def _identity_decorator(_arg: Any = None) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
28
+ def deco(fn: Callable[..., Any]) -> Callable[..., Any]:
29
+ @functools.wraps(fn)
30
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
31
+ return fn(*args, **kwargs)
32
+
33
+ return wrapper
34
+
35
+ return deco
36
+
37
+ def log_operation(_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
38
+ return _identity_decorator()
39
+
40
+ def log_timing(_threshold: float = 0.0) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
41
+ return _identity_decorator()
42
+
43
+ def log_errors(_severity: str = "medium") -> Callable[[Callable[..., Any]], Callable[..., Any]]:
44
+ return _identity_decorator()
45
+
46
+ def log_portfolio_operation(_event: str, _details: dict[str, Any] | None = None, execution_time: float | None = None) -> dict[str, Any]:
47
+ if _details:
48
+ portfolio_logger.info("[%s] %s", _event, _details)
49
+ else:
50
+ portfolio_logger.info("[%s]", _event)
51
+ return {"event": _event, "details": _details or {}, "execution_time": execution_time}
52
+
53
+ def log_critical_alert(_alert_type: str, _severity: str, message: str, _action: str | None = None, details: dict[str, Any] | None = None) -> None:
54
+ portfolio_logger.warning("critical_alert: %s %s", message, details or {})
55
+
56
+ def log_service_health(service: str, status: str, response_time: float | None = None, details: dict[str, Any] | None = None) -> None:
57
+ portfolio_logger.info("service_health: %s %s %.3f %s", service, status, response_time or 0.0, details or {})
@@ -0,0 +1,59 @@
1
+ """Minimal ticker/currency resolver helpers for standalone mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def normalize_currency(currency: Optional[str]) -> Optional[str]:
9
+ if not currency:
10
+ return None
11
+ ccy = str(currency).upper()
12
+ aliases = {
13
+ "GBX": "GBP",
14
+ "GBP": "GBP",
15
+ }
16
+ return aliases.get(ccy, ccy)
17
+
18
+
19
+ def select_fmp_symbol(
20
+ ticker: str,
21
+ *,
22
+ fmp_ticker: Optional[str] = None,
23
+ fmp_ticker_map: Optional[dict[str, str]] = None,
24
+ ) -> str:
25
+ if fmp_ticker:
26
+ return fmp_ticker
27
+ if fmp_ticker_map and ticker in fmp_ticker_map:
28
+ mapped = fmp_ticker_map.get(ticker)
29
+ if mapped:
30
+ return mapped
31
+ return ticker
32
+
33
+
34
+ def normalize_fmp_price(price: Optional[float], currency: Optional[str]) -> tuple[Optional[float], str]:
35
+ if price is None:
36
+ return None, (currency or "USD")
37
+ ccy = normalize_currency(currency) or "USD"
38
+ minor = {
39
+ "GBX": ("GBP", 100.0),
40
+ }
41
+ if ccy in minor:
42
+ base_ccy, divisor = minor[ccy]
43
+ return (float(price) / divisor), base_ccy
44
+ return float(price), ccy
45
+
46
+
47
+ def fetch_fmp_quote_with_currency(symbol: str) -> tuple[Optional[float], Optional[str]]:
48
+ if not symbol:
49
+ return None, None
50
+ try: # pragma: no cover - best effort live fetch
51
+ from fmp.client import FMPClient # type: ignore
52
+
53
+ data = FMPClient().fetch_raw("profile", symbol=symbol)
54
+ if isinstance(data, list) and data:
55
+ row = data[0] or {}
56
+ return row.get("price"), row.get("currency")
57
+ except Exception:
58
+ pass
59
+ return None, None
@@ -0,0 +1,76 @@
1
+ """Small vendored helpers for standalone-safe serialization/coercion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ try:
9
+ import numpy as np
10
+ except Exception: # pragma: no cover
11
+ np = None
12
+
13
+ try:
14
+ import pandas as pd
15
+ except Exception: # pragma: no cover
16
+ pd = None
17
+
18
+
19
+ def make_json_safe(obj: Any) -> Any:
20
+ """Recursively convert values into JSON-serializable forms."""
21
+ if isinstance(obj, dict):
22
+ out = {}
23
+ for key, value in obj.items():
24
+ if pd is not None and isinstance(key, (pd.Timestamp, datetime)):
25
+ safe_key = key.strftime("%Y-%m-%d %H:%M:%S")
26
+ elif isinstance(key, (int, float, str, bool, type(None))):
27
+ safe_key = key
28
+ else:
29
+ safe_key = str(key)
30
+ out[safe_key] = make_json_safe(value)
31
+ return out
32
+
33
+ if isinstance(obj, list):
34
+ return [make_json_safe(item) for item in obj]
35
+
36
+ if pd is not None and isinstance(obj, pd.DataFrame):
37
+ return obj.to_dict("records")
38
+
39
+ if pd is not None and isinstance(obj, pd.Series):
40
+ return {str(k): make_json_safe(v) for k, v in obj.to_dict().items()}
41
+
42
+ if np is not None and isinstance(obj, np.ndarray):
43
+ return obj.tolist()
44
+
45
+ if np is not None and isinstance(obj, (np.int64, np.int32)):
46
+ return int(obj)
47
+
48
+ if np is not None and isinstance(obj, (np.float64, np.float32)):
49
+ return float(obj)
50
+
51
+ if np is not None and isinstance(obj, np.bool_):
52
+ return bool(obj)
53
+
54
+ if pd is not None and isinstance(obj, (pd.Timestamp, datetime)):
55
+ return obj.strftime("%Y-%m-%d %H:%M:%S")
56
+
57
+ if pd is not None:
58
+ try:
59
+ if pd.isna(obj):
60
+ return None
61
+ except Exception:
62
+ pass
63
+
64
+ if isinstance(obj, (int, float, str, bool, type(None))):
65
+ return obj
66
+
67
+ return str(obj)
68
+
69
+
70
+ def _to_float(value: Any) -> float | None:
71
+ try:
72
+ if value is None:
73
+ return None
74
+ return float(value)
75
+ except (TypeError, ValueError):
76
+ return None
@@ -0,0 +1,103 @@
1
+ """
2
+ Asset Class Performance - Core Business Logic (monthly-only periods)
3
+
4
+ Pure functions to compute portfolio asset-class performance over a selected
5
+ monthly period window using cached price data. No logging, no services here.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Dict
10
+ from datetime import datetime, timedelta
11
+
12
+ from portfolio_risk_engine.data_loader import fetch_monthly_close
13
+
14
+
15
+ SUPPORTED_PERIODS = {"1M", "3M", "6M", "1Y", "YTD"}
16
+
17
+
18
+ def get_period_start_date(time_period: str) -> str:
19
+ """Return ISO date string for the start of the given monthly period.
20
+
21
+ Supported periods: 1M, 3M, 6M, 1Y, YTD
22
+ Defaults to 1M if unknown.
23
+ """
24
+ now = datetime.now()
25
+ period = (time_period or "1M").upper()
26
+ if period == "3M":
27
+ start = now - timedelta(days=90)
28
+ elif period == "6M":
29
+ start = now - timedelta(days=180)
30
+ elif period == "1Y":
31
+ start = now - timedelta(days=365)
32
+ elif period == "YTD":
33
+ start = datetime(now.year, 1, 1)
34
+ else:
35
+ # Default 1M
36
+ start = now - timedelta(days=30)
37
+ return start.strftime("%Y-%m-%d")
38
+
39
+
40
+ def group_holdings_by_asset_class(
41
+ portfolio_weights: Dict[str, float],
42
+ asset_class_mapping: Dict[str, str]
43
+ ) -> Dict[str, Dict[str, float]]:
44
+ """Group weights by asset class using a ticker→asset_class mapping."""
45
+ grouped: Dict[str, Dict[str, float]] = {}
46
+ for ticker, weight in (portfolio_weights or {}).items():
47
+ asset_class = asset_class_mapping.get(ticker, "unknown")
48
+ bucket = grouped.setdefault(asset_class, {})
49
+ bucket[ticker] = weight
50
+ return grouped
51
+
52
+
53
+ def calculate_weighted_portfolio_return(
54
+ holdings: Dict[str, float],
55
+ time_period: str,
56
+ fmp_ticker_map: Dict[str, str] | None = None,
57
+ ) -> float:
58
+ """Compute weighted period return for a set of holdings using monthly closes."""
59
+ total_weight = sum(holdings.values()) or 0.0
60
+ if total_weight == 0:
61
+ return 0.0
62
+
63
+ start_date = get_period_start_date(time_period)
64
+ total_return = 0.0
65
+ for ticker, weight in holdings.items():
66
+ series = fetch_monthly_close(
67
+ ticker,
68
+ start_date=start_date,
69
+ fmp_ticker_map=fmp_ticker_map,
70
+ )
71
+ if len(series) >= 2:
72
+ period_ret = (series.iloc[-1] / series.iloc[0]) - 1.0
73
+ total_return += period_ret * (weight / total_weight)
74
+ return total_return
75
+
76
+
77
+ def calculate_asset_class_returns(
78
+ asset_class_holdings: Dict[str, Dict[str, float]],
79
+ time_period: str,
80
+ fmp_ticker_map: Dict[str, str] | None = None,
81
+ ) -> Dict[str, float]:
82
+ """Calculate weighted returns per asset class for the selected period."""
83
+ results: Dict[str, float] = {}
84
+ for asset_class, class_holdings in (asset_class_holdings or {}).items():
85
+ if not class_holdings:
86
+ continue
87
+ results[asset_class] = calculate_weighted_portfolio_return(
88
+ class_holdings,
89
+ time_period,
90
+ fmp_ticker_map=fmp_ticker_map,
91
+ )
92
+ return results
93
+
94
+
95
+ def classify_performance_change(return_pct: float) -> str:
96
+ """Classify change as positive/negative/neutral using ±0.5% thresholds."""
97
+ if return_pct is None:
98
+ return "neutral"
99
+ if return_pct > 0.005:
100
+ return "positive"
101
+ if return_pct < -0.005:
102
+ return "negative"
103
+ return "neutral"
@@ -0,0 +1,143 @@
1
+ """Standalone-safe configuration surface for portfolio_risk_engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+
9
+ def _env_int(name: str, default: int) -> int:
10
+ try:
11
+ return int(os.getenv(name, str(default)))
12
+ except Exception:
13
+ return default
14
+
15
+
16
+ def _env_float(name: str, default: float) -> float:
17
+ try:
18
+ return float(os.getenv(name, str(default)))
19
+ except Exception:
20
+ return default
21
+
22
+
23
+ _DEFAULTS: dict[str, Any] = {
24
+ "PORTFOLIO_DEFAULTS": {
25
+ "start_date": os.getenv("PORTFOLIO_DEFAULT_START_DATE", "2019-01-31"),
26
+ "end_date": os.getenv("PORTFOLIO_DEFAULT_END_DATE", "2026-01-29"),
27
+ "normalize_weights": os.getenv("PORTFOLIO_DEFAULT_NORMALIZE_WEIGHTS", "false").lower() == "true",
28
+ "worst_case_lookback_years": _env_int("PORTFOLIO_WORST_CASE_LOOKBACK_YEARS", 10),
29
+ "expected_returns_lookback_years": _env_int("PORTFOLIO_EXPECTED_RETURNS_LOOKBACK_YEARS", 10),
30
+ "expected_returns_fallback_default": _env_float("PORTFOLIO_EXPECTED_RETURNS_FALLBACK", 0.06),
31
+ "cash_proxy_fallback_return": _env_float("PORTFOLIO_CASH_PROXY_FALLBACK_RETURN", 0.02),
32
+ },
33
+ "DIVIDEND_DEFAULTS": {
34
+ "lookback_months": _env_int("DIVIDEND_LOOKBACK_MONTHS", 12),
35
+ "min_dividend_data_coverage": _env_float("DIVIDEND_MIN_DATA_COVERAGE", 0.7),
36
+ "include_zero_yield_positions": os.getenv("DIVIDEND_INCLUDE_ZERO_YIELD_POSITIONS", "true").lower() == "true",
37
+ },
38
+ "RATE_FACTOR_CONFIG": {
39
+ "default_maturities": ["UST2Y", "UST5Y", "UST10Y", "UST30Y"],
40
+ "treasury_mapping": {
41
+ "UST2Y": "year2",
42
+ "UST5Y": "year5",
43
+ "UST10Y": "year10",
44
+ "UST30Y": "year30",
45
+ },
46
+ "min_required_maturities": 2,
47
+ "scale": "pp",
48
+ "frequency": "M",
49
+ "eligible_asset_classes": ["bond", "real_estate"],
50
+ },
51
+ "DATA_QUALITY_THRESHOLDS": {
52
+ "min_observations_for_factor_betas": 2,
53
+ "min_observations_for_interest_rate_beta": 6,
54
+ "min_observations_for_peer_validation": 3,
55
+ "min_peer_overlap_observations": 1,
56
+ "min_observations_for_returns_calculation": 2,
57
+ "min_observations_for_regression": 3,
58
+ "min_valid_peers_for_median": 1,
59
+ "max_peer_drop_rate": 0.8,
60
+ "min_observations_for_expected_returns": 11,
61
+ "min_observations_for_capm_regression": 12,
62
+ "min_r2_for_rate_factors": 0.3,
63
+ "max_reasonable_interest_rate_beta": 25,
64
+ },
65
+ "RISK_ANALYSIS_THRESHOLDS": {
66
+ "leverage_warning_threshold": 1.1,
67
+ "risk_score_safe_threshold": 0.8,
68
+ "risk_score_caution_threshold": 1.0,
69
+ "risk_score_danger_threshold": 1.5,
70
+ "risk_score_critical_threshold": 2.0,
71
+ "beta_warning_ratio": 0.75,
72
+ "beta_violation_ratio": 1.0,
73
+ "herfindahl_warning_threshold": 0.15,
74
+ "concentration_warning_ratio": 0.8,
75
+ "volatility_warning_ratio": 0.8,
76
+ "factor_variance_warning_ratio": 0.8,
77
+ "market_variance_warning_ratio": 0.8,
78
+ "variance_contribution_threshold": 0.05,
79
+ "industry_concentration_warning_ratio": 0.5,
80
+ "leverage_display_threshold": 1.01,
81
+ },
82
+ "WORST_CASE_SCENARIOS": {
83
+ "market_crash": 0.35,
84
+ "momentum_crash": 0.50,
85
+ "value_crash": 0.40,
86
+ "single_stock_crash": 0.80,
87
+ "sector_crash": 0.50,
88
+ "etf_crash": 0.35,
89
+ "fund_crash": 0.40,
90
+ "mutual_fund_crash": 0.40,
91
+ "cash_crash": 0.05,
92
+ "max_reasonable_volatility": 0.40,
93
+ },
94
+ "MAX_SINGLE_FACTOR_LOSS": {
95
+ "default": -0.10,
96
+ "sector": -0.08,
97
+ "portfolio": -0.08,
98
+ },
99
+ "SECURITY_TYPE_CRASH_MAPPING": {
100
+ "equity": "single_stock_crash",
101
+ "etf": "etf_crash",
102
+ "fund": "fund_crash",
103
+ "mutual_fund": "mutual_fund_crash",
104
+ "cash": "cash_crash",
105
+ },
106
+ "DIVIDEND_LRU_SIZE": _env_int("DIVIDEND_LRU_SIZE", 100),
107
+ "DIVIDEND_DATA_QUALITY_THRESHOLD": _env_float("DIVIDEND_DATA_QUALITY_THRESHOLD", 0.25),
108
+ "PORTFOLIO_RISK_LRU_SIZE": _env_int("PORTFOLIO_RISK_LRU_SIZE", 100),
109
+ "FMP_API_KEY": os.getenv("FMP_API_KEY", ""),
110
+ }
111
+
112
+
113
+ try: # pragma: no cover - monorepo defaults
114
+ import settings as _settings # type: ignore
115
+
116
+ for key in list(_DEFAULTS.keys()):
117
+ if hasattr(_settings, key):
118
+ _DEFAULTS[key] = getattr(_settings, key)
119
+ except Exception:
120
+ pass
121
+
122
+
123
+ PORTFOLIO_DEFAULTS = _DEFAULTS["PORTFOLIO_DEFAULTS"]
124
+ DIVIDEND_DEFAULTS = _DEFAULTS["DIVIDEND_DEFAULTS"]
125
+ RATE_FACTOR_CONFIG = _DEFAULTS["RATE_FACTOR_CONFIG"]
126
+ DATA_QUALITY_THRESHOLDS = _DEFAULTS["DATA_QUALITY_THRESHOLDS"]
127
+ RISK_ANALYSIS_THRESHOLDS = _DEFAULTS["RISK_ANALYSIS_THRESHOLDS"]
128
+ WORST_CASE_SCENARIOS = _DEFAULTS["WORST_CASE_SCENARIOS"]
129
+ MAX_SINGLE_FACTOR_LOSS = _DEFAULTS["MAX_SINGLE_FACTOR_LOSS"]
130
+ SECURITY_TYPE_CRASH_MAPPING = _DEFAULTS["SECURITY_TYPE_CRASH_MAPPING"]
131
+ DIVIDEND_LRU_SIZE = int(_DEFAULTS["DIVIDEND_LRU_SIZE"])
132
+ DIVIDEND_DATA_QUALITY_THRESHOLD = float(_DEFAULTS["DIVIDEND_DATA_QUALITY_THRESHOLD"])
133
+ PORTFOLIO_RISK_LRU_SIZE = int(_DEFAULTS["PORTFOLIO_RISK_LRU_SIZE"])
134
+ FMP_API_KEY = str(_DEFAULTS["FMP_API_KEY"])
135
+
136
+
137
+ def configure(**overrides: Any) -> None:
138
+ """Programmatically override package configuration values."""
139
+ globals_dict = globals()
140
+ for key, value in overrides.items():
141
+ if key not in globals_dict:
142
+ raise KeyError(f"Unknown config key: {key}")
143
+ globals_dict[key] = value