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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- 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
|
+
}
|