backtest-integrity-guard 0.2.1__tar.gz

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,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,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,120 @@
1
+ # Backtest Integrity Guard
2
+
3
+ [![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)
4
+
5
+ A small, dependency-free Python CLI for catching common integrity failures in quantitative backtests before performance metrics are trusted.
6
+
7
+ It focuses on **causality, data integrity, explicit ambiguity handling, and reproducibility**. It does not implement trading signals or ship market data.
8
+
9
+ ## Install
10
+
11
+ ### From the v0.2.1 release wheel
12
+
13
+ ~~~bash
14
+ 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"
15
+ btguard --help
16
+ ~~~
17
+
18
+ The release also includes a source archive and SHA256SUMS.
19
+
20
+ ### From source
21
+
22
+ ~~~bash
23
+ git clone https://github.com/suguobin2021/backtest-integrity-guard.git
24
+ cd backtest-integrity-guard
25
+ python -m pip install -e .
26
+ ~~~
27
+
28
+ ## Quick start
29
+
30
+ Audit ordinary OHLCV data:
31
+
32
+ ~~~bash
33
+ btguard ohlcv examples/ohlcv.csv --hash-input
34
+ ~~~
35
+
36
+ Audit vendor-specific column names:
37
+
38
+ ~~~bash
39
+ btguard ohlcv examples/vendor.csv --map examples/schema.json
40
+ ~~~
41
+
42
+ Check a 5-minute cadence while explicitly allowing a known session break:
43
+
44
+ ~~~bash
45
+ btguard ohlcv examples/session_gap.csv \
46
+ --interval-seconds 300 \
47
+ --allow-gaps examples/allowed_gaps.json
48
+ ~~~
49
+
50
+ Audit signal-to-execution timing:
51
+
52
+ ~~~bash
53
+ btguard ledger examples/trades.csv --hash-input
54
+ ~~~
55
+
56
+ Freeze and verify research inputs:
57
+
58
+ ~~~bash
59
+ btguard freeze examples/ohlcv.csv examples/trades.csv -o manifest.json --root .
60
+ btguard verify manifest.json --root .
61
+ ~~~
62
+
63
+ Commands exit non-zero when an integrity error is found, so they can be used in CI.
64
+
65
+ ## Output contract
66
+
67
+ Audit output is deterministic JSON with a stable report schema:
68
+
69
+ ~~~json
70
+ {
71
+ "errors": 0,
72
+ "findings": [],
73
+ "input_sha256": "<64 hex chars>",
74
+ "report_schema_version": "1.0",
75
+ "status": "PASS",
76
+ "tool_version": "0.2.1",
77
+ "warnings": 0
78
+ }
79
+ ~~~
80
+
81
+ Use --hash-input when a report should be bound to the exact audited file.
82
+
83
+ ## What it checks
84
+
85
+ - OHLC geometry and negative or invalid volume
86
+ - duplicate and non-monotonic timestamps
87
+ - timezone-aware timestamps
88
+ - incomplete-bar markers
89
+ - configurable OHLCV schema aliases
90
+ - missing bars and irregular cadence
91
+ - explicitly allowlisted session gaps
92
+ - signal-close to execution ordering
93
+ - next-bar execution constraints
94
+ - same-bar stop/target ambiguity
95
+ - SHA256 frozen-input manifests
96
+ - deterministic machine-readable reports
97
+
98
+ ## Scope
99
+
100
+ Backtest Integrity Guard intentionally does **not** contain:
101
+ - trading signals or alpha logic
102
+ - brokerage execution code
103
+ - proprietary or licensed market data
104
+ - account credentials or API keys
105
+
106
+ The goal is to make research assumptions machine-checkable without depending on any particular strategy.
107
+
108
+ ## Contributing
109
+
110
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports should include the smallest synthetic example that reproduces the problem.
111
+
112
+ Useful project links:
113
+ - [Issues](https://github.com/suguobin2021/backtest-integrity-guard/issues)
114
+ - [Roadmap](ROADMAP.md)
115
+ - [Changelog](CHANGELOG.md)
116
+ - [Releases](https://github.com/suguobin2021/backtest-integrity-guard/releases)
117
+
118
+ ## License
119
+
120
+ MIT.
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,45 @@
1
+ [metadata]
2
+ name = backtest-integrity-guard
3
+ version = 0.2.1
4
+ description = Auditable checks for causal backtests, OHLCV data, execution timing, and frozen inputs.
5
+ long_description = file: README.md
6
+ long_description_content_type = text/markdown
7
+ url = https://github.com/suguobin2021/backtest-integrity-guard
8
+ project_urls =
9
+ Source = https://github.com/suguobin2021/backtest-integrity-guard
10
+ Issues = https://github.com/suguobin2021/backtest-integrity-guard/issues
11
+ Releases = https://github.com/suguobin2021/backtest-integrity-guard/releases
12
+ Changelog = https://github.com/suguobin2021/backtest-integrity-guard/blob/main/CHANGELOG.md
13
+ license = MIT
14
+ license_files = LICENSE
15
+ author = suguobin2021
16
+ keywords = backtest, quantitative-finance, causality, lookahead-bias, ohlcv, data-validation
17
+ classifiers =
18
+ Development Status :: 3 - Alpha
19
+ Intended Audience :: Developers
20
+ Intended Audience :: Science/Research
21
+ License :: OSI Approved :: MIT License
22
+ Programming Language :: Python :: 3
23
+ Programming Language :: Python :: 3.10
24
+ Programming Language :: Python :: 3.11
25
+ Programming Language :: Python :: 3.12
26
+ Topic :: Scientific/Engineering :: Information Analysis
27
+
28
+ [options]
29
+ package_dir =
30
+ = src
31
+ packages = find:
32
+ python_requires = >=3.10
33
+ include_package_data = True
34
+
35
+ [options.packages.find]
36
+ where = src
37
+
38
+ [options.entry_points]
39
+ console_scripts =
40
+ btguard = backtest_integrity_guard.cli:main
41
+
42
+ [egg_info]
43
+ tag_build =
44
+ tag_date = 0
45
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()
@@ -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,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ setup.py
6
+ src/backtest_integrity_guard/__init__.py
7
+ src/backtest_integrity_guard/cli.py
8
+ src/backtest_integrity_guard/core.py
9
+ src/backtest_integrity_guard.egg-info/PKG-INFO
10
+ src/backtest_integrity_guard.egg-info/SOURCES.txt
11
+ src/backtest_integrity_guard.egg-info/dependency_links.txt
12
+ src/backtest_integrity_guard.egg-info/entry_points.txt
13
+ src/backtest_integrity_guard.egg-info/top_level.txt
14
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ btguard = backtest_integrity_guard.cli:main
@@ -0,0 +1,133 @@
1
+ from pathlib import Path
2
+ import json
3
+ import tempfile
4
+ import unittest
5
+
6
+ from backtest_integrity_guard.core import (
7
+ audit_ohlcv_rows, audit_trade_rows, build_manifest, verify_manifest,
8
+ )
9
+
10
+
11
+ class IntegrityTests(unittest.TestCase):
12
+ def test_clean_ohlcv_passes(self):
13
+ rows = [
14
+ {"timestamp":"2026-01-01T00:00:00Z","open":"10","high":"12","low":"9","close":"11","volume":"100"},
15
+ {"timestamp":"2026-01-01T00:05:00Z","open":"11","high":"13","low":"10","close":"12","volume":"110"},
16
+ ]
17
+ self.assertEqual(audit_ohlcv_rows(rows).status, "PASS")
18
+
19
+ def test_alternate_schema_mapping_passes(self):
20
+ rows = [
21
+ {"ts":"2026-01-01T00:00:00Z","o":"10","h":"12","l":"9","c":"11","qty":"100"},
22
+ ]
23
+ report = audit_ohlcv_rows(rows, field_map={
24
+ "timestamp":"ts", "open":"o", "high":"h", "low":"l", "close":"c", "volume":"qty"
25
+ })
26
+ self.assertEqual(report.status, "PASS")
27
+
28
+ def test_missing_mapped_required_field_fails_closed(self):
29
+ rows = [
30
+ {"ts":"2026-01-01T00:00:00Z","o":"10","h":"12","c":"11"},
31
+ ]
32
+ report = audit_ohlcv_rows(rows, field_map={
33
+ "timestamp":"ts", "open":"o", "high":"h", "low":"l", "close":"c"
34
+ })
35
+ codes = {x.code for x in report.findings}
36
+ self.assertIn("MISSING_REQUIRED_FIELD", codes)
37
+
38
+ def test_missing_bars_are_counted(self):
39
+ rows = [
40
+ {"timestamp":"2026-01-01T00:00:00Z","open":"10","high":"12","low":"9","close":"11"},
41
+ {"timestamp":"2026-01-01T00:15:00Z","open":"11","high":"13","low":"10","close":"12"},
42
+ ]
43
+ report = audit_ohlcv_rows(rows, expected_interval_seconds=300)
44
+ hits = [x for x in report.findings if x.code == "MISSING_BARS"]
45
+ self.assertEqual(len(hits), 1)
46
+ self.assertIn("missing=2", hits[0].message)
47
+ self.assertEqual(report.status, "FAIL")
48
+
49
+ def test_explicit_session_gap_is_allowed(self):
50
+ from backtest_integrity_guard.core import parse_iso8601
51
+ start = parse_iso8601("2026-01-02T16:00:00Z")
52
+ end = parse_iso8601("2026-01-05T09:30:00Z")
53
+ rows = [
54
+ {"timestamp":"2026-01-02T16:00:00Z","open":"10","high":"12","low":"9","close":"11"},
55
+ {"timestamp":"2026-01-05T09:30:00Z","open":"11","high":"13","low":"10","close":"12"},
56
+ ]
57
+ report = audit_ohlcv_rows(
58
+ rows,
59
+ expected_interval_seconds=300,
60
+ allowed_gaps={(start, end)},
61
+ )
62
+ self.assertEqual(report.status, "PASS")
63
+ self.assertEqual([x.code for x in report.findings], ["ALLOWED_SESSION_GAP"])
64
+
65
+ def test_irregular_interval_is_distinct(self):
66
+ rows = [
67
+ {"timestamp":"2026-01-01T00:00:00Z","open":"10","high":"12","low":"9","close":"11"},
68
+ {"timestamp":"2026-01-01T00:07:00Z","open":"11","high":"13","low":"10","close":"12"},
69
+ ]
70
+ codes = {
71
+ x.code for x in audit_ohlcv_rows(
72
+ rows, expected_interval_seconds=300
73
+ ).findings
74
+ }
75
+ self.assertIn("IRREGULAR_BAR_INTERVAL", codes)
76
+
77
+ def test_bad_ohlcv_fails(self):
78
+ rows = [{"timestamp":"2026-01-01T00:00:00Z","open":"10","high":"9","low":"11","close":"10","volume":"-1"}]
79
+ report = audit_ohlcv_rows(rows)
80
+ self.assertEqual(report.status, "FAIL")
81
+ self.assertGreaterEqual(report.errors, 3)
82
+
83
+ def test_same_bar_entry_fails(self):
84
+ rows = [{
85
+ "signal_bar_close":"2026-01-01T00:05:00Z",
86
+ "entry_time":"2026-01-01T00:05:00Z",
87
+ "next_bar_open":"2026-01-01T00:05:00Z",
88
+ }]
89
+ codes = {x.code for x in audit_trade_rows(rows).findings}
90
+ self.assertIn("LOOKAHEAD_OR_SAME_BAR_ENTRY", codes)
91
+
92
+ def test_ambiguous_exit_fails(self):
93
+ rows = [{
94
+ "signal_bar_close":"2026-01-01T00:05:00Z",
95
+ "entry_time":"2026-01-01T00:10:00Z",
96
+ "next_bar_open":"2026-01-01T00:10:00Z",
97
+ "stop_hit":"true", "target_hit":"true",
98
+ }]
99
+ codes = {x.code for x in audit_trade_rows(rows).findings}
100
+ self.assertIn("AMBIGUOUS_SAME_BAR_EXIT", codes)
101
+
102
+ def test_report_json_is_byte_stable(self):
103
+ rows = [
104
+ {"timestamp":"2026-01-01T00:00:00Z","open":"10","high":"12","low":"9","close":"11"},
105
+ ]
106
+ a = audit_ohlcv_rows(rows)
107
+ b = audit_ohlcv_rows(rows)
108
+ self.assertEqual(a.to_json(), b.to_json())
109
+ self.assertIn('"report_schema_version":"1.0"', a.to_json())
110
+ self.assertIn('"tool_version":"0.2.1"', a.to_json())
111
+
112
+ def test_optional_input_sha256_is_emitted(self):
113
+ report = audit_ohlcv_rows([
114
+ {"timestamp":"2026-01-01T00:00:00Z","open":"10","high":"12","low":"9","close":"11"},
115
+ ])
116
+ report.input_sha256 = "a" * 64
117
+ rendered = report.to_json()
118
+ self.assertIn('"input_sha256":"' + ("a" * 64) + '"', rendered)
119
+
120
+ def test_manifest_round_trip(self):
121
+ with tempfile.TemporaryDirectory() as d:
122
+ root = Path(d)
123
+ f = root / "input.txt"
124
+ f.write_text("frozen\n")
125
+ m = root / "manifest.json"
126
+ m.write_text(json.dumps(build_manifest([f], root)))
127
+ self.assertEqual(verify_manifest(m, root).status, "PASS")
128
+ f.write_text("changed\n")
129
+ self.assertEqual(verify_manifest(m, root).status, "FAIL")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ unittest.main()