ellements 0.2.0__py3-none-any.whl

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 (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. ellements-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,279 @@
1
+ """Quantitative portfolio analytics with optional QuantStats integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from tempfile import NamedTemporaryFile
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from pydantic import BaseModel, Field
12
+
13
+ from .risk import RiskMetrics, risk_metrics
14
+
15
+
16
+ def quantstats_available() -> bool:
17
+ """Return True if quantstats is importable."""
18
+
19
+ try:
20
+ import quantstats # noqa: F401
21
+
22
+ return True
23
+ except Exception:
24
+ return False
25
+
26
+
27
+ class QuantRiskSummary(BaseModel):
28
+ """Quantitative risk summary, with or without QuantStats backing."""
29
+
30
+ sample_size: int
31
+ periods_per_year: int
32
+ quantstats_used: bool = False
33
+ benchmark_symbol: str | None = None
34
+ annualized_return: float | None = None
35
+ annualized_volatility: float | None = None
36
+ sharpe_ratio: float | None = None
37
+ sortino_ratio: float | None = None
38
+ calmar_ratio: float | None = None
39
+ max_drawdown: float | None = None
40
+ value_at_risk_95: float | None = None
41
+ value_at_risk_99: float | None = None
42
+ conditional_value_at_risk_95: float | None = None
43
+ conditional_value_at_risk_99: float | None = None
44
+ win_rate: float | None = None
45
+ beta: float | None = None
46
+
47
+
48
+ class AlignedReturnSeries(BaseModel):
49
+ """Aligned portfolio and benchmark return series."""
50
+
51
+ portfolio_returns: list[float] = Field(default_factory=list)
52
+ benchmark_returns: list[float] | None = None
53
+ sample_size: int = 0
54
+
55
+
56
+ def _to_series(returns: Sequence[float] | pd.Series, name: str) -> pd.Series:
57
+ if isinstance(returns, pd.Series):
58
+ series = returns.astype(float).copy()
59
+ else:
60
+ series = pd.Series(list(returns), dtype="float64", name=name)
61
+ series = series.replace([np.inf, -np.inf], np.nan).dropna()
62
+ if series.name is None:
63
+ series.name = name
64
+ return series
65
+
66
+
67
+ def align_return_series(
68
+ portfolio_returns: Sequence[float] | pd.Series,
69
+ benchmark_returns: Sequence[float] | pd.Series | None = None,
70
+ ) -> AlignedReturnSeries:
71
+ """Align return series and remove NaN/inf values."""
72
+
73
+ p = _to_series(portfolio_returns, "portfolio")
74
+ if benchmark_returns is None:
75
+ return AlignedReturnSeries(
76
+ portfolio_returns=p.tolist(),
77
+ benchmark_returns=None,
78
+ sample_size=len(p),
79
+ )
80
+
81
+ b = _to_series(benchmark_returns, "benchmark")
82
+ if isinstance(portfolio_returns, pd.Series) and isinstance(
83
+ benchmark_returns, pd.Series
84
+ ):
85
+ aligned = pd.concat([p, b], axis=1, join="inner").dropna()
86
+ p = aligned.iloc[:, 0]
87
+ b = aligned.iloc[:, 1]
88
+ else:
89
+ n = min(len(p), len(b))
90
+ p = p.iloc[-n:] if n > 0 else p.iloc[:0]
91
+ b = b.iloc[-n:] if n > 0 else b.iloc[:0]
92
+
93
+ return AlignedReturnSeries(
94
+ portfolio_returns=p.tolist(),
95
+ benchmark_returns=b.tolist(),
96
+ sample_size=len(p),
97
+ )
98
+
99
+
100
+ def _safe_float(value: object) -> float | None:
101
+ try:
102
+ if value is None:
103
+ return None
104
+ result = float(value) # type: ignore[arg-type]
105
+ if np.isnan(result):
106
+ return None
107
+ return result
108
+ except Exception:
109
+ return None
110
+
111
+
112
+ def summarize_quant_risk(
113
+ portfolio_returns: Sequence[float] | pd.Series,
114
+ benchmark_returns: Sequence[float] | pd.Series | None = None,
115
+ risk_free_rate: float = 0.0,
116
+ periods_per_year: int = 252,
117
+ benchmark_symbol: str | None = None,
118
+ var_method: str = "historical",
119
+ ) -> QuantRiskSummary:
120
+ """Summarize risk metrics, using QuantStats when available."""
121
+
122
+ aligned = align_return_series(portfolio_returns, benchmark_returns)
123
+ market = aligned.benchmark_returns
124
+
125
+ base = risk_metrics(
126
+ aligned.portfolio_returns,
127
+ market_returns=market,
128
+ risk_free_rate=risk_free_rate,
129
+ periods_per_year=periods_per_year,
130
+ var_method=var_method,
131
+ )
132
+
133
+ summary = QuantRiskSummary(
134
+ sample_size=aligned.sample_size,
135
+ periods_per_year=periods_per_year,
136
+ quantstats_used=False,
137
+ benchmark_symbol=benchmark_symbol,
138
+ annualized_return=base.annualized_return,
139
+ annualized_volatility=base.annualized_volatility,
140
+ sharpe_ratio=base.sharpe_ratio,
141
+ sortino_ratio=base.sortino_ratio,
142
+ calmar_ratio=base.calmar_ratio,
143
+ max_drawdown=base.max_drawdown,
144
+ value_at_risk_95=base.value_at_risk_95,
145
+ value_at_risk_99=base.value_at_risk_99,
146
+ conditional_value_at_risk_95=base.conditional_value_at_risk_95,
147
+ conditional_value_at_risk_99=base.conditional_value_at_risk_99,
148
+ win_rate=base.win_rate,
149
+ beta=base.beta,
150
+ )
151
+
152
+ if not quantstats_available() or aligned.sample_size < 2:
153
+ return summary
154
+
155
+ try:
156
+ import quantstats as qs
157
+
158
+ series = _to_series(aligned.portfolio_returns, "portfolio")
159
+ benchmark_series = (
160
+ _to_series(market, "benchmark") if market is not None else None
161
+ )
162
+
163
+ qs_sharpe = _safe_float(
164
+ qs.stats.sharpe(series, rf=risk_free_rate, periods=periods_per_year)
165
+ )
166
+ qs_sortino = _safe_float(
167
+ qs.stats.sortino(series, rf=risk_free_rate, periods=periods_per_year)
168
+ )
169
+ qs_calmar = _safe_float(qs.stats.calmar(series))
170
+ qs_cagr = _safe_float(qs.stats.cagr(series, periods=periods_per_year))
171
+ qs_vol = _safe_float(qs.stats.volatility(series, periods=periods_per_year))
172
+ qs_mdd = _safe_float(abs(qs.stats.max_drawdown(series)))
173
+ qs_win_rate = _safe_float(qs.stats.win_rate(series))
174
+ qs_beta = (
175
+ _safe_float(qs.stats.beta(series, benchmark_series))
176
+ if benchmark_series is not None
177
+ else summary.beta
178
+ )
179
+
180
+ summary.sharpe_ratio = (
181
+ qs_sharpe if qs_sharpe is not None else summary.sharpe_ratio
182
+ )
183
+ summary.sortino_ratio = (
184
+ qs_sortino if qs_sortino is not None else summary.sortino_ratio
185
+ )
186
+ summary.calmar_ratio = (
187
+ qs_calmar if qs_calmar is not None else summary.calmar_ratio
188
+ )
189
+ summary.annualized_return = (
190
+ qs_cagr if qs_cagr is not None else summary.annualized_return
191
+ )
192
+ summary.annualized_volatility = (
193
+ qs_vol if qs_vol is not None else summary.annualized_volatility
194
+ )
195
+ summary.max_drawdown = qs_mdd if qs_mdd is not None else summary.max_drawdown
196
+ summary.win_rate = qs_win_rate if qs_win_rate is not None else summary.win_rate
197
+ summary.beta = qs_beta if qs_beta is not None else summary.beta
198
+ summary.quantstats_used = True
199
+ return summary
200
+ except Exception:
201
+ return summary
202
+
203
+
204
+ def risk_metrics_from_quant_summary(summary: QuantRiskSummary) -> RiskMetrics:
205
+ """Convert a QuantRiskSummary into the generic RiskMetrics model."""
206
+
207
+ return RiskMetrics(
208
+ sharpe_ratio=summary.sharpe_ratio,
209
+ sortino_ratio=summary.sortino_ratio,
210
+ calmar_ratio=summary.calmar_ratio,
211
+ beta=summary.beta,
212
+ annualized_return=summary.annualized_return,
213
+ annualized_volatility=summary.annualized_volatility,
214
+ value_at_risk_95=summary.value_at_risk_95,
215
+ value_at_risk_99=summary.value_at_risk_99,
216
+ conditional_value_at_risk_95=summary.conditional_value_at_risk_95,
217
+ conditional_value_at_risk_99=summary.conditional_value_at_risk_99,
218
+ max_drawdown=summary.max_drawdown,
219
+ win_rate=summary.win_rate,
220
+ quantstats_used=summary.quantstats_used,
221
+ )
222
+
223
+
224
+ def _ensure_datetime_index(series: pd.Series) -> pd.Series:
225
+ """Return *series* with a ``DatetimeIndex``.
226
+
227
+ quantstats reporting helpers require a ``DatetimeIndex`` so they can
228
+ format the report's date range. When the caller supplied a plain
229
+ integer index (the common case for synthetic return streams), we
230
+ synthesize a daily business-day index ending today.
231
+ """
232
+ if isinstance(series.index, pd.DatetimeIndex):
233
+ return series
234
+ new_index = pd.bdate_range(
235
+ end=pd.Timestamp.today().normalize(), periods=len(series)
236
+ )
237
+ return series.set_axis(new_index)
238
+
239
+
240
+ def generate_quantstats_tear_sheet(
241
+ portfolio_returns: Sequence[float] | pd.Series,
242
+ benchmark_returns: Sequence[float] | pd.Series | None = None,
243
+ output_path: str | Path | None = None,
244
+ title: str = "Portfolio Risk Tear Sheet",
245
+ ) -> str:
246
+ """Generate an HTML tear sheet and return path to generated file."""
247
+
248
+ if not quantstats_available():
249
+ raise RuntimeError("quantstats is not available in the current environment.")
250
+
251
+ import quantstats as qs
252
+
253
+ aligned = align_return_series(portfolio_returns, benchmark_returns)
254
+ if aligned.sample_size < 2:
255
+ raise ValueError("At least 2 return periods are required for a tear sheet.")
256
+
257
+ portfolio_series = _ensure_datetime_index(
258
+ _to_series(aligned.portfolio_returns, "portfolio")
259
+ )
260
+ benchmark_series = (
261
+ _ensure_datetime_index(_to_series(aligned.benchmark_returns, "benchmark"))
262
+ if aligned.benchmark_returns is not None
263
+ else None
264
+ )
265
+
266
+ if output_path is None:
267
+ with NamedTemporaryFile(suffix=".html", delete=False) as tmp:
268
+ output = Path(tmp.name)
269
+ else:
270
+ output = Path(output_path).expanduser().resolve()
271
+ output.parent.mkdir(parents=True, exist_ok=True)
272
+
273
+ qs.reports.html(
274
+ portfolio_series,
275
+ benchmark=benchmark_series,
276
+ output=str(output),
277
+ title=title,
278
+ )
279
+ return str(output)
@@ -0,0 +1,362 @@
1
+ """Risk analytics and portfolio optimization utilities.
2
+
3
+ This module intentionally keeps risk assessment separate from valuation logic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from collections.abc import Sequence
10
+ from statistics import NormalDist
11
+
12
+ import numpy as np
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class RiskMetrics(BaseModel):
17
+ """Collection of risk metrics for a return series."""
18
+
19
+ sharpe_ratio: float | None = None
20
+ sortino_ratio: float | None = None
21
+ calmar_ratio: float | None = None
22
+ beta: float | None = None
23
+ annualized_return: float | None = None
24
+ annualized_volatility: float | None = None
25
+ value_at_risk_95: float | None = None
26
+ value_at_risk_99: float | None = None
27
+ conditional_value_at_risk_95: float | None = None
28
+ conditional_value_at_risk_99: float | None = None
29
+ max_drawdown: float | None = None
30
+ win_rate: float | None = None
31
+ quantstats_used: bool = False
32
+
33
+
34
+ class PortfolioResult(BaseModel):
35
+ """Portfolio return and risk calculation."""
36
+
37
+ expected_return: float
38
+ volatility: float
39
+ sharpe_ratio: float | None = None
40
+ weights: list[float]
41
+
42
+
43
+ class PortfolioOptimizationResult(BaseModel):
44
+ """Mean-variance optimization result."""
45
+
46
+ weights: list[float]
47
+ expected_return: float
48
+ volatility: float
49
+ sharpe_ratio: float | None = None
50
+ method: str
51
+ target_return: float | None = None
52
+
53
+
54
+ def sharpe_ratio(
55
+ returns: Sequence[float],
56
+ risk_free_rate: float = 0.0,
57
+ ) -> float:
58
+ """Sharpe ratio — risk-adjusted return."""
59
+
60
+ arr = np.array(returns, dtype=float)
61
+ if len(arr) < 2:
62
+ return 0.0
63
+ excess = arr - risk_free_rate
64
+ std = np.std(excess, ddof=1)
65
+ if std == 0:
66
+ return 0.0
67
+ return float(np.mean(excess) / std)
68
+
69
+
70
+ def sortino_ratio(
71
+ returns: Sequence[float],
72
+ risk_free_rate: float = 0.0,
73
+ target_return: float = 0.0,
74
+ ) -> float:
75
+ """Sortino ratio — downside-risk-adjusted return."""
76
+
77
+ arr = np.array(returns, dtype=float)
78
+ if len(arr) < 2:
79
+ return 0.0
80
+ downside = arr[arr < target_return] - target_return
81
+ if len(downside) == 0:
82
+ return float("inf") if np.mean(arr) > risk_free_rate else 0.0
83
+ downside_std = np.sqrt(np.mean(downside**2))
84
+ if downside_std == 0:
85
+ return 0.0
86
+ return float((np.mean(arr) - risk_free_rate) / downside_std)
87
+
88
+
89
+ def calculate_beta(
90
+ asset_returns: Sequence[float],
91
+ market_returns: Sequence[float],
92
+ ) -> float:
93
+ """Beta — systematic risk relative to the market."""
94
+
95
+ a = np.array(asset_returns, dtype=float)
96
+ m = np.array(market_returns, dtype=float)
97
+ if len(a) != len(m):
98
+ raise ValueError("Asset and market return series must have equal length.")
99
+ if len(a) < 2:
100
+ raise ValueError("At least 2 return periods required.")
101
+
102
+ cov_matrix = np.cov(a, m, ddof=1)
103
+ var_market = cov_matrix[1, 1]
104
+ if var_market == 0:
105
+ return 0.0
106
+ return float(cov_matrix[0, 1] / var_market)
107
+
108
+
109
+ def value_at_risk(
110
+ returns: Sequence[float],
111
+ confidence_level: float = 0.95,
112
+ method: str = "historical",
113
+ ) -> float:
114
+ """Value at Risk as a positive loss threshold."""
115
+
116
+ arr = np.array(returns, dtype=float)
117
+ if len(arr) == 0:
118
+ return 0.0
119
+
120
+ percentile = (1 - confidence_level) * 100
121
+ method_normalized = method.lower().strip()
122
+ if method_normalized == "gaussian":
123
+ mean = float(np.mean(arr))
124
+ std = float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0
125
+ z = NormalDist().inv_cdf(1 - confidence_level)
126
+ var = mean + z * std
127
+ return float(max(-var, 0.0))
128
+
129
+ var = np.percentile(arr, percentile)
130
+ return float(max(-var, 0.0))
131
+
132
+
133
+ def conditional_value_at_risk(
134
+ returns: Sequence[float],
135
+ confidence_level: float = 0.95,
136
+ method: str = "historical",
137
+ ) -> float:
138
+ """Conditional VaR (Expected Shortfall) as a positive expected tail loss."""
139
+
140
+ arr = np.array(returns, dtype=float)
141
+ if len(arr) == 0:
142
+ return 0.0
143
+
144
+ method_normalized = method.lower().strip()
145
+ if method_normalized == "gaussian":
146
+ mean = float(np.mean(arr))
147
+ std = float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0
148
+ alpha = 1 - confidence_level
149
+ if alpha <= 0 or std == 0:
150
+ return max(-mean, 0.0)
151
+ z = NormalDist().inv_cdf(alpha)
152
+ pdf = math.exp(-(z**2) / 2) / math.sqrt(2 * math.pi)
153
+ es = -(mean - std * (pdf / alpha))
154
+ return float(max(es, 0.0))
155
+
156
+ threshold = np.percentile(arr, (1 - confidence_level) * 100)
157
+ tail = arr[arr <= threshold]
158
+ if len(tail) == 0:
159
+ return value_at_risk(arr.tolist(), confidence_level, method="historical")
160
+ return float(max(-np.mean(tail), 0.0))
161
+
162
+
163
+ def max_drawdown(returns: Sequence[float]) -> float:
164
+ """Maximum drawdown from a return series."""
165
+
166
+ arr = np.array(returns, dtype=float)
167
+ if len(arr) == 0:
168
+ return 0.0
169
+ cumulative = np.cumprod(1 + arr)
170
+ running_max = np.maximum.accumulate(cumulative)
171
+ drawdowns = (running_max - cumulative) / np.maximum(running_max, 1e-12)
172
+ return float(np.max(drawdowns)) if len(drawdowns) > 0 else 0.0
173
+
174
+
175
+ def calmar_ratio(
176
+ returns: Sequence[float],
177
+ periods_per_year: int = 252,
178
+ ) -> float:
179
+ """Calmar ratio = annualized return / max drawdown."""
180
+
181
+ arr = np.array(returns, dtype=float)
182
+ if len(arr) < 2:
183
+ return 0.0
184
+ ann_ret = float((np.prod(1 + arr) ** (periods_per_year / len(arr))) - 1)
185
+ mdd = max_drawdown(arr.tolist())
186
+ if mdd == 0:
187
+ return 0.0
188
+ return ann_ret / mdd
189
+
190
+
191
+ def risk_metrics(
192
+ returns: Sequence[float],
193
+ market_returns: Sequence[float] | None = None,
194
+ risk_free_rate: float = 0.0,
195
+ periods_per_year: int = 252,
196
+ var_method: str = "historical",
197
+ ) -> RiskMetrics:
198
+ """Calculate a comprehensive set of risk metrics."""
199
+
200
+ arr = np.array(returns, dtype=float)
201
+ n = len(arr)
202
+ if n < 2:
203
+ return RiskMetrics()
204
+
205
+ ann_ret = float((np.prod(1 + arr) ** (periods_per_year / n)) - 1)
206
+ ann_vol = float(np.std(arr, ddof=1) * np.sqrt(periods_per_year))
207
+ win_rate = float(np.mean(arr > 0))
208
+
209
+ arr_list = arr.tolist()
210
+ result = RiskMetrics(
211
+ sharpe_ratio=sharpe_ratio(arr_list, risk_free_rate),
212
+ sortino_ratio=sortino_ratio(arr_list, risk_free_rate),
213
+ calmar_ratio=calmar_ratio(arr_list, periods_per_year=periods_per_year),
214
+ annualized_return=ann_ret,
215
+ annualized_volatility=ann_vol,
216
+ value_at_risk_95=value_at_risk(arr_list, 0.95, method=var_method),
217
+ value_at_risk_99=value_at_risk(arr_list, 0.99, method=var_method),
218
+ conditional_value_at_risk_95=conditional_value_at_risk(
219
+ arr_list, 0.95, method=var_method
220
+ ),
221
+ conditional_value_at_risk_99=conditional_value_at_risk(
222
+ arr_list, 0.99, method=var_method
223
+ ),
224
+ max_drawdown=max_drawdown(arr_list),
225
+ win_rate=win_rate,
226
+ )
227
+
228
+ if market_returns is not None and len(market_returns) == n:
229
+ result.beta = calculate_beta(arr_list, market_returns)
230
+
231
+ return result
232
+
233
+
234
+ def portfolio_return(
235
+ weights: Sequence[float],
236
+ returns: Sequence[float],
237
+ ) -> float:
238
+ """Expected portfolio return from weights and expected returns."""
239
+
240
+ w = np.array(weights, dtype=float)
241
+ r = np.array(returns, dtype=float)
242
+ if len(w) != len(r):
243
+ raise ValueError("weights and returns must have the same length.")
244
+ return float(np.dot(w, r))
245
+
246
+
247
+ def portfolio_volatility(
248
+ weights: Sequence[float],
249
+ cov_matrix: Sequence[Sequence[float]],
250
+ ) -> float:
251
+ """Portfolio volatility from weights and covariance matrix."""
252
+
253
+ w = np.array(weights, dtype=float)
254
+ cov = np.array(cov_matrix, dtype=float)
255
+ variance = float(w @ cov @ w)
256
+ return math.sqrt(max(variance, 0))
257
+
258
+
259
+ def portfolio_analysis(
260
+ weights: Sequence[float],
261
+ expected_returns: Sequence[float],
262
+ cov_matrix: Sequence[Sequence[float]],
263
+ risk_free_rate: float = 0.0,
264
+ ) -> PortfolioResult:
265
+ """Full portfolio return, volatility, and Sharpe ratio."""
266
+
267
+ ret = portfolio_return(weights, expected_returns)
268
+ vol = portfolio_volatility(weights, cov_matrix)
269
+ sr = (ret - risk_free_rate) / vol if vol > 0 else 0.0
270
+
271
+ return PortfolioResult(
272
+ expected_return=ret,
273
+ volatility=vol,
274
+ sharpe_ratio=sr,
275
+ weights=list(weights),
276
+ )
277
+
278
+
279
+ def optimize_portfolio_min_variance(
280
+ expected_returns: Sequence[float],
281
+ cov_matrix: Sequence[Sequence[float]],
282
+ target_return: float | None = None,
283
+ risk_free_rate: float = 0.0,
284
+ allow_short: bool = False,
285
+ ) -> PortfolioOptimizationResult:
286
+ """Solve a simple mean-variance portfolio optimization problem."""
287
+
288
+ mu = np.array(expected_returns, dtype=float)
289
+ cov = np.array(cov_matrix, dtype=float)
290
+ n = len(mu)
291
+ if n == 0 or cov.shape != (n, n):
292
+ raise ValueError("expected_returns and cov_matrix dimensions are inconsistent.")
293
+
294
+ inv_cov = np.linalg.pinv(cov)
295
+ ones = np.ones(n)
296
+
297
+ if target_return is None:
298
+ raw_weights = inv_cov @ ones
299
+ method = "minimum_variance"
300
+ else:
301
+ a = float(ones @ inv_cov @ ones)
302
+ b = float(ones @ inv_cov @ mu)
303
+ c = float(mu @ inv_cov @ mu)
304
+ det = a * c - b * b
305
+ if abs(det) < 1e-12:
306
+ raw_weights = inv_cov @ ones
307
+ method = "minimum_variance_fallback"
308
+ else:
309
+ lam = (c - b * target_return) / det
310
+ gam = (a * target_return - b) / det
311
+ raw_weights = inv_cov @ (lam * ones + gam * mu)
312
+ method = "minimum_variance_target_return"
313
+
314
+ if not allow_short:
315
+ raw_weights = np.clip(raw_weights, 0.0, None)
316
+
317
+ total = float(np.sum(raw_weights))
318
+ weights = (raw_weights / total) if total > 0 else np.full(n, 1.0 / n)
319
+
320
+ result = portfolio_analysis(
321
+ weights=weights.tolist(),
322
+ expected_returns=mu.tolist(),
323
+ cov_matrix=cov.tolist(),
324
+ risk_free_rate=risk_free_rate,
325
+ )
326
+ return PortfolioOptimizationResult(
327
+ weights=result.weights,
328
+ expected_return=result.expected_return,
329
+ volatility=result.volatility,
330
+ sharpe_ratio=result.sharpe_ratio,
331
+ method=method,
332
+ target_return=target_return,
333
+ )
334
+
335
+
336
+ def simulated_portfolio_scenarios(
337
+ periods: int = 252,
338
+ seed: int = 42,
339
+ ) -> dict[str, list[float]]:
340
+ """Generate deterministic simulated return scenarios for testing and demos."""
341
+
342
+ rng = np.random.default_rng(seed)
343
+ balanced = rng.normal(0.0006, 0.0100, periods)
344
+ concentrated_growth = rng.normal(0.0009, 0.0180, periods)
345
+ defensive_income = rng.normal(0.0003, 0.0060, periods)
346
+ high_volatility = rng.normal(0.0004, 0.0280, periods)
347
+
348
+ first = periods // 2
349
+ regime_shift = np.concatenate(
350
+ [
351
+ rng.normal(0.0009, 0.0090, first),
352
+ rng.normal(-0.0006, 0.0180, periods - first),
353
+ ]
354
+ )
355
+
356
+ return {
357
+ "balanced": balanced.tolist(),
358
+ "concentrated_growth": concentrated_growth.tolist(),
359
+ "defensive_income": defensive_income.tolist(),
360
+ "high_volatility": high_volatility.tolist(),
361
+ "regime_shift": regime_shift.tolist(),
362
+ }