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.
@@ -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