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 +72 -0
- trialgate/gates.py +99 -0
- trialgate/holdout.py +154 -0
- trialgate/py.typed +0 -0
- trialgate/registry.py +174 -0
- trialgate/validation.py +238 -0
- trialgate-0.1.0.dist-info/METADATA +187 -0
- trialgate-0.1.0.dist-info/RECORD +11 -0
- trialgate-0.1.0.dist-info/WHEEL +5 -0
- trialgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- trialgate-0.1.0.dist-info/top_level.txt +1 -0
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)
|
trialgate/validation.py
ADDED
|
@@ -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
|
+
[](https://github.com/0Smallcat0/trialgate/actions/workflows/ci.yml)
|
|
33
|
+
[](https://www.python.org/downloads/)
|
|
34
|
+
[](https://mypy.readthedocs.io/)
|
|
35
|
+
[](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,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
|