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.
- backtest_integrity_guard-0.2.1/LICENSE +21 -0
- backtest_integrity_guard-0.2.1/PKG-INFO +146 -0
- backtest_integrity_guard-0.2.1/README.md +120 -0
- backtest_integrity_guard-0.2.1/pyproject.toml +3 -0
- backtest_integrity_guard-0.2.1/setup.cfg +45 -0
- backtest_integrity_guard-0.2.1/setup.py +3 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard/__init__.py +2 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard/cli.py +100 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard/core.py +250 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard.egg-info/PKG-INFO +146 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard.egg-info/SOURCES.txt +14 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard.egg-info/dependency_links.txt +1 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard.egg-info/entry_points.txt +2 -0
- backtest_integrity_guard-0.2.1/src/backtest_integrity_guard.egg-info/top_level.txt +1 -0
- backtest_integrity_guard-0.2.1/tests/test_core.py +133 -0
|
@@ -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
|
+
[](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
|
+
[](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,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,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
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backtest_integrity_guard
|
|
@@ -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()
|