backtest-integrity-guard 0.2.1__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,2 @@
1
+ """Backtest Integrity Guard."""
2
+ __version__ = "0.2.1"
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .core import audit_ohlcv_rows, audit_trade_rows, build_manifest, verify_manifest, parse_iso8601, sha256_file
9
+
10
+
11
+ def read_csv(path: Path):
12
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
13
+ yield from csv.DictReader(handle)
14
+
15
+
16
+ def emit(report) -> int:
17
+ print(report.to_json(), end="")
18
+ return 1 if report.errors else 0
19
+
20
+
21
+ def main() -> int:
22
+ p = argparse.ArgumentParser(prog="btguard")
23
+ sub = p.add_subparsers(dest="command", required=True)
24
+
25
+ a = sub.add_parser("ohlcv", help="audit OHLCV geometry and timestamps")
26
+ a.add_argument("file", type=Path)
27
+ a.add_argument("--timestamp", default="timestamp")
28
+ a.add_argument("--map", type=Path, help="JSON mapping of canonical fields to input column names")
29
+ a.add_argument("--interval-seconds", type=int, help="Expected bar cadence in seconds")
30
+ a.add_argument(
31
+ "--allow-gaps", type=Path,
32
+ help="JSON list of explicit [from_timestamp, to_timestamp] session breaks",
33
+ )
34
+ a.add_argument("--hash-input", action="store_true", help="Include SHA256 of the audited input")
35
+
36
+ b = sub.add_parser("ledger", help="audit signal-to-execution causality")
37
+ b.add_argument("file", type=Path)
38
+ b.add_argument("--signal-close", default="signal_bar_close")
39
+ b.add_argument("--entry", default="entry_time")
40
+ b.add_argument("--next-open", default="next_bar_open")
41
+ b.add_argument("--hash-input", action="store_true", help="Include SHA256 of the audited input")
42
+
43
+ c = sub.add_parser("freeze", help="create a SHA256 input manifest")
44
+ c.add_argument("files", nargs="+", type=Path)
45
+ c.add_argument("-o", "--output", required=True, type=Path)
46
+ c.add_argument("--root", type=Path)
47
+
48
+ d = sub.add_parser("verify", help="verify a frozen SHA256 manifest")
49
+ d.add_argument("manifest", type=Path)
50
+ d.add_argument("--root", type=Path)
51
+
52
+ args = p.parse_args()
53
+ if args.command == "ohlcv":
54
+ mapping = None
55
+ if args.map:
56
+ mapping = json.loads(args.map.read_text(encoding="utf-8"))
57
+ if not isinstance(mapping, dict):
58
+ raise SystemExit("--map must contain a JSON object")
59
+
60
+ allowed_gaps = None
61
+ if args.allow_gaps:
62
+ raw = json.loads(args.allow_gaps.read_text(encoding="utf-8"))
63
+ if not isinstance(raw, list):
64
+ raise SystemExit("--allow-gaps must contain a JSON list")
65
+ try:
66
+ allowed_gaps = {
67
+ (parse_iso8601(pair[0]), parse_iso8601(pair[1]))
68
+ for pair in raw
69
+ if isinstance(pair, list) and len(pair) == 2
70
+ }
71
+ except Exception as exc:
72
+ raise SystemExit(f"invalid --allow-gaps entry: {exc}") from exc
73
+ if len(allowed_gaps) != len(raw):
74
+ raise SystemExit("each --allow-gaps entry must be [from_timestamp, to_timestamp]")
75
+
76
+ report = audit_ohlcv_rows(
77
+ read_csv(args.file),
78
+ args.timestamp,
79
+ mapping,
80
+ args.interval_seconds,
81
+ allowed_gaps,
82
+ )
83
+ if args.hash_input:
84
+ report.input_sha256 = sha256_file(args.file)
85
+ return emit(report)
86
+ if args.command == "ledger":
87
+ report = audit_trade_rows(read_csv(args.file), args.signal_close, args.entry, args.next_open)
88
+ if args.hash_input:
89
+ report.input_sha256 = sha256_file(args.file)
90
+ return emit(report)
91
+ if args.command == "freeze":
92
+ data = build_manifest(args.files, args.root)
93
+ args.output.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
94
+ print(args.output)
95
+ return 0
96
+ return emit(verify_manifest(args.manifest, args.root))
97
+
98
+
99
+ if __name__ == "__main__":
100
+ raise SystemExit(main())
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict
4
+ from datetime import datetime, timedelta
5
+ from pathlib import Path
6
+ import hashlib
7
+ import json
8
+ from typing import Iterable, Mapping, Any
9
+
10
+ from . import __version__
11
+
12
+
13
+ REPORT_SCHEMA_VERSION = "1.0"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Finding:
18
+ severity: str
19
+ code: str
20
+ row: int | None
21
+ message: str
22
+
23
+
24
+ @dataclass
25
+ class AuditReport:
26
+ findings: list[Finding]
27
+ input_sha256: str | None = None
28
+
29
+ @property
30
+ def errors(self) -> int:
31
+ return sum(x.severity == "ERROR" for x in self.findings)
32
+
33
+ @property
34
+ def warnings(self) -> int:
35
+ return sum(x.severity == "WARNING" for x in self.findings)
36
+
37
+ @property
38
+ def status(self) -> str:
39
+ return "FAIL" if self.errors else "PASS"
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ out: dict[str, Any] = {
43
+ "report_schema_version": REPORT_SCHEMA_VERSION,
44
+ "tool_version": __version__,
45
+ "status": self.status,
46
+ "errors": self.errors,
47
+ "warnings": self.warnings,
48
+ "findings": [asdict(x) for x in self.findings],
49
+ }
50
+ if self.input_sha256 is not None:
51
+ out["input_sha256"] = self.input_sha256
52
+ return out
53
+
54
+ def to_json(self) -> str:
55
+ return json.dumps(
56
+ self.to_dict(),
57
+ ensure_ascii=False,
58
+ sort_keys=True,
59
+ separators=(",", ":"),
60
+ ) + "\n"
61
+
62
+
63
+ def parse_iso8601(value: str) -> datetime:
64
+ text = value.strip()
65
+ if text.endswith("Z"):
66
+ text = text[:-1] + "+00:00"
67
+ dt = datetime.fromisoformat(text)
68
+ if dt.tzinfo is None:
69
+ raise ValueError("timestamp must include UTC offset or Z")
70
+ return dt
71
+
72
+
73
+ def _float(row: Mapping[str, str], key: str) -> float:
74
+ return float(row[key])
75
+
76
+
77
+ def audit_ohlcv_rows(
78
+ rows: Iterable[Mapping[str, str]],
79
+ timestamp_field: str = "timestamp",
80
+ field_map: Mapping[str, str] | None = None,
81
+ expected_interval_seconds: int | None = None,
82
+ allowed_gaps: Iterable[tuple[datetime, datetime]] | None = None,
83
+ ) -> AuditReport:
84
+ findings: list[Finding] = []
85
+ seen: set[datetime] = set()
86
+ previous: datetime | None = None
87
+ if expected_interval_seconds is not None and expected_interval_seconds <= 0:
88
+ raise ValueError("expected_interval_seconds must be positive")
89
+ expected = (
90
+ timedelta(seconds=expected_interval_seconds)
91
+ if expected_interval_seconds is not None else None
92
+ )
93
+ allowed = set(allowed_gaps or ())
94
+ names = {
95
+ "timestamp": timestamp_field,
96
+ "open": "open",
97
+ "high": "high",
98
+ "low": "low",
99
+ "close": "close",
100
+ "volume": "volume",
101
+ **(dict(field_map) if field_map else {}),
102
+ }
103
+
104
+ for n, row in enumerate(rows, start=2):
105
+ missing = [k for k in ("timestamp", "open", "high", "low", "close") if names[k] not in row]
106
+ if missing:
107
+ findings.append(Finding(
108
+ "ERROR", "MISSING_REQUIRED_FIELD", n,
109
+ ",".join(f"{k}->{names[k]}" for k in missing),
110
+ ))
111
+ continue
112
+ try:
113
+ ts = parse_iso8601(row[names["timestamp"]])
114
+ except Exception as exc:
115
+ findings.append(Finding("ERROR", "INVALID_TIMESTAMP", n, str(exc)))
116
+ continue
117
+
118
+ if ts in seen:
119
+ findings.append(Finding("ERROR", "DUPLICATE_TIMESTAMP", n, str(ts)))
120
+ if previous is not None and ts <= previous:
121
+ findings.append(Finding("ERROR", "NON_MONOTONIC_TIME", n, str(ts)))
122
+ elif previous is not None and expected is not None:
123
+ delta = ts - previous
124
+ if delta != expected:
125
+ if (previous, ts) in allowed:
126
+ findings.append(Finding(
127
+ "WARNING", "ALLOWED_SESSION_GAP", n,
128
+ f"from={previous.isoformat()} to={ts.isoformat()} seconds={int(delta.total_seconds())}",
129
+ ))
130
+ elif delta > expected and delta.total_seconds() % expected.total_seconds() == 0:
131
+ missing = int(delta / expected) - 1
132
+ findings.append(Finding(
133
+ "ERROR", "MISSING_BARS", n,
134
+ f"missing={missing} expected_interval_seconds={expected_interval_seconds} "
135
+ f"from={previous.isoformat()} to={ts.isoformat()}",
136
+ ))
137
+ else:
138
+ findings.append(Finding(
139
+ "ERROR", "IRREGULAR_BAR_INTERVAL", n,
140
+ f"expected_seconds={expected_interval_seconds} "
141
+ f"actual_seconds={delta.total_seconds():g} "
142
+ f"from={previous.isoformat()} to={ts.isoformat()}",
143
+ ))
144
+ seen.add(ts)
145
+ previous = ts
146
+
147
+ try:
148
+ o, h, l, c = (_float(row, names[k]) for k in ("open", "high", "low", "close"))
149
+ except Exception as exc:
150
+ findings.append(Finding("ERROR", "INVALID_OHLC", n, str(exc)))
151
+ continue
152
+
153
+ if l > h:
154
+ findings.append(Finding("ERROR", "LOW_ABOVE_HIGH", n, f"{l}>{h}"))
155
+ if h < max(o, c):
156
+ findings.append(Finding("ERROR", "HIGH_BELOW_BODY", n, f"h={h} o={o} c={c}"))
157
+ if l > min(o, c):
158
+ findings.append(Finding("ERROR", "LOW_ABOVE_BODY", n, f"l={l} o={o} c={c}"))
159
+
160
+ volume_field = names["volume"]
161
+ if volume_field in row and row[volume_field] not in ("", None):
162
+ try:
163
+ if float(row[volume_field]) < 0:
164
+ findings.append(Finding("ERROR", "NEGATIVE_VOLUME", n, row[volume_field]))
165
+ except Exception as exc:
166
+ findings.append(Finding("ERROR", "INVALID_VOLUME", n, str(exc)))
167
+
168
+ if "complete" in row and str(row["complete"]).strip().lower() in {"false", "0", "no"}:
169
+ findings.append(Finding("WARNING", "INCOMPLETE_BAR", n, "bar is marked incomplete"))
170
+
171
+ return AuditReport(findings)
172
+
173
+
174
+ def audit_trade_rows(
175
+ rows: Iterable[Mapping[str, str]],
176
+ signal_close_field: str = "signal_bar_close",
177
+ entry_field: str = "entry_time",
178
+ next_open_field: str | None = "next_bar_open",
179
+ ) -> AuditReport:
180
+ findings: list[Finding] = []
181
+
182
+ for n, row in enumerate(rows, start=2):
183
+ try:
184
+ signal_close = parse_iso8601(row[signal_close_field])
185
+ entry = parse_iso8601(row[entry_field])
186
+ except Exception as exc:
187
+ findings.append(Finding("ERROR", "INVALID_EXECUTION_TIME", n, str(exc)))
188
+ continue
189
+
190
+ if entry <= signal_close:
191
+ findings.append(Finding(
192
+ "ERROR", "LOOKAHEAD_OR_SAME_BAR_ENTRY", n,
193
+ f"entry={entry.isoformat()} signal_close={signal_close.isoformat()}",
194
+ ))
195
+
196
+ if next_open_field and row.get(next_open_field):
197
+ try:
198
+ next_open = parse_iso8601(row[next_open_field])
199
+ if entry < next_open:
200
+ findings.append(Finding(
201
+ "ERROR", "ENTRY_BEFORE_NEXT_BAR", n,
202
+ f"entry={entry.isoformat()} next_open={next_open.isoformat()}",
203
+ ))
204
+ except Exception as exc:
205
+ findings.append(Finding("ERROR", "INVALID_NEXT_BAR_OPEN", n, str(exc)))
206
+
207
+ stop_hit = str(row.get("stop_hit", "")).strip().lower() in {"1", "true", "yes"}
208
+ target_hit = str(row.get("target_hit", "")).strip().lower() in {"1", "true", "yes"}
209
+ policy = str(row.get("same_bar_policy", "")).strip().lower()
210
+ if stop_hit and target_hit and policy not in {"stop_first", "target_first", "path_known"}:
211
+ findings.append(Finding(
212
+ "ERROR", "AMBIGUOUS_SAME_BAR_EXIT", n,
213
+ "stop and target both hit but no explicit same_bar_policy is declared",
214
+ ))
215
+
216
+ return AuditReport(findings)
217
+
218
+
219
+ def sha256_file(path: Path) -> str:
220
+ h = hashlib.sha256()
221
+ with path.open("rb") as handle:
222
+ for block in iter(lambda: handle.read(1 << 20), b""):
223
+ h.update(block)
224
+ return h.hexdigest()
225
+
226
+
227
+ def build_manifest(paths: Iterable[Path], root: Path | None = None) -> dict[str, Any]:
228
+ root = root.resolve() if root else None
229
+ files = []
230
+ for path in sorted(Path(p).resolve() for p in paths):
231
+ name = str(path.relative_to(root)) if root and path.is_relative_to(root) else str(path)
232
+ files.append({"path": name, "sha256": sha256_file(path), "bytes": path.stat().st_size})
233
+ return {"algorithm": "sha256", "files": files}
234
+
235
+
236
+ def verify_manifest(manifest_path: Path, root: Path | None = None) -> AuditReport:
237
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
238
+ base = root.resolve() if root else manifest_path.parent.resolve()
239
+ findings: list[Finding] = []
240
+ for item in data.get("files", []):
241
+ path = Path(item["path"])
242
+ if not path.is_absolute():
243
+ path = base / path
244
+ if not path.exists():
245
+ findings.append(Finding("ERROR", "MISSING_FROZEN_INPUT", None, str(path)))
246
+ continue
247
+ actual = sha256_file(path)
248
+ if actual != item["sha256"]:
249
+ findings.append(Finding("ERROR", "FROZEN_INPUT_HASH_MISMATCH", None, str(path)))
250
+ return AuditReport(findings)
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: backtest-integrity-guard
3
+ Version: 0.2.1
4
+ Summary: Auditable checks for causal backtests, OHLCV data, execution timing, and frozen inputs.
5
+ Home-page: https://github.com/suguobin2021/backtest-integrity-guard
6
+ Author: suguobin2021
7
+ License: MIT
8
+ Project-URL: Source, https://github.com/suguobin2021/backtest-integrity-guard
9
+ Project-URL: Issues, https://github.com/suguobin2021/backtest-integrity-guard/issues
10
+ Project-URL: Releases, https://github.com/suguobin2021/backtest-integrity-guard/releases
11
+ Project-URL: Changelog, https://github.com/suguobin2021/backtest-integrity-guard/blob/main/CHANGELOG.md
12
+ Keywords: backtest,quantitative-finance,causality,lookahead-bias,ohlcv,data-validation
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # Backtest Integrity Guard
28
+
29
+ [![tests](https://github.com/suguobin2021/backtest-integrity-guard/actions/workflows/test.yml/badge.svg)](https://github.com/suguobin2021/backtest-integrity-guard/actions/workflows/test.yml)
30
+
31
+ A small, dependency-free Python CLI for catching common integrity failures in quantitative backtests before performance metrics are trusted.
32
+
33
+ It focuses on **causality, data integrity, explicit ambiguity handling, and reproducibility**. It does not implement trading signals or ship market data.
34
+
35
+ ## Install
36
+
37
+ ### From the v0.2.1 release wheel
38
+
39
+ ~~~bash
40
+ python -m pip install "https://github.com/suguobin2021/backtest-integrity-guard/releases/download/v0.2.1/backtest_integrity_guard-0.2.1-py3-none-any.whl"
41
+ btguard --help
42
+ ~~~
43
+
44
+ The release also includes a source archive and SHA256SUMS.
45
+
46
+ ### From source
47
+
48
+ ~~~bash
49
+ git clone https://github.com/suguobin2021/backtest-integrity-guard.git
50
+ cd backtest-integrity-guard
51
+ python -m pip install -e .
52
+ ~~~
53
+
54
+ ## Quick start
55
+
56
+ Audit ordinary OHLCV data:
57
+
58
+ ~~~bash
59
+ btguard ohlcv examples/ohlcv.csv --hash-input
60
+ ~~~
61
+
62
+ Audit vendor-specific column names:
63
+
64
+ ~~~bash
65
+ btguard ohlcv examples/vendor.csv --map examples/schema.json
66
+ ~~~
67
+
68
+ Check a 5-minute cadence while explicitly allowing a known session break:
69
+
70
+ ~~~bash
71
+ btguard ohlcv examples/session_gap.csv \
72
+ --interval-seconds 300 \
73
+ --allow-gaps examples/allowed_gaps.json
74
+ ~~~
75
+
76
+ Audit signal-to-execution timing:
77
+
78
+ ~~~bash
79
+ btguard ledger examples/trades.csv --hash-input
80
+ ~~~
81
+
82
+ Freeze and verify research inputs:
83
+
84
+ ~~~bash
85
+ btguard freeze examples/ohlcv.csv examples/trades.csv -o manifest.json --root .
86
+ btguard verify manifest.json --root .
87
+ ~~~
88
+
89
+ Commands exit non-zero when an integrity error is found, so they can be used in CI.
90
+
91
+ ## Output contract
92
+
93
+ Audit output is deterministic JSON with a stable report schema:
94
+
95
+ ~~~json
96
+ {
97
+ "errors": 0,
98
+ "findings": [],
99
+ "input_sha256": "<64 hex chars>",
100
+ "report_schema_version": "1.0",
101
+ "status": "PASS",
102
+ "tool_version": "0.2.1",
103
+ "warnings": 0
104
+ }
105
+ ~~~
106
+
107
+ Use --hash-input when a report should be bound to the exact audited file.
108
+
109
+ ## What it checks
110
+
111
+ - OHLC geometry and negative or invalid volume
112
+ - duplicate and non-monotonic timestamps
113
+ - timezone-aware timestamps
114
+ - incomplete-bar markers
115
+ - configurable OHLCV schema aliases
116
+ - missing bars and irregular cadence
117
+ - explicitly allowlisted session gaps
118
+ - signal-close to execution ordering
119
+ - next-bar execution constraints
120
+ - same-bar stop/target ambiguity
121
+ - SHA256 frozen-input manifests
122
+ - deterministic machine-readable reports
123
+
124
+ ## Scope
125
+
126
+ Backtest Integrity Guard intentionally does **not** contain:
127
+ - trading signals or alpha logic
128
+ - brokerage execution code
129
+ - proprietary or licensed market data
130
+ - account credentials or API keys
131
+
132
+ The goal is to make research assumptions machine-checkable without depending on any particular strategy.
133
+
134
+ ## Contributing
135
+
136
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports should include the smallest synthetic example that reproduces the problem.
137
+
138
+ Useful project links:
139
+ - [Issues](https://github.com/suguobin2021/backtest-integrity-guard/issues)
140
+ - [Roadmap](ROADMAP.md)
141
+ - [Changelog](CHANGELOG.md)
142
+ - [Releases](https://github.com/suguobin2021/backtest-integrity-guard/releases)
143
+
144
+ ## License
145
+
146
+ MIT.
@@ -0,0 +1,9 @@
1
+ backtest_integrity_guard/__init__.py,sha256=LeVqnnEmPqlAaUWQq-agNioz640cOY6CpxTcD_5mTOY,54
2
+ backtest_integrity_guard/cli.py,sha256=0nk_C1LO9LqOihgKZ-2vSYnVLKoDRpKLdlARh3erJ3Y,3880
3
+ backtest_integrity_guard/core.py,sha256=aAD8zJdXd1B64fmI7gTM3mw_z8QwgDJ-In4iyYwuqas,9308
4
+ backtest_integrity_guard-0.2.1.dist-info/licenses/LICENSE,sha256=TXrvWvqZRgag4u1EWmMHF14vNXd0x4jKgQ4uCfINIgw,1069
5
+ backtest_integrity_guard-0.2.1.dist-info/METADATA,sha256=qXUr8DA_cY4PTE7tJG4LpFuahmJLdFW-Sq42BiOZhns,4493
6
+ backtest_integrity_guard-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ backtest_integrity_guard-0.2.1.dist-info/entry_points.txt,sha256=ya8_pmtG3x_10GnctWOAE7LV6CAsMv7fskZvxts9tn8,62
8
+ backtest_integrity_guard-0.2.1.dist-info/top_level.txt,sha256=dLGJUL6VFQiRsLozu0OEifPXjTNsMW5ywB5wksd2b6g,25
9
+ backtest_integrity_guard-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ btguard = backtest_integrity_guard.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 suguobin2021
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
+ backtest_integrity_guard