trialgate 0.1.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.
trialgate/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """trialgate: an anti-overfitting validation gate for backtested strategies.
2
+
3
+ A strategy earns belief by surviving verification, not by looking good
4
+ in-sample. This package provides the mechanical parts of a six-gate
5
+ qualification standard: an append-only trial registry, CSCV/PBO and
6
+ Deflated Sharpe Ratio statistics, a single-use holdout lock, and threshold
7
+ gates that combine them into a verdict.
8
+ """
9
+
10
+ from trialgate.gates import (
11
+ DEFAULT_MAX_PBO,
12
+ DEFAULT_MIN_DSR,
13
+ DEFAULT_MIN_OBSERVATIONS,
14
+ GateCheck,
15
+ GateReport,
16
+ evaluate_gates,
17
+ )
18
+ from trialgate.holdout import (
19
+ HoldoutState,
20
+ HoldoutViolationError,
21
+ initialize_holdout,
22
+ load_holdout,
23
+ require_data_allowed,
24
+ spend_holdout,
25
+ )
26
+ from trialgate.registry import (
27
+ TrialRecord,
28
+ TrialRegistryError,
29
+ append_trial,
30
+ config_hash_for,
31
+ load_trials,
32
+ trial_count,
33
+ )
34
+ from trialgate.validation import (
35
+ DeflatedSharpeResult,
36
+ PBOResult,
37
+ ValidationInputError,
38
+ deflated_sharpe_ratio,
39
+ non_annualized_sharpe_variance,
40
+ probability_of_backtest_overfitting,
41
+ sharpe_ratio,
42
+ )
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "DEFAULT_MAX_PBO",
48
+ "DEFAULT_MIN_DSR",
49
+ "DEFAULT_MIN_OBSERVATIONS",
50
+ "DeflatedSharpeResult",
51
+ "GateCheck",
52
+ "GateReport",
53
+ "HoldoutState",
54
+ "HoldoutViolationError",
55
+ "PBOResult",
56
+ "TrialRecord",
57
+ "TrialRegistryError",
58
+ "ValidationInputError",
59
+ "append_trial",
60
+ "config_hash_for",
61
+ "deflated_sharpe_ratio",
62
+ "evaluate_gates",
63
+ "initialize_holdout",
64
+ "load_holdout",
65
+ "load_trials",
66
+ "non_annualized_sharpe_variance",
67
+ "probability_of_backtest_overfitting",
68
+ "require_data_allowed",
69
+ "sharpe_ratio",
70
+ "spend_holdout",
71
+ "trial_count",
72
+ ]
trialgate/gates.py ADDED
@@ -0,0 +1,99 @@
1
+ """Threshold gates and the combined qualification report.
2
+
3
+ The six-gate qualification standard (numbering follows the reference
4
+ deployment, a live crypto signal system):
5
+
6
+ 1. Trial registry (structural) -> trialgate.registry
7
+ 2. Data floor (statistical) -> judged here
8
+ 3. PBO <= 0.05 (statistical) -> computed by trialgate.validation, judged here
9
+ 4. DSR >= 0.95 (statistical) -> computed by trialgate.validation, judged here
10
+ 5. Single-use holdout (structural) -> trialgate.holdout
11
+ 6. Paper trading with measured real costs (procedural) -> out of library
12
+ scope on purpose: it is a live measurement over months, not a computation.
13
+
14
+ Passing every gate is a NECESSARY condition for trusting a backtest, not a
15
+ profit guarantee: in a verified 2022 case study the least-overfit strategy
16
+ still lost ~35% in two months.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ DEFAULT_MIN_OBSERVATIONS = 1000
24
+ DEFAULT_MAX_PBO = 0.05
25
+ DEFAULT_MIN_DSR = 0.95
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class GateCheck:
30
+ """One threshold verdict."""
31
+
32
+ gate: str
33
+ passed: bool
34
+ observed: float
35
+ threshold: float
36
+ comparison: str
37
+
38
+ def describe(self) -> str:
39
+ status = "PASS" if self.passed else "FAIL"
40
+ return f"{status} {self.gate}: {self.observed:g} {self.comparison} {self.threshold:g}"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class GateReport:
45
+ """Combined verdict over the statistical gates (2-4)."""
46
+
47
+ checks: tuple[GateCheck, ...]
48
+
49
+ @property
50
+ def passed(self) -> bool:
51
+ return all(check.passed for check in self.checks)
52
+
53
+ def summary(self) -> str:
54
+ lines = [check.describe() for check in self.checks]
55
+ lines.append("QUALIFIED" if self.passed else "NOT QUALIFIED")
56
+ return "\n".join(lines)
57
+
58
+
59
+ def evaluate_gates(
60
+ *,
61
+ observations: int,
62
+ pbo: float,
63
+ deflated_sharpe: float,
64
+ min_observations: int = DEFAULT_MIN_OBSERVATIONS,
65
+ max_pbo: float = DEFAULT_MAX_PBO,
66
+ min_dsr: float = DEFAULT_MIN_DSR,
67
+ ) -> GateReport:
68
+ """Judge the statistical gates (2-4) against their thresholds.
69
+
70
+ Feed the honest numbers: ``observations`` from the actual data span,
71
+ ``pbo`` from probability_of_backtest_overfitting() over ALL registered
72
+ trials, ``deflated_sharpe`` from deflated_sharpe_ratio() using the
73
+ registry's full trial count. Flattering inputs void the report.
74
+ """
75
+
76
+ checks = (
77
+ GateCheck(
78
+ gate="data floor (gate 2)",
79
+ passed=observations >= min_observations,
80
+ observed=float(observations),
81
+ threshold=float(min_observations),
82
+ comparison=">=",
83
+ ),
84
+ GateCheck(
85
+ gate="PBO (gate 3)",
86
+ passed=pbo <= max_pbo,
87
+ observed=pbo,
88
+ threshold=max_pbo,
89
+ comparison="<=",
90
+ ),
91
+ GateCheck(
92
+ gate="DSR (gate 4)",
93
+ passed=deflated_sharpe >= min_dsr,
94
+ observed=deflated_sharpe,
95
+ threshold=min_dsr,
96
+ comparison=">=",
97
+ ),
98
+ )
99
+ return GateReport(checks=checks)
trialgate/holdout.py ADDED
@@ -0,0 +1,154 @@
1
+ """Single-use final holdout lock: iterated out-of-sample is not out-of-sample.
2
+
3
+ Gate 5 of the six-gate qualification standard. The most recent slice of data
4
+ (typically ~12 months) is locked at first backtest; unlocking is a single-use,
5
+ logged, irreversible event. After the spend, holdout data stays off-limits to
6
+ regular runs forever (Arnott, Harvey & Markowitz 2019).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+
17
+ class HoldoutViolationError(ValueError):
18
+ """Raised when a run would touch locked or already-spent holdout data."""
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class HoldoutState:
23
+ """Current holdout lock state."""
24
+
25
+ holdout_start: datetime
26
+ locked_at: datetime
27
+ spent: bool
28
+ spent_at: datetime | None
29
+
30
+ def to_json_dict(self) -> dict[str, object]:
31
+ return {
32
+ "holdout_start": self.holdout_start.isoformat(),
33
+ "locked_at": self.locked_at.isoformat(),
34
+ "spent": self.spent,
35
+ "spent_at": self.spent_at.isoformat() if self.spent_at else None,
36
+ }
37
+
38
+
39
+ def initialize_holdout(
40
+ path: str | Path,
41
+ *,
42
+ holdout_start: datetime,
43
+ locked_at: datetime,
44
+ ) -> HoldoutState:
45
+ """Create the lock once; loading the existing lock on later calls."""
46
+
47
+ _require_utc("holdout_start", holdout_start)
48
+ _require_utc("locked_at", locked_at)
49
+ existing = load_holdout(path)
50
+ if existing is not None:
51
+ return existing
52
+ state = HoldoutState(
53
+ holdout_start=holdout_start,
54
+ locked_at=locked_at,
55
+ spent=False,
56
+ spent_at=None,
57
+ )
58
+ _write(path, state)
59
+ return state
60
+
61
+
62
+ def load_holdout(path: str | Path) -> HoldoutState | None:
63
+ """Load the lock state, or None when no lock exists yet."""
64
+
65
+ lock_path = Path(path)
66
+ if not lock_path.exists():
67
+ return None
68
+ try:
69
+ row = json.loads(lock_path.read_text(encoding="utf-8"))
70
+ except json.JSONDecodeError as exc:
71
+ msg = f"holdout lock is corrupt: {lock_path}"
72
+ raise HoldoutViolationError(msg) from exc
73
+ if not isinstance(row, dict):
74
+ msg = f"holdout lock must be a JSON object: {lock_path}"
75
+ raise HoldoutViolationError(msg)
76
+ spent_at_raw = row.get("spent_at")
77
+ return HoldoutState(
78
+ holdout_start=_parse_utc(str(row["holdout_start"])),
79
+ locked_at=_parse_utc(str(row["locked_at"])),
80
+ spent=bool(row["spent"]),
81
+ spent_at=_parse_utc(str(spent_at_raw)) if spent_at_raw else None,
82
+ )
83
+
84
+
85
+ def require_data_allowed(path: str | Path, *, data_end: datetime) -> None:
86
+ """Reject any regular run whose data reaches into the holdout period."""
87
+
88
+ _require_utc("data_end", data_end)
89
+ state = load_holdout(path)
90
+ if state is None:
91
+ return
92
+ if data_end >= state.holdout_start:
93
+ spent_note = (
94
+ "the holdout has already been SPENT; re-testing against it is void"
95
+ if state.spent
96
+ else "unlock it only through the single-use spend procedure"
97
+ )
98
+ msg = (
99
+ f"data_end {data_end.isoformat()} reaches into the locked holdout "
100
+ f"(starts {state.holdout_start.isoformat()}); {spent_note}"
101
+ )
102
+ raise HoldoutViolationError(msg)
103
+
104
+
105
+ def spend_holdout(path: str | Path, *, spent_at: datetime) -> HoldoutState:
106
+ """Mark the single-use holdout as spent. A second spend attempt fails.
107
+
108
+ The spend is recorded BEFORE the qualification run executes: if the run
109
+ crashes, the holdout stays spent (conservative by doctrine).
110
+ """
111
+
112
+ _require_utc("spent_at", spent_at)
113
+ state = load_holdout(path)
114
+ if state is None:
115
+ msg = "cannot spend a holdout that was never initialized"
116
+ raise HoldoutViolationError(msg)
117
+ if state.spent:
118
+ msg = (
119
+ f"holdout was already spent at "
120
+ f"{state.spent_at.isoformat() if state.spent_at else 'unknown'}; "
121
+ "a second use is void (iterated OOS is not OOS)"
122
+ )
123
+ raise HoldoutViolationError(msg)
124
+ spent_state = HoldoutState(
125
+ holdout_start=state.holdout_start,
126
+ locked_at=state.locked_at,
127
+ spent=True,
128
+ spent_at=spent_at,
129
+ )
130
+ _write(path, spent_state)
131
+ return spent_state
132
+
133
+
134
+ def _write(path: str | Path, state: HoldoutState) -> None:
135
+ lock_path = Path(path)
136
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
137
+ lock_path.write_text(
138
+ json.dumps(state.to_json_dict(), indent=2, sort_keys=True) + "\n",
139
+ encoding="utf-8",
140
+ )
141
+
142
+
143
+ def _parse_utc(raw: str) -> datetime:
144
+ value = datetime.fromisoformat(raw)
145
+ if value.tzinfo is None:
146
+ msg = f"holdout timestamps must be timezone-aware: {raw}"
147
+ raise HoldoutViolationError(msg)
148
+ return value.astimezone(UTC)
149
+
150
+
151
+ def _require_utc(name: str, value: datetime) -> None:
152
+ if value.tzinfo is None or value.utcoffset() != UTC.utcoffset(value):
153
+ msg = f"{name} must be timezone-aware UTC"
154
+ raise HoldoutViolationError(msg)
trialgate/py.typed ADDED
File without changes
trialgate/registry.py ADDED
@@ -0,0 +1,174 @@
1
+ """Append-only trial registry: unregistered backtests are void.
2
+
3
+ Gate 1 of the six-gate qualification standard. Every backtest execution of
4
+ any variant must be recorded here; the registry maintains the running trial
5
+ count N used by the DSR gate. A backtest that does not disclose its trial
6
+ count is worthless by construction (Bailey & Lopez de Prado 2014).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+
18
+ class TrialRegistryError(ValueError):
19
+ """Raised when the trial registry cannot be read or appended safely."""
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class TrialRecord:
24
+ """One registered backtest execution."""
25
+
26
+ trial_id: int
27
+ recorded_at: datetime
28
+ config_hash: str
29
+ code_version: str
30
+ strategy_id: str
31
+ parameters: dict[str, str]
32
+ universe: tuple[str, ...]
33
+ data_start: datetime
34
+ data_end: datetime
35
+ cost_assumptions: dict[str, str]
36
+ metrics: dict[str, str]
37
+ operator_note: str
38
+
39
+ def to_json_dict(self) -> dict[str, object]:
40
+ return {
41
+ "trial_id": self.trial_id,
42
+ "recorded_at": self.recorded_at.isoformat(),
43
+ "config_hash": self.config_hash,
44
+ "code_version": self.code_version,
45
+ "strategy_id": self.strategy_id,
46
+ "parameters": dict(self.parameters),
47
+ "universe": list(self.universe),
48
+ "data_start": self.data_start.isoformat(),
49
+ "data_end": self.data_end.isoformat(),
50
+ "cost_assumptions": dict(self.cost_assumptions),
51
+ "metrics": dict(self.metrics),
52
+ "operator_note": self.operator_note,
53
+ }
54
+
55
+
56
+ def config_hash_for(snapshot: dict[str, object]) -> str:
57
+ """Deterministic SHA-256 of a JSON-serializable config snapshot."""
58
+
59
+ canonical = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
60
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
61
+
62
+
63
+ def trial_count(path: str | Path) -> int:
64
+ """Current registered trial count N."""
65
+
66
+ return len(load_trials(path))
67
+
68
+
69
+ def load_trials(path: str | Path) -> tuple[TrialRecord, ...]:
70
+ """Load every registered trial, oldest first."""
71
+
72
+ registry_path = Path(path)
73
+ if not registry_path.exists():
74
+ return ()
75
+ records: list[TrialRecord] = []
76
+ for line_number, line in enumerate(
77
+ registry_path.read_text(encoding="utf-8").splitlines(), start=1
78
+ ):
79
+ if not line.strip():
80
+ continue
81
+ try:
82
+ row = json.loads(line)
83
+ except json.JSONDecodeError as exc:
84
+ msg = f"{registry_path}:{line_number} is not valid JSON"
85
+ raise TrialRegistryError(msg) from exc
86
+ records.append(_record_from_row(row, registry_path, line_number))
87
+ return tuple(records)
88
+
89
+
90
+ def append_trial(
91
+ path: str | Path,
92
+ *,
93
+ recorded_at: datetime,
94
+ config_hash: str,
95
+ code_version: str,
96
+ strategy_id: str,
97
+ parameters: dict[str, str],
98
+ universe: tuple[str, ...],
99
+ data_start: datetime,
100
+ data_end: datetime,
101
+ cost_assumptions: dict[str, str],
102
+ metrics: dict[str, str],
103
+ operator_note: str,
104
+ ) -> TrialRecord:
105
+ """Append one trial record and return it with its assigned monotonic id."""
106
+
107
+ _require_utc("recorded_at", recorded_at)
108
+ _require_utc("data_start", data_start)
109
+ _require_utc("data_end", data_end)
110
+ if not strategy_id.strip():
111
+ msg = "strategy_id must not be empty"
112
+ raise TrialRegistryError(msg)
113
+ if not universe:
114
+ msg = "universe must not be empty"
115
+ raise TrialRegistryError(msg)
116
+
117
+ registry_path = Path(path)
118
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
119
+ existing = load_trials(registry_path)
120
+ record = TrialRecord(
121
+ trial_id=len(existing) + 1,
122
+ recorded_at=recorded_at,
123
+ config_hash=config_hash,
124
+ code_version=code_version,
125
+ strategy_id=strategy_id,
126
+ parameters=parameters,
127
+ universe=universe,
128
+ data_start=data_start,
129
+ data_end=data_end,
130
+ cost_assumptions=cost_assumptions,
131
+ metrics=metrics,
132
+ operator_note=operator_note,
133
+ )
134
+ with registry_path.open("a", encoding="utf-8") as handle:
135
+ handle.write(json.dumps(record.to_json_dict(), sort_keys=True) + "\n")
136
+ return record
137
+
138
+
139
+ def _record_from_row(row: object, path: Path, line_number: int) -> TrialRecord:
140
+ if not isinstance(row, dict):
141
+ msg = f"{path}:{line_number} must be a JSON object"
142
+ raise TrialRegistryError(msg)
143
+ try:
144
+ return TrialRecord(
145
+ trial_id=int(row["trial_id"]),
146
+ recorded_at=_parse_utc(str(row["recorded_at"])),
147
+ config_hash=str(row["config_hash"]),
148
+ code_version=str(row["code_version"]),
149
+ strategy_id=str(row["strategy_id"]),
150
+ parameters={str(k): str(v) for k, v in dict(row["parameters"]).items()},
151
+ universe=tuple(str(v) for v in row["universe"]),
152
+ data_start=_parse_utc(str(row["data_start"])),
153
+ data_end=_parse_utc(str(row["data_end"])),
154
+ cost_assumptions={str(k): str(v) for k, v in dict(row["cost_assumptions"]).items()},
155
+ metrics={str(k): str(v) for k, v in dict(row["metrics"]).items()},
156
+ operator_note=str(row["operator_note"]),
157
+ )
158
+ except (KeyError, TypeError, ValueError) as exc:
159
+ msg = f"{path}:{line_number} is not a valid trial record: {exc}"
160
+ raise TrialRegistryError(msg) from exc
161
+
162
+
163
+ def _parse_utc(raw: str) -> datetime:
164
+ value = datetime.fromisoformat(raw)
165
+ if value.tzinfo is None:
166
+ msg = f"trial timestamps must be timezone-aware: {raw}"
167
+ raise TrialRegistryError(msg)
168
+ return value.astimezone(UTC)
169
+
170
+
171
+ def _require_utc(name: str, value: datetime) -> None:
172
+ if value.tzinfo is None or value.utcoffset() != UTC.utcoffset(value):
173
+ msg = f"{name} must be timezone-aware UTC"
174
+ raise TrialRegistryError(msg)
@@ -0,0 +1,238 @@
1
+ """Qualification-gate statistics: Deflated Sharpe Ratio and CSCV/PBO.
2
+
3
+ Sources:
4
+ - Bailey & Lopez de Prado, "The Deflated Sharpe Ratio" (JPM 2014).
5
+ - Bailey, Borwein, Lopez de Prado & Zhu, "The Probability of Backtest
6
+ Overfitting" (Journal of Computational Finance 2015).
7
+
8
+ These statistics operate on measurement floats, not account money, so
9
+ float arithmetic is intentional here (unlike a Decimal trading path).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import statistics
16
+ from dataclasses import dataclass
17
+ from itertools import combinations
18
+
19
+ _EULER_GAMMA = 0.5772156649015329
20
+
21
+
22
+ class ValidationInputError(ValueError):
23
+ """Raised when gate statistics receive unusable inputs."""
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class DeflatedSharpeResult:
28
+ """DSR output with the inputs that produced it."""
29
+
30
+ deflated_sharpe_ratio: float
31
+ observed_sharpe: float
32
+ expected_max_sharpe: float
33
+ effective_trials: int
34
+ observations: int
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class PBOResult:
39
+ """CSCV output: probability of backtest overfitting."""
40
+
41
+ pbo: float
42
+ combinations_evaluated: int
43
+ block_count: int
44
+ strategies: int
45
+ observations: int
46
+
47
+
48
+ def sharpe_ratio(returns: list[float], *, periods_per_year: int = 365) -> float:
49
+ """Annualized Sharpe ratio of a periodic return series (risk-free = 0)."""
50
+
51
+ if len(returns) < 2:
52
+ return 0.0
53
+ stdev = statistics.stdev(returns)
54
+ if stdev == 0.0:
55
+ return 0.0
56
+ return statistics.fmean(returns) / stdev * math.sqrt(periods_per_year)
57
+
58
+
59
+ def deflated_sharpe_ratio(
60
+ returns: list[float],
61
+ *,
62
+ trial_sharpe_variance: float,
63
+ effective_trials: int,
64
+ ) -> DeflatedSharpeResult:
65
+ """Probability that the true Sharpe exceeds the expected max of N noise trials.
66
+
67
+ ``returns`` are the candidate strategy's periodic (e.g. daily) returns.
68
+ ``trial_sharpe_variance`` is the variance of (non-annualized) Sharpe ratios
69
+ across ALL registered trials; ``effective_trials`` is the
70
+ correlation-adjusted trial count N. Pass the honest numbers from the trial
71
+ registry — a flattering N voids the gate.
72
+ """
73
+
74
+ if effective_trials < 1:
75
+ msg = "effective_trials must be at least 1"
76
+ raise ValidationInputError(msg)
77
+ if trial_sharpe_variance < 0.0:
78
+ msg = "trial_sharpe_variance must not be negative"
79
+ raise ValidationInputError(msg)
80
+ observations = len(returns)
81
+ if observations < 3:
82
+ msg = "returns must contain at least 3 observations"
83
+ raise ValidationInputError(msg)
84
+
85
+ stdev = statistics.stdev(returns)
86
+ if stdev == 0.0:
87
+ msg = "returns must not be constant"
88
+ raise ValidationInputError(msg)
89
+ observed_sharpe = statistics.fmean(returns) / stdev
90
+
91
+ normal = statistics.NormalDist()
92
+ if effective_trials == 1 or trial_sharpe_variance == 0.0:
93
+ expected_max_sharpe = 0.0
94
+ else:
95
+ spread = math.sqrt(trial_sharpe_variance)
96
+ expected_max_sharpe = spread * (
97
+ (1.0 - _EULER_GAMMA) * normal.inv_cdf(1.0 - 1.0 / effective_trials)
98
+ + _EULER_GAMMA * normal.inv_cdf(1.0 - 1.0 / (effective_trials * math.e))
99
+ )
100
+
101
+ skewness = _skewness(returns)
102
+ kurtosis = _kurtosis(returns)
103
+ mertens_term = 1.0 - skewness * observed_sharpe + (kurtosis - 1.0) / 4.0 * observed_sharpe**2
104
+ if mertens_term <= 0.0:
105
+ # Fabricating certainty by clamping would answer 0.0/1.0 confidently
106
+ # on inputs where the variance approximation has broken down.
107
+ msg = (
108
+ "returns are too extreme or too short for a stable DSR estimate "
109
+ f"(Mertens variance term {mertens_term:.6f} <= 0)"
110
+ )
111
+ raise ValidationInputError(msg)
112
+ denominator = math.sqrt(mertens_term)
113
+ statistic = (
114
+ (observed_sharpe - expected_max_sharpe) * math.sqrt(observations - 1.0)
115
+ ) / denominator
116
+ return DeflatedSharpeResult(
117
+ deflated_sharpe_ratio=normal.cdf(statistic),
118
+ observed_sharpe=observed_sharpe,
119
+ expected_max_sharpe=expected_max_sharpe,
120
+ effective_trials=effective_trials,
121
+ observations=observations,
122
+ )
123
+
124
+
125
+ def probability_of_backtest_overfitting(
126
+ performance_matrix: list[list[float]],
127
+ *,
128
+ block_count: int = 16,
129
+ ) -> PBOResult:
130
+ """CSCV estimate of PBO over a T x N trial performance matrix.
131
+
132
+ ``performance_matrix``: T rows (periods) x N columns (one per registered
133
+ trial/configuration), each cell the period return of that configuration.
134
+ Splits the rows into ``block_count`` contiguous equal blocks, evaluates all
135
+ C(S, S/2) symmetric train/test partitions, and returns the fraction where
136
+ the in-sample-best configuration ranks in the bottom half out-of-sample.
137
+
138
+ Rows beyond the largest multiple of ``block_count`` — the MOST RECENT
139
+ ``T mod S`` observations — are dropped to keep blocks equal; compare
140
+ ``result.observations`` against ``len(performance_matrix)`` to see how
141
+ many were excluded.
142
+ """
143
+
144
+ if block_count < 2 or block_count % 2 != 0:
145
+ msg = "block_count must be an even integer of at least 2"
146
+ raise ValidationInputError(msg)
147
+ observations = len(performance_matrix)
148
+ if observations < block_count:
149
+ msg = "performance_matrix must have at least block_count rows"
150
+ raise ValidationInputError(msg)
151
+ strategies = len(performance_matrix[0])
152
+ if strategies < 2:
153
+ msg = "performance_matrix must contain at least 2 strategy columns"
154
+ raise ValidationInputError(msg)
155
+ if any(len(row) != strategies for row in performance_matrix):
156
+ msg = "performance_matrix rows must have equal length"
157
+ raise ValidationInputError(msg)
158
+
159
+ usable_rows = observations - (observations % block_count)
160
+ block_size = usable_rows // block_count
161
+ block_slices = [
162
+ performance_matrix[block * block_size : (block + 1) * block_size]
163
+ for block in range(block_count)
164
+ ]
165
+
166
+ overfit_count = 0
167
+ combos = list(combinations(range(block_count), block_count // 2))
168
+ for train_blocks in combos:
169
+ train_set = frozenset(train_blocks)
170
+ train_rows = [row for block in sorted(train_set) for row in block_slices[block]]
171
+ test_rows = [
172
+ row
173
+ for block in range(block_count)
174
+ if block not in train_set
175
+ for row in block_slices[block]
176
+ ]
177
+ train_scores = [_column_sharpe(train_rows, column) for column in range(strategies)]
178
+ test_scores = [_column_sharpe(test_rows, column) for column in range(strategies)]
179
+ best_in_sample = max(range(strategies), key=lambda column: train_scores[column])
180
+ rank = sum(
181
+ 1 for column in range(strategies) if test_scores[column] <= test_scores[best_in_sample]
182
+ )
183
+ relative_rank = rank / (strategies + 1)
184
+ if relative_rank <= 0.5:
185
+ overfit_count += 1
186
+
187
+ return PBOResult(
188
+ pbo=overfit_count / len(combos),
189
+ combinations_evaluated=len(combos),
190
+ block_count=block_count,
191
+ strategies=strategies,
192
+ observations=usable_rows,
193
+ )
194
+
195
+
196
+ def _column_sharpe(rows: list[list[float]], column: int) -> float:
197
+ values = [row[column] for row in rows]
198
+ if len(values) < 2:
199
+ return 0.0
200
+ stdev = statistics.stdev(values)
201
+ if stdev == 0.0:
202
+ return 0.0
203
+ return statistics.fmean(values) / stdev
204
+
205
+
206
+ def non_annualized_sharpe_variance(
207
+ annualized_sharpes: list[float], *, periods_per_year: int = 365
208
+ ) -> float:
209
+ """De-annualize registry Sharpe values into the variance DSR expects.
210
+
211
+ The trial registry stores ANNUALIZED Sharpe ratios; feeding their variance
212
+ into deflated_sharpe_ratio() unconverted inflates the expected-max Sharpe
213
+ by periods_per_year and false-rejects everything. Var(SR_ann / sqrt(p))
214
+ equals Var(SR_ann) / p.
215
+ """
216
+
217
+ if len(annualized_sharpes) < 2:
218
+ return 0.0
219
+ return statistics.pvariance(annualized_sharpes) / periods_per_year
220
+
221
+
222
+ def _skewness(values: list[float]) -> float:
223
+ # Population moments (ddof=0), matching the paper's gamma_3/gamma_4.
224
+ mean = statistics.fmean(values)
225
+ stdev = statistics.pstdev(values)
226
+ if stdev == 0.0:
227
+ return 0.0
228
+ count = len(values)
229
+ return sum(((value - mean) / stdev) ** 3 for value in values) / count
230
+
231
+
232
+ def _kurtosis(values: list[float]) -> float:
233
+ mean = statistics.fmean(values)
234
+ stdev = statistics.pstdev(values)
235
+ if stdev == 0.0:
236
+ return 3.0
237
+ count = len(values)
238
+ return sum(((value - mean) / stdev) ** 4 for value in values) / count
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: trialgate
3
+ Version: 0.1.0
4
+ Summary: Anti-overfitting validation gate for backtested trading strategies: append-only trial registry, CSCV/PBO, Deflated Sharpe Ratio, single-use holdout lock.
5
+ Author: Tsai Chih-Chun (0Smallcat0)
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/0Smallcat0/trialgate
8
+ Project-URL: Issues, https://github.com/0Smallcat0/trialgate/issues
9
+ Project-URL: Reference deployment, https://github.com/0Smallcat0/crypto-quant-signal
10
+ Keywords: backtesting,overfitting,deflated-sharpe-ratio,probability-of-backtest-overfitting,cscv,quantitative-finance,validation,holdout
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Office/Business :: Financial :: Investment
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Requires-Dist: mypy; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # trialgate
31
+
32
+ [![CI](https://github.com/0Smallcat0/trialgate/actions/workflows/ci.yml/badge.svg)](https://github.com/0Smallcat0/trialgate/actions/workflows/ci.yml)
33
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
34
+ [![mypy: strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
36
+
37
+ An **anti-overfitting validation gate** for backtested trading strategies.
38
+ Zero dependencies, pure standard library, fully typed.
39
+
40
+ > A strategy earns belief by surviving verification, not by looking good
41
+ > in-sample. Optimizing four parameters on a **pure random walk** can produce
42
+ > an in-sample Sharpe of 1.27. A single backtest — however pretty — is not
43
+ > evidence.
44
+
45
+ `trialgate` packages the mechanical parts of a six-gate qualification
46
+ standard, built from the anti-overfitting literature (Bailey & López de
47
+ Prado, 2014; Bailey, Borwein, López de Prado & Zhu, 2015; Arnott, Harvey &
48
+ Markowitz, 2019):
49
+
50
+ | Gate | What it enforces | Module |
51
+ |------|------------------|--------|
52
+ | 1. Trial registry | Every backtest of every variant is recorded append-only; unregistered results are void. The registry maintains the honest trial count N. | `trialgate.registry` |
53
+ | 2. Data floor | Enough observations (default ≥ 1,000 daily) spanning real regimes. | `trialgate.gates` |
54
+ | 3. PBO ≤ 0.05 | Probability of Backtest Overfitting via CSCV — all C(S, S/2) symmetric train/test splits. | `trialgate.validation` |
55
+ | 4. DSR ≥ 0.95 | Deflated Sharpe Ratio — the observed Sharpe must beat the expected maximum of N noise trials, adjusted for skew, kurtosis, and sample length. | `trialgate.validation` |
56
+ | 5. Single-use holdout | The most recent data slice is locked at first backtest; spending it is a one-way, logged event. Iterated out-of-sample is not out-of-sample. | `trialgate.holdout` |
57
+ | 6. Paper trading | ≥ 3 months live paper execution with measured real costs. Procedural — deliberately **not** in this library: it is a measurement, not a computation. | — |
58
+
59
+ Passing all six is a **necessary condition, not a profit guarantee**: in a
60
+ verified 2022 case study, the least-overfit strategy still lost ~35% in two
61
+ months.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install trialgate
67
+ ```
68
+
69
+ ## Quickstart
70
+
71
+ ```python
72
+ from datetime import UTC, datetime
73
+
74
+ from trialgate import (
75
+ append_trial, config_hash_for, load_trials, trial_count,
76
+ initialize_holdout, require_data_allowed,
77
+ deflated_sharpe_ratio, probability_of_backtest_overfitting,
78
+ non_annualized_sharpe_variance, evaluate_gates,
79
+ )
80
+
81
+ NOW = datetime.now(tz=UTC)
82
+
83
+ # Gate 5 — lock the most recent ~12 months BEFORE the first backtest.
84
+ initialize_holdout(
85
+ "state/holdout.json",
86
+ holdout_start=datetime(2025, 7, 1, tzinfo=UTC),
87
+ locked_at=NOW,
88
+ )
89
+ # Every regular run must prove it stays out of the holdout.
90
+ require_data_allowed("state/holdout.json", data_end=datetime(2025, 6, 30, tzinfo=UTC))
91
+
92
+ # Gate 1 — register EVERY backtest execution, including the quick ones.
93
+ append_trial(
94
+ "state/registry.jsonl",
95
+ recorded_at=NOW,
96
+ config_hash=config_hash_for({"lookbacks": [20, 65, 150, 200]}),
97
+ code_version="8b91661",
98
+ strategy_id="daily_trend_ensemble",
99
+ parameters={"lookbacks": "20,65,150,200"},
100
+ universe=("BTCUSDT", "ETHUSDT"),
101
+ data_start=datetime(2019, 1, 1, tzinfo=UTC),
102
+ data_end=datetime(2025, 6, 30, tzinfo=UTC),
103
+ cost_assumptions={"round_trip_bps": "25"},
104
+ metrics={"annualized_sharpe": "1.10"},
105
+ operator_note="baseline",
106
+ )
107
+
108
+ # Gates 3 + 4 — computed over ALL registered trials, never a flattering subset.
109
+ trials = load_trials("state/registry.jsonl")
110
+ annualized = [float(t.metrics["annualized_sharpe"]) for t in trials]
111
+
112
+ pbo = probability_of_backtest_overfitting(performance_matrix, block_count=16)
113
+ dsr = deflated_sharpe_ratio(
114
+ candidate_daily_returns,
115
+ trial_sharpe_variance=non_annualized_sharpe_variance(annualized),
116
+ effective_trials=trial_count("state/registry.jsonl"),
117
+ )
118
+
119
+ # Gates 2-4 combined verdict.
120
+ report = evaluate_gates(
121
+ observations=len(candidate_daily_returns),
122
+ pbo=pbo.pbo,
123
+ deflated_sharpe=dsr.deflated_sharpe_ratio,
124
+ )
125
+ print(report.summary())
126
+ # PASS data floor (gate 2): 1848 >= 1000
127
+ # PASS PBO (gate 3): 0.02 <= 0.05
128
+ # PASS DSR (gate 4): 0.97 >= 0.95
129
+ # QUALIFIED
130
+ ```
131
+
132
+ `performance_matrix` is a T×N list of per-period returns, one column per
133
+ registered configuration; `candidate_daily_returns` is the candidate's daily
134
+ return series. Both come from your backtest engine — `trialgate` judges, it
135
+ does not backtest.
136
+
137
+ ## The gate's job is to say no
138
+
139
+ This library is extracted from a live system and has one verdict of each kind
140
+ on record:
141
+
142
+ - **Reference deployment (in progress)** —
143
+ [crypto-quant-signal](https://github.com/0Smallcat0/crypto-quant-signal):
144
+ a daily crypto trend-signal system currently spending its locked holdout
145
+ and 90-day paper period against these exact gates.
146
+ - **A registered FAIL** —
147
+ [tw-stock-trading](https://github.com/0Smallcat0/tw-stock-trading): the
148
+ same strategy family adapted to the Taiwan 0050 ETF **failed its
149
+ pre-registered claim** (CAGR trailed dividend-included buy-and-hold by
150
+ 5.3 pp/year over 21 years; even the zero-cost upper bound barely reached
151
+ the tolerance line). Per the pre-registered rule, the runtime was never
152
+ built. The FAIL report *is* the product.
153
+ Full story: [docs/rejecting-my-own-strategy.md](docs/rejecting-my-own-strategy.md).
154
+
155
+ ## Design notes
156
+
157
+ - **Zero dependencies.** Standard library only; frozen, slotted dataclasses;
158
+ ships `py.typed` and passes `mypy --strict`.
159
+ - **UTC or it didn't happen.** Naive datetimes are rejected everywhere.
160
+ - **Raises instead of clamping.** When the DSR variance approximation breaks
161
+ down, you get an exception, not a confident 0.0/1.0.
162
+ - **Append-only state.** The registry is a JSONL log; the holdout lock is a
163
+ one-way JSON file. Both are plain text you can audit in any editor.
164
+ - **Judgement, not orchestration.** Bring your own backtest engine and data;
165
+ the library never touches an exchange or a price feed.
166
+
167
+ ## Provenance
168
+
169
+ The gate specification, thresholds, and doctrine were defined and verified by
170
+ the author from the primary literature; implementation was AI-assisted
171
+ (spec-driven development with Claude), with every module reviewed against the
172
+ spec and covered by tests before acceptance. This mirrors the library's own
173
+ thesis: artifacts earn trust through verification, not through how they were
174
+ produced.
175
+
176
+ ## References
177
+
178
+ - Bailey & López de Prado — *The Deflated Sharpe Ratio* (Journal of Portfolio
179
+ Management, 2014)
180
+ - Bailey, Borwein, López de Prado & Zhu — *The Probability of Backtest
181
+ Overfitting* (Journal of Computational Finance, 2015)
182
+ - Arnott, Harvey & Markowitz — *A Backtesting Protocol in the Era of Machine
183
+ Learning* (Journal of Financial Data Science, 2019)
184
+
185
+ ## License
186
+
187
+ [MIT](LICENSE) © Tsai Chih-Chun (0Smallcat0)
@@ -0,0 +1,11 @@
1
+ trialgate/__init__.py,sha256=6tmzuO32gW-sblSdEPXn12rOm96LOf3E40topxuO_rw,1747
2
+ trialgate/gates.py,sha256=jlL0iE8T4Voa1_uGMe2Fa57nnXDLJcNUCVKJXPL49Iw,3100
3
+ trialgate/holdout.py,sha256=YCKoMmRi8gfrVyc1k7mll88hMSTrM7m3_idyUCrWmg4,5024
4
+ trialgate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ trialgate/registry.py,sha256=inD8K6AyizeD2tzaG6eGNgwrWWB5O_GObwsD7IAW90M,5967
6
+ trialgate/validation.py,sha256=LiWLA8GpBiHMBCHKfJDpgNhFCX_tt0MfvdXoYt9otaY,8532
7
+ trialgate-0.1.0.dist-info/licenses/LICENSE,sha256=dOZv3mDP0a8anlsJ2WiLh7zW5PqbyOBJZFQVOjhl0AU,1084
8
+ trialgate-0.1.0.dist-info/METADATA,sha256=N_j56e3tPcVAInYEstLw5itKXTZJmWPoD8K-7fo0r9w,8351
9
+ trialgate-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ trialgate-0.1.0.dist-info/top_level.txt,sha256=r3qAHzkr1waBBe_tV_GzxslJhJxdgDWmROh8NE8s8f8,10
11
+ trialgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tsai Chih-Chun (0Smallcat0)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ trialgate