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.
- portfolio_risk_engine-0.1.0/.gitignore +10 -0
- portfolio_risk_engine-0.1.0/LICENSE +21 -0
- portfolio_risk_engine-0.1.0/PKG-INFO +18 -0
- portfolio_risk_engine-0.1.0/README.md +3 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/__init__.py +27 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/_fmp_provider.py +91 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/_logging.py +57 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/_ticker.py +59 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/_vendor.py +76 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/asset_class_performance.py +103 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/config.py +143 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/config_adapters.py +129 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/constants.py +127 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/data_loader.py +419 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/data_objects.py +1478 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/exceptions.py +110 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/exit_signals.py +229 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/factor_utils.py +622 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/income_projection.py +534 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/optimization.py +212 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/performance_analysis.py +174 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/performance_metrics_engine.py +234 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_config.py +370 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_optimizer.py +1399 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_risk.py +1809 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/portfolio_risk_score.py +1930 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/providers.py +49 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/results.py +59 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_flags.py +122 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_helpers.py +371 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_profiles.py +167 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/risk_summary.py +193 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/scenario_analysis.py +222 -0
- portfolio_risk_engine-0.1.0/portfolio_risk_engine/stock_analysis.py +323 -0
- portfolio_risk_engine-0.1.0/pyproject.toml +22 -0
|
@@ -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,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
|