nlalpha-performance 0.6.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.
- nlalpha_performance/__init__.py +55 -0
- nlalpha_performance/basic_statistics.py +222 -0
- nlalpha_performance/drawdowns.py +307 -0
- nlalpha_performance/period_returns.py +382 -0
- nlalpha_performance/provider.py +256 -0
- nlalpha_performance-0.6.0.dist-info/METADATA +69 -0
- nlalpha_performance-0.6.0.dist-info/RECORD +10 -0
- nlalpha_performance-0.6.0.dist-info/WHEEL +4 -0
- nlalpha_performance-0.6.0.dist-info/entry_points.txt +2 -0
- nlalpha_performance-0.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Register the performance analytics provider through capability entry points."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _distribution_version
|
|
4
|
+
|
|
5
|
+
from nlalpha.core.capabilities.manifest import ProviderManifest
|
|
6
|
+
from nlalpha.core.capabilities.plugin.protocol import BaseCapabilityPlugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PerformancePlugin(BaseCapabilityPlugin):
|
|
10
|
+
"""Expose the performance analytics capability provider."""
|
|
11
|
+
|
|
12
|
+
plugin_name = "nlalpha_performance"
|
|
13
|
+
plugin_version = _distribution_version("nlalpha_performance")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._provider = None
|
|
18
|
+
|
|
19
|
+
def is_available(self) -> bool:
|
|
20
|
+
"""Check whether the empyrical dependency is installed."""
|
|
21
|
+
try:
|
|
22
|
+
__import__("empyrical")
|
|
23
|
+
return True
|
|
24
|
+
except ImportError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def get_provider(self):
|
|
28
|
+
"""Return the cached performance analytics provider."""
|
|
29
|
+
if self._provider is None:
|
|
30
|
+
from .provider import PerformanceProvider
|
|
31
|
+
self._provider = PerformanceProvider()
|
|
32
|
+
return self._provider
|
|
33
|
+
|
|
34
|
+
def get_manifest(self) -> dict:
|
|
35
|
+
"""Return the provider runtime manifest."""
|
|
36
|
+
if self.is_available():
|
|
37
|
+
provider = self.get_provider()
|
|
38
|
+
manifest = provider.manifest().to_dict()
|
|
39
|
+
manifest["domains"] = ["performance"]
|
|
40
|
+
return manifest
|
|
41
|
+
return ProviderManifest(
|
|
42
|
+
provider_id="performance",
|
|
43
|
+
provider_name="PerformancePlugin",
|
|
44
|
+
available=False,
|
|
45
|
+
unavailable_reason="empyrical-reloaded not installed",
|
|
46
|
+
domains=["performance"],
|
|
47
|
+
).to_dict()
|
|
48
|
+
|
|
49
|
+
def get_helpers(self) -> list[dict]:
|
|
50
|
+
"""Return the capability helper declarations."""
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
def get_prompt_path(self) -> str | None:
|
|
54
|
+
"""Return the optional capability prompt path."""
|
|
55
|
+
return None
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Calculate basic annualized statistics from net account equity moments."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from numbers import Real
|
|
5
|
+
|
|
6
|
+
from nlalpha.core.errors import StrategyRuntimeError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _error(field: str, message: str) -> StrategyRuntimeError:
|
|
10
|
+
"""Create a statistics input error."""
|
|
11
|
+
return StrategyRuntimeError(
|
|
12
|
+
message, details={"reason": "invalid_statistics_input", "field": field}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _finite_number(value: object, field: str) -> float:
|
|
17
|
+
"""Validate a finite real number."""
|
|
18
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
19
|
+
raise _error(field, f"{field} must be a finite number")
|
|
20
|
+
try:
|
|
21
|
+
number = float(value)
|
|
22
|
+
except (OverflowError, ValueError) as exc:
|
|
23
|
+
raise _error(field, f"{field} must be a finite number") from exc
|
|
24
|
+
if not math.isfinite(number):
|
|
25
|
+
raise _error(field, f"{field} must be a finite number")
|
|
26
|
+
return number
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _positive_annualization(value: object) -> int:
|
|
30
|
+
"""Validate an explicit observations-per-year count."""
|
|
31
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
32
|
+
raise _error("annualization", "annualization must be a positive integer")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _risk_free_rate(value: object) -> float:
|
|
37
|
+
"""Validate an annual risk-free rate."""
|
|
38
|
+
rate = _finite_number(value, "risk_free_rate")
|
|
39
|
+
if rate <= -1.0:
|
|
40
|
+
raise _error("risk_free_rate", "risk_free_rate must be greater than -1")
|
|
41
|
+
return rate
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_statistics_settings(execution: dict) -> dict:
|
|
45
|
+
"""Validate explicit annualization and annual risk-free execution settings."""
|
|
46
|
+
if not isinstance(execution, dict):
|
|
47
|
+
raise _error("execution", "execution must be a dictionary")
|
|
48
|
+
annualization = None
|
|
49
|
+
if "annualization" in execution:
|
|
50
|
+
annualization = _positive_annualization(execution["annualization"])
|
|
51
|
+
configured_rate = "risk_free_rate" in execution
|
|
52
|
+
return {
|
|
53
|
+
"annualization": annualization,
|
|
54
|
+
"annualization_source": "configured" if annualization is not None else "not_provided",
|
|
55
|
+
"risk_free_rate": _risk_free_rate(execution["risk_free_rate"]) if configured_rate else 0.0,
|
|
56
|
+
"risk_free_rate_source": "configured" if configured_rate else "engine_default",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validated_settings(settings: dict) -> dict:
|
|
61
|
+
"""Validate normalized settings and their source labels."""
|
|
62
|
+
if not isinstance(settings, dict):
|
|
63
|
+
raise _error("settings", "settings must be a dictionary")
|
|
64
|
+
for field in ("annualization", "annualization_source", "risk_free_rate", "risk_free_rate_source"):
|
|
65
|
+
if field not in settings:
|
|
66
|
+
raise _error(field, f"normalized settings must include {field}")
|
|
67
|
+
annualization = settings["annualization"]
|
|
68
|
+
if annualization is not None:
|
|
69
|
+
annualization = _positive_annualization(annualization)
|
|
70
|
+
annualization_source = settings["annualization_source"]
|
|
71
|
+
expected_source = "configured" if annualization is not None else "not_provided"
|
|
72
|
+
if annualization_source != expected_source:
|
|
73
|
+
raise _error("annualization_source", "annualization_source must match annualization")
|
|
74
|
+
rate = _risk_free_rate(settings["risk_free_rate"])
|
|
75
|
+
rate_source = settings["risk_free_rate_source"]
|
|
76
|
+
if rate_source not in ("configured", "engine_default"):
|
|
77
|
+
raise _error("risk_free_rate_source", "risk_free_rate_source is invalid")
|
|
78
|
+
if rate_source == "engine_default" and rate != 0.0:
|
|
79
|
+
raise _error("risk_free_rate_source", "the engine default risk-free rate must be zero")
|
|
80
|
+
return {
|
|
81
|
+
"annualization": annualization,
|
|
82
|
+
"annualization_source": annualization_source,
|
|
83
|
+
"risk_free_rate": rate,
|
|
84
|
+
"risk_free_rate_source": rate_source,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ReturnMoments:
|
|
89
|
+
"""Accumulate simple return moments from successive net equity observations."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, initial_equity: float):
|
|
92
|
+
self._previous_equity = _finite_number(initial_equity, "initial_equity")
|
|
93
|
+
self.count = 0
|
|
94
|
+
self.mean = 0.0
|
|
95
|
+
self.m2 = 0.0
|
|
96
|
+
self.invalid_reason = "non_positive_equity" if self._previous_equity <= 0 else None
|
|
97
|
+
|
|
98
|
+
def observe(self, equity: float) -> None:
|
|
99
|
+
"""Observe one account equity and retain only finite return moments."""
|
|
100
|
+
current_equity = _finite_number(equity, "equity")
|
|
101
|
+
if self.invalid_reason is not None:
|
|
102
|
+
return
|
|
103
|
+
if current_equity <= 0:
|
|
104
|
+
self.invalid_reason = "non_positive_equity"
|
|
105
|
+
return
|
|
106
|
+
period_return = current_equity / self._previous_equity - 1.0
|
|
107
|
+
count = self.count + 1
|
|
108
|
+
delta = period_return - self.mean
|
|
109
|
+
mean = self.mean + delta / count
|
|
110
|
+
m2 = self.m2 + delta * (period_return - mean)
|
|
111
|
+
if not all(math.isfinite(value) for value in (period_return, mean, m2)):
|
|
112
|
+
self.invalid_reason = "numerical_overflow"
|
|
113
|
+
return
|
|
114
|
+
if m2 < 0:
|
|
115
|
+
raise _error("return_m2", "return_m2 must be non-negative")
|
|
116
|
+
self.count = count
|
|
117
|
+
self.mean = mean
|
|
118
|
+
self.m2 = m2
|
|
119
|
+
self._previous_equity = current_equity
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def calculate_basic_statistics(
|
|
123
|
+
*,
|
|
124
|
+
initial_equity: float,
|
|
125
|
+
final_equity: float,
|
|
126
|
+
return_count: int,
|
|
127
|
+
return_mean: float,
|
|
128
|
+
return_m2: float,
|
|
129
|
+
settings: dict,
|
|
130
|
+
invalid_reason: str | None = None,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Calculate observation-based annual return, sample annual volatility, and Sharpe."""
|
|
133
|
+
initial = _finite_number(initial_equity, "initial_equity")
|
|
134
|
+
final = _finite_number(final_equity, "final_equity")
|
|
135
|
+
mean = _finite_number(return_mean, "return_mean")
|
|
136
|
+
m2 = _finite_number(return_m2, "return_m2")
|
|
137
|
+
if isinstance(return_count, bool) or not isinstance(return_count, int) or return_count < 0:
|
|
138
|
+
raise _error("return_count", "return_count must be a non-negative integer")
|
|
139
|
+
if m2 < 0:
|
|
140
|
+
raise _error("return_m2", "return_m2 must be non-negative")
|
|
141
|
+
if invalid_reason is not None and (not isinstance(invalid_reason, str) or not invalid_reason):
|
|
142
|
+
raise _error("invalid_reason", "invalid_reason must be a non-empty string or None")
|
|
143
|
+
normalized = _validated_settings(settings)
|
|
144
|
+
annualization = normalized["annualization"]
|
|
145
|
+
rate = normalized["risk_free_rate"]
|
|
146
|
+
period_rate = None
|
|
147
|
+
if annualization is not None:
|
|
148
|
+
try:
|
|
149
|
+
period_rate = math.expm1(math.log1p(rate) / annualization)
|
|
150
|
+
except OverflowError:
|
|
151
|
+
pass
|
|
152
|
+
values = {"annual_return": None, "annual_volatility": None, "sharpe_ratio": None}
|
|
153
|
+
unavailable = {}
|
|
154
|
+
diagnostics = {
|
|
155
|
+
"return_count": return_count,
|
|
156
|
+
"annualization": annualization,
|
|
157
|
+
"annualization_source": normalized["annualization_source"],
|
|
158
|
+
"annual_risk_free_rate": rate,
|
|
159
|
+
"risk_free_rate_source": normalized["risk_free_rate_source"],
|
|
160
|
+
"period_risk_free_rate": period_rate,
|
|
161
|
+
"variance_ddof": 1,
|
|
162
|
+
"returns_basis": "net_account_equity_simple_returns",
|
|
163
|
+
"annualization_basis": "observations_per_year_no_calendar_resampling",
|
|
164
|
+
"unavailable": unavailable,
|
|
165
|
+
}
|
|
166
|
+
reason = invalid_reason
|
|
167
|
+
if reason is None and (initial <= 0 or final <= 0):
|
|
168
|
+
reason = "non_positive_equity"
|
|
169
|
+
if reason is None and annualization is None:
|
|
170
|
+
reason = "annualization_not_provided"
|
|
171
|
+
if reason is None and return_count == 0:
|
|
172
|
+
reason = "insufficient_observations"
|
|
173
|
+
if reason is not None:
|
|
174
|
+
unavailable.update({metric: reason for metric in values})
|
|
175
|
+
else:
|
|
176
|
+
try:
|
|
177
|
+
annual_return = math.expm1(
|
|
178
|
+
(math.log(final) - math.log(initial)) * annualization / return_count
|
|
179
|
+
)
|
|
180
|
+
if math.isfinite(annual_return):
|
|
181
|
+
values["annual_return"] = annual_return
|
|
182
|
+
else:
|
|
183
|
+
unavailable["annual_return"] = "numerical_overflow"
|
|
184
|
+
except OverflowError:
|
|
185
|
+
unavailable["annual_return"] = "numerical_overflow"
|
|
186
|
+
if return_count < 2:
|
|
187
|
+
unavailable.update({
|
|
188
|
+
metric: "insufficient_observations"
|
|
189
|
+
for metric in ("annual_volatility", "sharpe_ratio")
|
|
190
|
+
})
|
|
191
|
+
elif m2 == 0:
|
|
192
|
+
values["annual_volatility"] = 0.0
|
|
193
|
+
unavailable["sharpe_ratio"] = "zero_volatility"
|
|
194
|
+
else:
|
|
195
|
+
try:
|
|
196
|
+
deviation = math.sqrt(m2 / (return_count - 1))
|
|
197
|
+
annual_factor = math.sqrt(annualization)
|
|
198
|
+
volatility = deviation * annual_factor
|
|
199
|
+
if math.isfinite(volatility):
|
|
200
|
+
values["annual_volatility"] = volatility
|
|
201
|
+
else:
|
|
202
|
+
unavailable["annual_volatility"] = "numerical_overflow"
|
|
203
|
+
if deviation == 0:
|
|
204
|
+
unavailable["sharpe_ratio"] = "zero_volatility"
|
|
205
|
+
elif period_rate is None:
|
|
206
|
+
unavailable["sharpe_ratio"] = "numerical_overflow"
|
|
207
|
+
else:
|
|
208
|
+
sharpe = (mean - period_rate) / deviation * annual_factor
|
|
209
|
+
if math.isfinite(sharpe):
|
|
210
|
+
values["sharpe_ratio"] = sharpe
|
|
211
|
+
else:
|
|
212
|
+
unavailable["sharpe_ratio"] = "numerical_overflow"
|
|
213
|
+
except OverflowError:
|
|
214
|
+
unavailable.update({
|
|
215
|
+
metric: "numerical_overflow"
|
|
216
|
+
for metric in ("annual_volatility", "sharpe_ratio")
|
|
217
|
+
})
|
|
218
|
+
return {
|
|
219
|
+
"performance": {"annual_return": values["annual_return"]},
|
|
220
|
+
"risk": {metric: values[metric] for metric in ("annual_volatility", "sharpe_ratio")},
|
|
221
|
+
"diagnostics": diagnostics,
|
|
222
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Track drawdown episodes from completed-close net account equity observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from nlalpha.core.telemetry import get_meter, start_span
|
|
9
|
+
|
|
10
|
+
from .basic_statistics import _error, _finite_number
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DRAWDOWN_COLUMNS = [
|
|
14
|
+
"episode_id", "status", "peak_equity", "peak_timestamp_ns", "peak_observation_index",
|
|
15
|
+
"peak_is_initial", "start_timestamp_ns", "start_observation_index", "trough_equity",
|
|
16
|
+
"trough_timestamp_ns", "trough_observation_index", "recovery_equity", "recovery_timestamp_ns",
|
|
17
|
+
"recovery_observation_index", "end_timestamp_ns", "end_observation_index", "depth",
|
|
18
|
+
"decline_duration_ns", "recovery_duration_ns", "duration_ns", "observed_duration_ns",
|
|
19
|
+
"duration_is_lower_bound", "decline_observations", "recovery_observations", "duration_observations",
|
|
20
|
+
]
|
|
21
|
+
_SUMMARY_FIELDS = {
|
|
22
|
+
"episode_count", "recovered_episode_count", "unrecovered_episode_count", "deepest", "longest", "current",
|
|
23
|
+
}
|
|
24
|
+
_evidence_counter = get_meter().create_counter(
|
|
25
|
+
"nlalpha.empyrical.drawdown_evidence", description="Drawdown evidence envelopes built"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _integer(value: object, field: str, *, nullable: bool = False) -> int | None:
|
|
30
|
+
"""Validate exact integer timestamps and counts."""
|
|
31
|
+
if nullable and value is None:
|
|
32
|
+
return None
|
|
33
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
34
|
+
raise _error(field, f"{field} must be an integer")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Episode:
|
|
40
|
+
"""Maintain one peak-to-recovery or peak-to-sample-end drawdown."""
|
|
41
|
+
|
|
42
|
+
episode_id: int
|
|
43
|
+
peak_equity: float
|
|
44
|
+
peak_timestamp_ns: int | None
|
|
45
|
+
peak_observation_index: int
|
|
46
|
+
start_timestamp_ns: int
|
|
47
|
+
start_observation_index: int
|
|
48
|
+
trough_equity: float
|
|
49
|
+
trough_timestamp_ns: int
|
|
50
|
+
trough_observation_index: int
|
|
51
|
+
end_timestamp_ns: int
|
|
52
|
+
end_observation_index: int
|
|
53
|
+
depth: float
|
|
54
|
+
recovery_equity: float | None = None
|
|
55
|
+
recovery_timestamp_ns: int | None = None
|
|
56
|
+
recovery_observation_index: int | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def observed_duration_ns(self) -> int:
|
|
60
|
+
"""Return the known elapsed duration for this episode."""
|
|
61
|
+
start = self.start_timestamp_ns if self.peak_timestamp_ns is None else self.peak_timestamp_ns
|
|
62
|
+
return self.end_timestamp_ns - start
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def duration_observations(self) -> int:
|
|
66
|
+
"""Return the number of observation intervals since its reference peak."""
|
|
67
|
+
return self.end_observation_index - self.peak_observation_index
|
|
68
|
+
|
|
69
|
+
def row(self) -> dict:
|
|
70
|
+
"""Materialize one episode using exact observation timestamps and indices."""
|
|
71
|
+
initial = self.peak_timestamp_ns is None
|
|
72
|
+
recovered = self.recovery_timestamp_ns is not None
|
|
73
|
+
return {
|
|
74
|
+
"episode_id": self.episode_id,
|
|
75
|
+
"status": "recovered" if recovered else "unrecovered",
|
|
76
|
+
"peak_equity": self.peak_equity,
|
|
77
|
+
"peak_timestamp_ns": self.peak_timestamp_ns,
|
|
78
|
+
"peak_observation_index": self.peak_observation_index,
|
|
79
|
+
"peak_is_initial": initial,
|
|
80
|
+
"start_timestamp_ns": self.start_timestamp_ns,
|
|
81
|
+
"start_observation_index": self.start_observation_index,
|
|
82
|
+
"trough_equity": self.trough_equity,
|
|
83
|
+
"trough_timestamp_ns": self.trough_timestamp_ns,
|
|
84
|
+
"trough_observation_index": self.trough_observation_index,
|
|
85
|
+
"recovery_equity": self.recovery_equity,
|
|
86
|
+
"recovery_timestamp_ns": self.recovery_timestamp_ns,
|
|
87
|
+
"recovery_observation_index": self.recovery_observation_index,
|
|
88
|
+
"end_timestamp_ns": self.end_timestamp_ns,
|
|
89
|
+
"end_observation_index": self.end_observation_index,
|
|
90
|
+
"depth": self.depth,
|
|
91
|
+
"decline_duration_ns": None if initial else self.trough_timestamp_ns - self.peak_timestamp_ns,
|
|
92
|
+
"recovery_duration_ns": self.recovery_timestamp_ns - self.trough_timestamp_ns if recovered else None,
|
|
93
|
+
"duration_ns": None if initial else self.end_timestamp_ns - self.peak_timestamp_ns,
|
|
94
|
+
"observed_duration_ns": self.observed_duration_ns,
|
|
95
|
+
"duration_is_lower_bound": initial,
|
|
96
|
+
"decline_observations": self.trough_observation_index - self.peak_observation_index,
|
|
97
|
+
"recovery_observations": self.recovery_observation_index - self.trough_observation_index if recovered else None,
|
|
98
|
+
"duration_observations": self.duration_observations,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _longer(candidate: _Episode, previous: _Episode | None) -> bool:
|
|
103
|
+
"""Select the longest observed duration with deterministic tie rules."""
|
|
104
|
+
return previous is None or (
|
|
105
|
+
candidate.observed_duration_ns, candidate.duration_observations, -candidate.episode_id
|
|
106
|
+
) > (
|
|
107
|
+
previous.observed_duration_ns, previous.duration_observations, -previous.episode_id
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class DrawdownTracker:
|
|
112
|
+
"""Track peaks, troughs and recoveries while retaining bounded summary state."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, initial_equity: float, *, record_history: bool = True):
|
|
115
|
+
self._peak_equity = _finite_number(initial_equity, "initial_equity")
|
|
116
|
+
if self._peak_equity <= 0:
|
|
117
|
+
raise _error("initial_equity", "initial_equity must be greater than zero")
|
|
118
|
+
if not isinstance(record_history, bool):
|
|
119
|
+
raise _error("record_history", "record_history must be a boolean")
|
|
120
|
+
self._peak_timestamp_ns = None
|
|
121
|
+
self._peak_observation_index = 0
|
|
122
|
+
self._observation_count = 0
|
|
123
|
+
self._last_timestamp_ns = None
|
|
124
|
+
self._episode_count = 0
|
|
125
|
+
self._recovered_count = 0
|
|
126
|
+
self._current = None
|
|
127
|
+
self._deepest = None
|
|
128
|
+
self._longest_recovered = None
|
|
129
|
+
self._history = [] if record_history else None
|
|
130
|
+
|
|
131
|
+
def observe(self, timestamp_ns: int, equity: float) -> None:
|
|
132
|
+
"""Observe one strictly ordered completed-close equity value."""
|
|
133
|
+
timestamp = _integer(timestamp_ns, "timestamp_ns")
|
|
134
|
+
if self._last_timestamp_ns is not None and timestamp <= self._last_timestamp_ns:
|
|
135
|
+
raise _error("timestamp_ns", "observation timestamps must be strictly increasing")
|
|
136
|
+
equity = _finite_number(equity, "equity")
|
|
137
|
+
underwater = equity < self._peak_equity
|
|
138
|
+
depth = 0.0
|
|
139
|
+
if underwater:
|
|
140
|
+
depth = 1.0 - equity / self._peak_equity if equity < 0 else (self._peak_equity - equity) / self._peak_equity
|
|
141
|
+
if not math.isfinite(depth):
|
|
142
|
+
raise _error("depth", "drawdown depth must be finite")
|
|
143
|
+
index = self._observation_count + 1
|
|
144
|
+
if underwater:
|
|
145
|
+
if self._current is None:
|
|
146
|
+
self._episode_count += 1
|
|
147
|
+
self._current = _Episode(
|
|
148
|
+
self._episode_count, self._peak_equity, self._peak_timestamp_ns,
|
|
149
|
+
self._peak_observation_index, timestamp, index, equity, timestamp, index,
|
|
150
|
+
timestamp, index, depth,
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
self._current.end_timestamp_ns = timestamp
|
|
154
|
+
self._current.end_observation_index = index
|
|
155
|
+
if equity < self._current.trough_equity:
|
|
156
|
+
self._current.trough_equity = equity
|
|
157
|
+
self._current.trough_timestamp_ns = timestamp
|
|
158
|
+
self._current.trough_observation_index = index
|
|
159
|
+
self._current.depth = depth
|
|
160
|
+
if self._deepest is None or self._current.depth > self._deepest.depth:
|
|
161
|
+
self._deepest = self._current
|
|
162
|
+
else:
|
|
163
|
+
if self._current is not None:
|
|
164
|
+
self._current.end_timestamp_ns = timestamp
|
|
165
|
+
self._current.end_observation_index = index
|
|
166
|
+
self._current.recovery_equity = equity
|
|
167
|
+
self._current.recovery_timestamp_ns = timestamp
|
|
168
|
+
self._current.recovery_observation_index = index
|
|
169
|
+
self._recovered_count += 1
|
|
170
|
+
if _longer(self._current, self._longest_recovered):
|
|
171
|
+
self._longest_recovered = self._current
|
|
172
|
+
if self._history is not None:
|
|
173
|
+
self._history.append(self._current)
|
|
174
|
+
self._current = None
|
|
175
|
+
self._peak_equity = equity
|
|
176
|
+
self._peak_timestamp_ns = timestamp
|
|
177
|
+
self._peak_observation_index = index
|
|
178
|
+
self._observation_count = index
|
|
179
|
+
self._last_timestamp_ns = timestamp
|
|
180
|
+
|
|
181
|
+
def finish(self) -> tuple[dict, list[dict]]:
|
|
182
|
+
"""Return an independent summary and recorded rows without mutating tracker state."""
|
|
183
|
+
longest = self._longest_recovered
|
|
184
|
+
if self._current is not None and _longer(self._current, longest):
|
|
185
|
+
longest = self._current
|
|
186
|
+
summary = {
|
|
187
|
+
"episode_count": self._episode_count,
|
|
188
|
+
"recovered_episode_count": self._recovered_count,
|
|
189
|
+
"unrecovered_episode_count": int(self._current is not None),
|
|
190
|
+
"deepest": self._deepest.row() if self._deepest is not None else None,
|
|
191
|
+
"longest": longest.row() if longest is not None else None,
|
|
192
|
+
"current": self._current.row() if self._current is not None else None,
|
|
193
|
+
}
|
|
194
|
+
rows = []
|
|
195
|
+
if self._history is not None:
|
|
196
|
+
rows = [episode.row() for episode in self._history]
|
|
197
|
+
if self._current is not None:
|
|
198
|
+
rows.append(self._current.row())
|
|
199
|
+
return summary, rows
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_drawdown_evidence(
|
|
203
|
+
*,
|
|
204
|
+
summary: dict,
|
|
205
|
+
rows: list[dict],
|
|
206
|
+
record_mode: str,
|
|
207
|
+
source: str,
|
|
208
|
+
schema_id: str,
|
|
209
|
+
observation_count: int,
|
|
210
|
+
first_observation_timestamp_ns: int | None,
|
|
211
|
+
last_observation_timestamp_ns: int | None,
|
|
212
|
+
initial_reference_timestamp_ns: int | None,
|
|
213
|
+
) -> tuple[dict, dict]:
|
|
214
|
+
"""Build a drawdown report envelope and explain its sampling and unavailable values."""
|
|
215
|
+
if not isinstance(summary, dict) or set(summary) != _SUMMARY_FIELDS:
|
|
216
|
+
raise _error("summary", "drawdown summary must contain the declared summary fields")
|
|
217
|
+
if not isinstance(rows, list):
|
|
218
|
+
raise _error("rows", "drawdown rows must be a list")
|
|
219
|
+
if not isinstance(record_mode, str) or record_mode not in {"full", "summary"}:
|
|
220
|
+
raise _error("record_mode", "record_mode must be full or summary")
|
|
221
|
+
for field, value in (("source", source), ("schema_id", schema_id)):
|
|
222
|
+
if not isinstance(value, str) or not value or value != value.strip():
|
|
223
|
+
raise _error(field, f"{field} must be a non-empty trimmed string")
|
|
224
|
+
count = _integer(observation_count, "observation_count")
|
|
225
|
+
first = _integer(first_observation_timestamp_ns, "first_observation_timestamp_ns", nullable=True)
|
|
226
|
+
last = _integer(last_observation_timestamp_ns, "last_observation_timestamp_ns", nullable=True)
|
|
227
|
+
reference = _integer(initial_reference_timestamp_ns, "initial_reference_timestamp_ns", nullable=True)
|
|
228
|
+
if count < 0:
|
|
229
|
+
raise _error("observation_count", "observation_count must be non-negative")
|
|
230
|
+
if count == 0 and (first is not None or last is not None):
|
|
231
|
+
raise _error("observation_count", "empty observations require null observation timestamps")
|
|
232
|
+
if count > 0 and (first is None or last is None or first > last or (count == 1 and first != last) or (count > 1 and first == last)):
|
|
233
|
+
raise _error("observation_timestamps", "observation count must agree with ordered timestamp bounds")
|
|
234
|
+
for field in ("episode_count", "recovered_episode_count", "unrecovered_episode_count"):
|
|
235
|
+
if _integer(summary[field], field) < 0:
|
|
236
|
+
raise _error(field, f"{field} must be non-negative")
|
|
237
|
+
episodes = summary["episode_count"]
|
|
238
|
+
unrecovered = summary["unrecovered_episode_count"]
|
|
239
|
+
if episodes != summary["recovered_episode_count"] + unrecovered or unrecovered > 1 or episodes > count:
|
|
240
|
+
raise _error("summary", "drawdown episode counts are inconsistent")
|
|
241
|
+
for field in ("deepest", "longest", "current"):
|
|
242
|
+
row = summary[field]
|
|
243
|
+
expected = bool(unrecovered) if field == "current" else bool(episodes)
|
|
244
|
+
if expected != (row is not None) or (row is not None and (not isinstance(row, dict) or set(row) != set(DRAWDOWN_COLUMNS))):
|
|
245
|
+
raise _error(field, f"{field} must agree with episode counts and declared row fields")
|
|
246
|
+
if record_mode == "summary" and rows:
|
|
247
|
+
raise _error("rows", "summary mode must not contain recorded drawdown rows")
|
|
248
|
+
if record_mode == "full" and len(rows) != episodes:
|
|
249
|
+
raise _error("rows", "recorded drawdown row count must match episode_count")
|
|
250
|
+
unavailable = {key: "no_equity_observations" for key in ("max_drawdown", "drawdowns")} if count == 0 else {}
|
|
251
|
+
with start_span("empyrical.build_drawdown_evidence", attributes={"observation_count": count, "episode_count": episodes}):
|
|
252
|
+
diagnostics = {
|
|
253
|
+
"sampling": "completed_bar_close_net_equity",
|
|
254
|
+
"fee_treatment": "already_reflected_in_net_equity",
|
|
255
|
+
"timestamp_unit": "ns",
|
|
256
|
+
"timestamp_timezone": "UTC",
|
|
257
|
+
"timestamp_columns": [field for field in DRAWDOWN_COLUMNS if field.endswith("timestamp_ns")],
|
|
258
|
+
"depth_unit": "proportion",
|
|
259
|
+
"depth_definition": "(peak_equity-trough_equity)/peak_equity",
|
|
260
|
+
"peak_rule": "most_recent_greater_or_equal_equity",
|
|
261
|
+
"trough_rule": "earliest_equal_trough_only_strictly_lower_equity_updates",
|
|
262
|
+
"episode_start_rule": "first_equity_strictly_below_high_water_mark",
|
|
263
|
+
"recovery_rule": "first_observation_greater_or_equal_episode_peak_equity",
|
|
264
|
+
"deepest_selection": "largest_depth_then_earlier_episode_id",
|
|
265
|
+
"longest_selection": "largest_observed_duration_ns_then_duration_observations_then_earlier_episode_id",
|
|
266
|
+
"longest_duration_basis": "observed_duration_not_unknown_total_duration",
|
|
267
|
+
"initial_peak_duration_rule": "unknown_total_duration_observed_duration_is_lower_bound_from_first_underwater_observation",
|
|
268
|
+
"observed_peak_duration_rule": "duration_from_observed_peak_to_recovery_or_sample_end",
|
|
269
|
+
"observation_interval_rule": "index_difference_initial_reference_to_first_observation_counts_as_one_interval",
|
|
270
|
+
"initial_reference_timestamp_role": "informational_only_not_a_duration_endpoint",
|
|
271
|
+
"nullable_field_reasons": {
|
|
272
|
+
"peak_timestamp_ns": "initial_capital_has_no_economic_timestamp",
|
|
273
|
+
"decline_duration_ns": "initial_peak_has_unknown_economic_start",
|
|
274
|
+
"duration_ns": "initial_peak_has_unknown_economic_start",
|
|
275
|
+
"recovery_equity": "episode_unrecovered_at_sample_end",
|
|
276
|
+
"recovery_timestamp_ns": "episode_unrecovered_at_sample_end",
|
|
277
|
+
"recovery_observation_index": "episode_unrecovered_at_sample_end",
|
|
278
|
+
"recovery_duration_ns": "episode_unrecovered_at_sample_end",
|
|
279
|
+
"recovery_observations": "episode_unrecovered_at_sample_end",
|
|
280
|
+
"initial_reference_timestamp_ns": "reference_timestamp_not_provided",
|
|
281
|
+
"first_observation_timestamp_ns": "no_equity_observations",
|
|
282
|
+
"last_observation_timestamp_ns": "no_equity_observations",
|
|
283
|
+
"deepest": "no_drawdown_episodes_or_no_equity_observations",
|
|
284
|
+
"longest": "no_drawdown_episodes_or_no_equity_observations",
|
|
285
|
+
"current": "no_active_drawdown_episode",
|
|
286
|
+
},
|
|
287
|
+
"observation_count": count,
|
|
288
|
+
"first_observation_timestamp_ns": first,
|
|
289
|
+
"last_observation_timestamp_ns": last,
|
|
290
|
+
"initial_reference_timestamp_ns": reference,
|
|
291
|
+
"record_mode": record_mode,
|
|
292
|
+
"unavailable": unavailable,
|
|
293
|
+
}
|
|
294
|
+
status = "unavailable" if count == 0 else ("available" if record_mode == "full" else "not_recorded")
|
|
295
|
+
reason = "no_equity_observations" if count == 0 else (None if record_mode == "full" else "record_mode_summary")
|
|
296
|
+
report = {
|
|
297
|
+
"schema_id": schema_id,
|
|
298
|
+
"source": source,
|
|
299
|
+
"status": status,
|
|
300
|
+
"reason": reason,
|
|
301
|
+
"row_count": None if count == 0 else episodes,
|
|
302
|
+
"columns": list(DRAWDOWN_COLUMNS),
|
|
303
|
+
"rows": rows,
|
|
304
|
+
"metadata": dict(diagnostics),
|
|
305
|
+
}
|
|
306
|
+
_evidence_counter.add(1, {"source": source, "record_mode": record_mode})
|
|
307
|
+
return report, diagnostics
|