backtest-doctor 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backtest_doctor/__init__.py +2 -0
- backtest_doctor/__main__.py +2 -0
- backtest_doctor/adapters.py +71 -0
- backtest_doctor/audit.py +235 -0
- backtest_doctor/bundle.py +38 -0
- backtest_doctor/cli.py +92 -0
- backtest_doctor/explain.py +36 -0
- backtest_doctor/report.py +56 -0
- backtest_doctor/schema.py +75 -0
- backtest_doctor-0.4.0.dist-info/METADATA +119 -0
- backtest_doctor-0.4.0.dist-info/RECORD +15 -0
- backtest_doctor-0.4.0.dist-info/WHEEL +5 -0
- backtest_doctor-0.4.0.dist-info/entry_points.txt +2 -0
- backtest_doctor-0.4.0.dist-info/licenses/LICENSE +20 -0
- backtest_doctor-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Offline, bounded CSV adapters for *reported* settled trade returns.
|
|
2
|
+
|
|
3
|
+
These adapters cannot reconstruct signal timing from trade-summary exports.
|
|
4
|
+
No source code, pickle, native archives, account IDs, or proprietary data is read.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from .audit import _time, audit_stability
|
|
10
|
+
|
|
11
|
+
# All return columns are fractional returns, not percent points.
|
|
12
|
+
# backtesting.py ReturnPct / vectorbt Return / Freqtrade profit_ratio.
|
|
13
|
+
FORMATS = frozenset(('backtesting-py', 'vectorbt', 'freqtrade-csv'))
|
|
14
|
+
REQUIRED = {
|
|
15
|
+
'backtesting-py': ('ExitTime', 'ReturnPct'),
|
|
16
|
+
'vectorbt': ('Exit Timestamp', 'Return', 'Status'),
|
|
17
|
+
'freqtrade-csv': ('close_date', 'profit_ratio', 'is_open'),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def audit_imported_trades(rows, framework: str, additional_cost_bps: float = 0):
|
|
22
|
+
"""Audit a CSV of already settled trades without copying identity columns.
|
|
23
|
+
|
|
24
|
+
- Reported trade return may already incorporate fees and slippage.
|
|
25
|
+
- Additional cost scenarios are *extra stress*, not actual fee estimates.
|
|
26
|
+
- Missing/naive timestamps, open trades, and non-finite returns fail closed.
|
|
27
|
+
"""
|
|
28
|
+
if framework not in FORMATS:
|
|
29
|
+
raise ValueError('unsupported framework format')
|
|
30
|
+
if type(additional_cost_bps) not in (int, float) or not math.isfinite(additional_cost_bps) or additional_cost_bps < 0:
|
|
31
|
+
raise ValueError('additional cost must be finite and nonnegative')
|
|
32
|
+
if additional_cost_bps > 10_000:
|
|
33
|
+
raise ValueError('additional cost exceeds 100 percent')
|
|
34
|
+
if not rows:
|
|
35
|
+
raise ValueError('empty import')
|
|
36
|
+
|
|
37
|
+
ts_key, ret_key = REQUIRED[framework][:2]
|
|
38
|
+
mapped = []
|
|
39
|
+
for row in rows:
|
|
40
|
+
if any(key not in row or row[key] is None or not str(row[key]).strip() for key in REQUIRED[framework]):
|
|
41
|
+
raise ValueError('missing required framework field')
|
|
42
|
+
# Do not silently convert timezone-naive pandas timestamps to UTC.
|
|
43
|
+
timestamp = _time(row[ts_key])
|
|
44
|
+
try:
|
|
45
|
+
result = float(row[ret_key])
|
|
46
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
47
|
+
raise ValueError('invalid reported trade return') from exc
|
|
48
|
+
if not math.isfinite(result) or result <= -1:
|
|
49
|
+
raise ValueError('nonfinite or impossible reported return')
|
|
50
|
+
if framework == 'vectorbt' and row['Status'].strip().casefold() != 'closed':
|
|
51
|
+
raise ValueError('unsettled vectorbt trade not supported')
|
|
52
|
+
if framework == 'freqtrade-csv' and 'is_open' in row:
|
|
53
|
+
state = row['is_open'].strip().casefold()
|
|
54
|
+
if state not in ('false', '0', 'no'):
|
|
55
|
+
raise ValueError('freqtrade open or unknown trade state')
|
|
56
|
+
mapped.append({'exit_time': timestamp.isoformat(), 'gross_return': str(result)})
|
|
57
|
+
|
|
58
|
+
scenarios = (0,) if additional_cost_bps == 0 else (0, additional_cost_bps)
|
|
59
|
+
report = audit_stability(mapped, costs=scenarios)
|
|
60
|
+
# The inherited audit API calls its input gross_return, but native exports
|
|
61
|
+
# may *already* be net-of-fees. Never claim a reconstructed gross return.
|
|
62
|
+
report['audit_type'] = 'framework-trades'
|
|
63
|
+
report['metrics']['framework_format'] = framework
|
|
64
|
+
report['metrics']['reported_return_basis'] = 'framework-provided trade return; fee inclusion not independently verified'
|
|
65
|
+
report['metrics']['cost_scenario_interpretation'] = '0bps = unmodified reported return; positive bps = additional hypothetical per-trade drag'
|
|
66
|
+
report['metrics']['signal_timing_verified'] = False
|
|
67
|
+
report['findings'].append({'code': 'SIGNAL_CAUSALITY_NOT_VERIFIED', 'row': None, 'severity': 'WARNING'})
|
|
68
|
+
report['warnings'] += 1
|
|
69
|
+
if report['status'] != 'FAIL':
|
|
70
|
+
report['status'] = 'WARN'
|
|
71
|
+
return report
|
backtest_doctor/audit.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Offline CSV diagnostics; accepts no executable strategy code.
|
|
2
|
+
|
|
3
|
+
Reports intentionally contain finding *codes* and row numbers, never input values,
|
|
4
|
+
file paths, credentials, symbols, or strategy-level identifiers.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
MAX_FILE_BYTES = 10 * 1024 * 1024
|
|
18
|
+
MAX_ROWS = 100_000
|
|
19
|
+
MAX_COLUMNS = 64
|
|
20
|
+
MAX_FIELD_BYTES = 131_072
|
|
21
|
+
MAX_FINDINGS = 2_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _time(value):
|
|
25
|
+
value = str(value).strip()
|
|
26
|
+
if value.endswith('Z'):
|
|
27
|
+
value = value[:-1] + '+00:00'
|
|
28
|
+
stamp = datetime.fromisoformat(value)
|
|
29
|
+
if stamp.utcoffset() is None:
|
|
30
|
+
raise ValueError('timezone required')
|
|
31
|
+
return stamp.astimezone(timezone.utc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _num(value):
|
|
35
|
+
number = float(value)
|
|
36
|
+
if not math.isfinite(number):
|
|
37
|
+
raise ValueError('non-finite number')
|
|
38
|
+
return number
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _finding(code, row=None, severity='ERROR'):
|
|
42
|
+
return {'code': code, 'row': row, 'severity': severity}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _push(findings, code, row=None, severity='ERROR'):
|
|
46
|
+
if len(findings) < MAX_FINDINGS:
|
|
47
|
+
findings.append(_finding(code, row, severity))
|
|
48
|
+
elif len(findings) == MAX_FINDINGS:
|
|
49
|
+
findings.append(_finding('FINDING_LIMIT_REACHED', severity='ERROR'))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _base(kind, findings, count, extras=None):
|
|
53
|
+
errors = sum(f['severity'] == 'ERROR' for f in findings)
|
|
54
|
+
warnings = sum(f['severity'] == 'WARNING' for f in findings)
|
|
55
|
+
return {
|
|
56
|
+
'report_schema_version': '0.4',
|
|
57
|
+
'audit_type': kind,
|
|
58
|
+
'status': 'FAIL' if errors else ('WARN' if warnings else 'PASS'),
|
|
59
|
+
'errors': errors,
|
|
60
|
+
'warnings': warnings,
|
|
61
|
+
'rows_examined': count,
|
|
62
|
+
'findings': findings,
|
|
63
|
+
'metrics': extras or {},
|
|
64
|
+
'data_policy': 'aggregate-only; no raw inputs included',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_csv(path):
|
|
69
|
+
"""Read at most 10 MiB from a regular file, with no symlink following.
|
|
70
|
+
|
|
71
|
+
The byte cap is enforced even if the file grows after the initial stat.
|
|
72
|
+
This is not a general sandbox against hostile mounts or filesystem races.
|
|
73
|
+
"""
|
|
74
|
+
path = Path(path)
|
|
75
|
+
if path.is_symlink():
|
|
76
|
+
raise ValueError('symlink input is prohibited')
|
|
77
|
+
flags = os.O_RDONLY
|
|
78
|
+
if hasattr(os, 'O_NOFOLLOW'):
|
|
79
|
+
flags |= os.O_NOFOLLOW
|
|
80
|
+
fd = os.open(path, flags)
|
|
81
|
+
with os.fdopen(fd, 'rb') as handle:
|
|
82
|
+
st = os.fstat(handle.fileno())
|
|
83
|
+
if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE_BYTES:
|
|
84
|
+
raise ValueError('input is not a regular file or is too large')
|
|
85
|
+
raw = handle.read(MAX_FILE_BYTES + 1)
|
|
86
|
+
if len(raw) > MAX_FILE_BYTES:
|
|
87
|
+
raise ValueError('input exceeds file size limit')
|
|
88
|
+
content = raw.decode('utf-8-sig')
|
|
89
|
+
csv.field_size_limit(MAX_FIELD_BYTES)
|
|
90
|
+
reader = csv.DictReader(io.StringIO(content, newline=''), strict=True)
|
|
91
|
+
headers = reader.fieldnames
|
|
92
|
+
if (not headers or len(headers) > MAX_COLUMNS or any(not h for h in headers)
|
|
93
|
+
or len(set(headers)) != len(headers)):
|
|
94
|
+
raise ValueError('invalid or duplicate CSV columns')
|
|
95
|
+
rows = []
|
|
96
|
+
for i, row in enumerate(reader, start=1):
|
|
97
|
+
if i > MAX_ROWS:
|
|
98
|
+
raise ValueError('input exceeds row safety limit')
|
|
99
|
+
if None in row or any(v is None for v in row.values()):
|
|
100
|
+
raise ValueError('malformed CSV row')
|
|
101
|
+
rows.append(row)
|
|
102
|
+
return rows
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def audit_ohlcv(rows, interval_seconds=None):
|
|
106
|
+
findings, seen, prior = [], set(), None
|
|
107
|
+
required = ('timestamp', 'open', 'high', 'low', 'close', 'volume')
|
|
108
|
+
if interval_seconds is not None and (type(interval_seconds) is not int or interval_seconds <= 0):
|
|
109
|
+
raise ValueError('interval must be positive integer')
|
|
110
|
+
if not rows:
|
|
111
|
+
_push(findings, 'EMPTY_OHLCV')
|
|
112
|
+
for idx, row in enumerate(rows, start=2):
|
|
113
|
+
if not all(k in row for k in required):
|
|
114
|
+
_push(findings, 'MISSING_OHLCV_COLUMN', idx)
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
stamp = _time(row['timestamp'])
|
|
118
|
+
except (ValueError, TypeError, OverflowError):
|
|
119
|
+
_push(findings, 'INVALID_TIMESTAMP', idx)
|
|
120
|
+
continue
|
|
121
|
+
if stamp in seen:
|
|
122
|
+
_push(findings, 'DUPLICATE_TIMESTAMP', idx)
|
|
123
|
+
seen.add(stamp)
|
|
124
|
+
if prior is not None:
|
|
125
|
+
if stamp <= prior:
|
|
126
|
+
_push(findings, 'NON_MONOTONIC_TIMESTAMP', idx)
|
|
127
|
+
if interval_seconds is not None and stamp > prior:
|
|
128
|
+
delta = (stamp-prior).total_seconds()
|
|
129
|
+
if delta != interval_seconds:
|
|
130
|
+
_push(findings, 'IRREGULAR_CADENCE', idx, 'WARNING')
|
|
131
|
+
prior = stamp
|
|
132
|
+
try:
|
|
133
|
+
op, hi, lo, cl, vol = (_num(row[k]) for k in required[1:])
|
|
134
|
+
except (TypeError, ValueError, OverflowError):
|
|
135
|
+
_push(findings, 'INVALID_NUMERIC_FIELD', idx)
|
|
136
|
+
continue
|
|
137
|
+
if min(op, hi, lo, cl) <= 0 or vol < 0:
|
|
138
|
+
_push(findings, 'INVALID_PRICE_OR_VOLUME', idx)
|
|
139
|
+
if hi < max(op, cl, lo) or lo > min(op, cl, hi):
|
|
140
|
+
_push(findings, 'INVALID_CANDLE_GEOMETRY', idx)
|
|
141
|
+
return _base('ohlcv', findings, len(rows))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def audit_ledger(rows):
|
|
145
|
+
findings = []
|
|
146
|
+
required = ('signal_bar_close', 'entry_time', 'next_bar_open')
|
|
147
|
+
if not rows:
|
|
148
|
+
_push(findings, 'EMPTY_LEDGER')
|
|
149
|
+
for idx, row in enumerate(rows, start=2):
|
|
150
|
+
if not all(k in row for k in required):
|
|
151
|
+
_push(findings, 'MISSING_LEDGER_COLUMN', idx)
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
signal, entry, next_open = (_time(row[k]) for k in required)
|
|
155
|
+
except (ValueError, TypeError, OverflowError):
|
|
156
|
+
_push(findings, 'INVALID_TIMESTAMP', idx)
|
|
157
|
+
continue
|
|
158
|
+
if entry <= signal:
|
|
159
|
+
_push(findings, 'LOOKAHEAD_OR_SAME_BAR_ENTRY', idx)
|
|
160
|
+
if next_open <= signal or entry < next_open:
|
|
161
|
+
_push(findings, 'NEXT_BAR_EXECUTION_VIOLATION', idx)
|
|
162
|
+
if (row.get('stop_hit', '').strip().lower() in ('true', '1', 'yes')
|
|
163
|
+
and row.get('target_hit', '').strip().lower() in ('true', '1', 'yes')):
|
|
164
|
+
_push(findings, 'AMBIGUOUS_SAME_BAR_EXIT', idx, 'WARNING')
|
|
165
|
+
return _base('ledger', findings, len(rows))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _pf(values):
|
|
169
|
+
gains = sum(x for x in values if x > 0)
|
|
170
|
+
losses = -sum(x for x in values if x < 0)
|
|
171
|
+
if not math.isfinite(gains) or not math.isfinite(losses):
|
|
172
|
+
return None
|
|
173
|
+
return round(gains / losses, 6) if losses and math.isfinite(gains/losses) else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _compound_and_drawdown(values):
|
|
177
|
+
equity = peak = 1.0
|
|
178
|
+
worst = 0.0
|
|
179
|
+
for value in values:
|
|
180
|
+
if value <= -1:
|
|
181
|
+
return None, None
|
|
182
|
+
equity *= 1.0 + value
|
|
183
|
+
if not math.isfinite(equity):
|
|
184
|
+
return None, None
|
|
185
|
+
peak = max(peak, equity)
|
|
186
|
+
worst = max(worst, 1.0 - equity / peak)
|
|
187
|
+
return round(equity - 1, 6), round(worst, 6)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def audit_stability(rows, costs=(0, 8, 20)):
|
|
191
|
+
findings, parsed = [], []
|
|
192
|
+
costs = tuple(costs)
|
|
193
|
+
if not costs or len(costs) > 20:
|
|
194
|
+
raise ValueError('invalid cost scenarios')
|
|
195
|
+
for cost in costs:
|
|
196
|
+
if not isinstance(cost, (float, int)) or not math.isfinite(cost) or cost < 0:
|
|
197
|
+
raise ValueError('cost must be finite and non-negative')
|
|
198
|
+
for idx, row in enumerate(rows, start=2):
|
|
199
|
+
try:
|
|
200
|
+
stamp = _time(row['exit_time'])
|
|
201
|
+
ret = _num(row['gross_return'])
|
|
202
|
+
if ret <= -1:
|
|
203
|
+
raise ValueError('return is -100% or less')
|
|
204
|
+
except (KeyError, ValueError, TypeError, OverflowError):
|
|
205
|
+
_push(findings, 'INVALID_TRADE_RETURN', idx)
|
|
206
|
+
continue
|
|
207
|
+
parsed.append((stamp, ret))
|
|
208
|
+
parsed.sort(key=lambda v: v[0])
|
|
209
|
+
if len(parsed) < 30:
|
|
210
|
+
_push(findings, 'LOW_TRADE_COUNT', severity='WARNING')
|
|
211
|
+
if len({(t.year, t.month) for t, _ in parsed}) < 3:
|
|
212
|
+
_push(findings, 'SHORT_TIME_COVERAGE', severity='WARNING')
|
|
213
|
+
metrics = {'interpretation': 'sequential non-overlapping, equal-equity trade proxy; not actual portfolio NAV'}
|
|
214
|
+
if parsed:
|
|
215
|
+
monthly = defaultdict(float)
|
|
216
|
+
for stamp, ret in parsed:
|
|
217
|
+
monthly[(stamp.year, stamp.month)] += ret
|
|
218
|
+
metrics['positive_month_fraction'] = round(sum(x > 0 for x in monthly.values())/len(monthly), 6)
|
|
219
|
+
metrics['excluded_invalid_trades'] = len(rows) - len(parsed)
|
|
220
|
+
for cost in costs:
|
|
221
|
+
net = [ret - cost/10000 for _, ret in parsed]
|
|
222
|
+
compound, mdd = _compound_and_drawdown(net)
|
|
223
|
+
if compound is None:
|
|
224
|
+
_push(findings, 'UNDEFINED_COMPOUNDING_COST_SCENARIO', severity='WARNING')
|
|
225
|
+
metrics[f'cost_{cost:g}bps'] = {
|
|
226
|
+
'trade_count': len(net),
|
|
227
|
+
'win_rate': round(sum(x > 0 for x in net)/len(net), 6),
|
|
228
|
+
'expectancy': round(sum(net)/len(net), 8),
|
|
229
|
+
'profit_factor': _pf(net),
|
|
230
|
+
'compounded_trade_return_proxy': compound,
|
|
231
|
+
'max_drawdown_proxy': mdd,
|
|
232
|
+
}
|
|
233
|
+
else:
|
|
234
|
+
_push(findings, 'NO_VALID_TRADES')
|
|
235
|
+
return _base('stability', findings, len(rows), metrics)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Aggregation of independently audited, local CSV artifacts (never raw data)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .audit import audit_ohlcv, audit_ledger, audit_stability
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def audit_bundle(*, ohlcv_rows, ledger_rows, stability_rows, interval_seconds=None):
|
|
8
|
+
"""All three files are required; reports contain only aggregate diagnostics.
|
|
9
|
+
|
|
10
|
+
This does not establish independence, sufficient sample size or a genuine edge.
|
|
11
|
+
"""
|
|
12
|
+
reports = {
|
|
13
|
+
'ohlcv': audit_ohlcv(ohlcv_rows, interval_seconds=interval_seconds),
|
|
14
|
+
'ledger': audit_ledger(ledger_rows),
|
|
15
|
+
'stability': audit_stability(stability_rows),
|
|
16
|
+
}
|
|
17
|
+
n_error = sum(v['errors'] for v in reports.values())
|
|
18
|
+
n_warn = sum(v['warnings'] for v in reports.values())
|
|
19
|
+
return {
|
|
20
|
+
'report_schema_version': '0.4',
|
|
21
|
+
'audit_type': 'bundle',
|
|
22
|
+
'status': 'FAIL' if n_error else ('WARN' if n_warn else 'PASS'),
|
|
23
|
+
'errors': n_error,
|
|
24
|
+
'warnings': n_warn,
|
|
25
|
+
'rows_examined': sum(v['rows_examined'] for v in reports.values()),
|
|
26
|
+
'findings': [
|
|
27
|
+
{'source': key, 'code': f['code'], 'severity': f['severity'], 'row': f['row']}
|
|
28
|
+
for key, report in reports.items() for f in report['findings']
|
|
29
|
+
],
|
|
30
|
+
'metrics': {
|
|
31
|
+
'sections': {k: {'status': v['status'], 'errors': v['errors'],
|
|
32
|
+
'warnings': v['warnings'], 'rows_examined': v['rows_examined']}
|
|
33
|
+
for k, v in reports.items()},
|
|
34
|
+
'stability': reports['stability']['metrics'],
|
|
35
|
+
'interpretation': 'Independent static audits; passing does not prove causality or profitability',
|
|
36
|
+
},
|
|
37
|
+
'data_policy': 'aggregate-only; no raw inputs included',
|
|
38
|
+
}
|
backtest_doctor/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Offline-only CLI. JSON and HTML reports redact input values and paths."""
|
|
2
|
+
import argparse
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .audit import audit_ledger, audit_ohlcv, audit_stability, read_csv
|
|
11
|
+
from .bundle import audit_bundle
|
|
12
|
+
from .report import render_html
|
|
13
|
+
from .schema import load_columns, normalize_rows
|
|
14
|
+
from .adapters import FORMATS, audit_imported_trades
|
|
15
|
+
from .explain import explain_findings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _save_new_report(path, content):
|
|
19
|
+
"""Exclusive creation prevents accidental overwrite and output symlink following."""
|
|
20
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
21
|
+
if hasattr(os, 'O_NOFOLLOW'):
|
|
22
|
+
flags |= os.O_NOFOLLOW
|
|
23
|
+
fd = os.open(path, flags, 0o600)
|
|
24
|
+
try:
|
|
25
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as out:
|
|
26
|
+
out.write(content)
|
|
27
|
+
except BaseException:
|
|
28
|
+
try:
|
|
29
|
+
os.unlink(path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv=None):
|
|
36
|
+
parser = argparse.ArgumentParser(prog='backtest-doctor', description='Offline CSV backtest audit; no strategy execution or telemetry')
|
|
37
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
38
|
+
parser.add_argument('mode', choices=['ohlcv', 'ledger', 'stability', 'bundle', 'import-trades'])
|
|
39
|
+
parser.add_argument('file', type=Path, nargs='?')
|
|
40
|
+
parser.add_argument('--ohlcv-file', type=Path)
|
|
41
|
+
parser.add_argument('--ledger-file', type=Path)
|
|
42
|
+
parser.add_argument('--stability-file', type=Path)
|
|
43
|
+
parser.add_argument('--interval-seconds', type=int)
|
|
44
|
+
parser.add_argument('--framework', choices=sorted(FORMATS), help='Trade export CSV dialect (import-trades only)')
|
|
45
|
+
parser.add_argument('--additional-cost-bps', type=float, default=0.0, help='Extra hypothetical cost stress for import-trades; 0 means no new fees')
|
|
46
|
+
parser.add_argument('--html', type=Path, help='Create a privacy-minimal HTML report, without overwrite')
|
|
47
|
+
parser.add_argument('--columns', type=Path, help='Local JSON canonical-to-source column aliases')
|
|
48
|
+
parser.add_argument('--explain', action='store_true', help='Include data-free explanations for findings')
|
|
49
|
+
parser.add_argument('--fail-on-warn', action='store_true', help='Exit 1 for WARN as well as FAIL (CI mode)')
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
try:
|
|
52
|
+
columns = load_columns(args.columns) if args.columns is not None else {}
|
|
53
|
+
if args.mode != 'import-trades' and (args.framework is not None or args.additional_cost_bps != 0):
|
|
54
|
+
raise ValueError('framework settings require import-trades')
|
|
55
|
+
if args.mode == 'import-trades':
|
|
56
|
+
if (args.file is None or args.framework is None or args.columns is not None
|
|
57
|
+
or args.interval_seconds is not None or any((args.ohlcv_file, args.ledger_file, args.stability_file))):
|
|
58
|
+
raise ValueError('import-trades requires CSV and framework only')
|
|
59
|
+
report = audit_imported_trades(read_csv(args.file), args.framework, args.additional_cost_bps)
|
|
60
|
+
elif args.mode == 'bundle':
|
|
61
|
+
if (args.file is not None or args.ohlcv_file is None or args.ledger_file is None
|
|
62
|
+
or args.stability_file is None):
|
|
63
|
+
raise ValueError('bundle requires three named inputs and no positional file')
|
|
64
|
+
report = audit_bundle(
|
|
65
|
+
ohlcv_rows=normalize_rows(read_csv(args.ohlcv_file), 'ohlcv', columns),
|
|
66
|
+
ledger_rows=normalize_rows(read_csv(args.ledger_file), 'ledger', columns),
|
|
67
|
+
stability_rows=normalize_rows(read_csv(args.stability_file), 'stability', columns),
|
|
68
|
+
interval_seconds=args.interval_seconds,
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
if args.file is None or any((args.ohlcv_file, args.ledger_file, args.stability_file)):
|
|
72
|
+
raise ValueError('invalid input selection')
|
|
73
|
+
rows = normalize_rows(read_csv(args.file), args.mode, columns)
|
|
74
|
+
if args.mode == 'ohlcv':
|
|
75
|
+
report = audit_ohlcv(rows, args.interval_seconds)
|
|
76
|
+
elif args.mode == 'ledger':
|
|
77
|
+
report = audit_ledger(rows)
|
|
78
|
+
else:
|
|
79
|
+
report = audit_stability(rows)
|
|
80
|
+
if args.explain:
|
|
81
|
+
explain_findings(report)
|
|
82
|
+
if args.html is not None:
|
|
83
|
+
_save_new_report(args.html, render_html(report))
|
|
84
|
+
print(json.dumps(report, allow_nan=False, sort_keys=True, separators=(',', ':')))
|
|
85
|
+
return 1 if report['status'] == 'FAIL' or (args.fail_on_warn and report['status'] == 'WARN') else 0
|
|
86
|
+
except (ValueError, OSError, UnicodeError, OverflowError, csv.Error):
|
|
87
|
+
print('AUDIT_ERROR: invalid or inaccessible input (details redacted)', file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == '__main__':
|
|
92
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Fixed, data-free explanations for diagnostic finding codes.
|
|
2
|
+
Descriptions never contain input rows, paths, identifiers, or timestamps.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
CATALOG = {
|
|
6
|
+
"EMPTY_OHLCV": ("No OHLCV rows were supplied.", "Export at least one timestamped candle and verify the input file is not empty."),
|
|
7
|
+
"MISSING_OHLCV_COLUMN": ("A candle row is missing one or more required OHLCV fields.", "Verify the exported header and column mapping; do not invent missing prices."),
|
|
8
|
+
"INVALID_TIMESTAMP": ("A timestamp is invalid or lacks an explicit timezone.", "Check the original time source and export timezone-aware ISO timestamps; never assume UTC."),
|
|
9
|
+
"DUPLICATE_TIMESTAMP": ("Two or more candle rows share a timestamp.", "Check whether duplicate candles represent duplicated imports or legitimate distinct intervals."),
|
|
10
|
+
"NON_MONOTONIC_TIMESTAMP": ("Candle timestamps did not increase strictly.", "Inspect the export ordering and any overlapping data partitions before sorting."),
|
|
11
|
+
"IRREGULAR_CADENCE": ("A positive interval differs from the declared cadence.", "Check sessions, exchange downtime, and missing bars; do not fill gaps without evidence."),
|
|
12
|
+
"INVALID_NUMERIC_FIELD": ("A required candle field is not a finite number.", "Check numeric formatting, missing values, and exporter conversions."),
|
|
13
|
+
"INVALID_PRICE_OR_VOLUME": ("A price is nonpositive or volume is negative.", "Check source adjustments, units, and upstream data quality; do not clamp values blindly."),
|
|
14
|
+
"INVALID_CANDLE_GEOMETRY": ("OHLC values violate high/low envelope consistency.", "Compare the candle to original source data and check adjustment/aggregation procedures."),
|
|
15
|
+
"EMPTY_LEDGER": ("No declared trade timing rows were supplied.", "Provide signal-close, entry, and next-open timestamps from a reproducible trade export."),
|
|
16
|
+
"MISSING_LEDGER_COLUMN": ("A trade timing row is missing a required timestamp field.", "Verify the ledger export and expected column names without synthesizing timing evidence."),
|
|
17
|
+
"LOOKAHEAD_OR_SAME_BAR_ENTRY": ("An entry was not strictly after the declared signal bar close.", "Reconcile indicator availability, decision timestamp, and execution timing; do not shift timestamps to hide the violation."),
|
|
18
|
+
"NEXT_BAR_EXECUTION_VIOLATION": ("Declared next-bar timing is inconsistent with the entry.", "Check the next executable bar open and ensure entry was not recorded before it."),
|
|
19
|
+
"AMBIGUOUS_SAME_BAR_EXIT": ("Stop and target are both declared hit within one bar.", "Use lower-timeframe or execution evidence and document the intrabar priority rule."),
|
|
20
|
+
"INVALID_TRADE_RETURN": ("A settled trade has an invalid timestamp or return.", "Check timezone, return units, and whether the record is settled; do not guess missing returns."),
|
|
21
|
+
"LOW_TRADE_COUNT": ("The sample contains fewer than 30 valid trades.", "Collect additional genuinely out-of-sample trades before interpreting stability measures."),
|
|
22
|
+
"SHORT_TIME_COVERAGE": ("Valid trades span fewer than three distinct calendar months.", "Extend the independent observation period; avoid treating a short window as robust evidence."),
|
|
23
|
+
"UNDEFINED_COMPOUNDING_COST_SCENARIO": ("A cost-stressed return cannot be compounded as a simple trade proxy.", "Inspect return-unit assumptions and cost magnitude; do not interpret the undefined proxy as account NAV."),
|
|
24
|
+
"NO_VALID_TRADES": ("No valid settled trade returns remained after validation.", "Check required fields and source export quality before computing performance metrics."),
|
|
25
|
+
"FINDING_LIMIT_REACHED": ("The diagnostics limit was reached.", "Address the first listed failures, then rerun on a bounded subset or corrected export."),
|
|
26
|
+
"SIGNAL_CAUSALITY_NOT_VERIFIED": ("The trade-summary CSV does not establish when signals became knowable.", "Supply a separately auditable signal-to-fill ledger before making causal claims."),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def explain_findings(report):
|
|
30
|
+
"""Add an optional static guide only for emitted, recognized codes."""
|
|
31
|
+
codes = sorted({f["code"] for f in report["findings"] if f.get("code") in CATALOG})
|
|
32
|
+
report["explanations"] = [
|
|
33
|
+
{"code": code, "meaning": CATALOG[code][0], "next_step": CATALOG[code][1]}
|
|
34
|
+
for code in codes
|
|
35
|
+
]
|
|
36
|
+
return report
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Standalone offline HTML reporting. Never embeds input rows or paths."""
|
|
2
|
+
from html import escape
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_html(report):
|
|
7
|
+
"""Render aggregate-only results using escaped values and zero external assets."""
|
|
8
|
+
title = escape(report['status'])
|
|
9
|
+
parts = []
|
|
10
|
+
sections = report.get('metrics', {}).get('sections', {})
|
|
11
|
+
for name, item in sections.items():
|
|
12
|
+
parts.append('<div class="section"><strong>'+escape(name.upper())+'</strong><span class="state">'
|
|
13
|
+
+escape(item['status'])+'</span><small>Rows: '+str(item['rows_examined'])
|
|
14
|
+
+' · Errors: '+str(item['errors'])+' · Warnings: '+str(item['warnings'])+'</small></div>')
|
|
15
|
+
rows = ''.join(
|
|
16
|
+
'<tr><td>'+escape(str(f.get('source','-')))+'</td><td>'+escape(f['code'])+
|
|
17
|
+
'</td><td>'+escape(f['severity'])+'</td><td>'+
|
|
18
|
+
escape(str(f['row']) if f['row'] is not None else '-')+'</td></tr>'
|
|
19
|
+
for f in report['findings']
|
|
20
|
+
)
|
|
21
|
+
explanations = report.get('explanations', [])
|
|
22
|
+
guide = ''.join(
|
|
23
|
+
'<section><h3>'+escape(str(item['code']))+'</h3><p>'+escape(str(item['meaning']))+
|
|
24
|
+
'</p><p><strong>Check:</strong> '+escape(str(item['next_step']))+'</p></section>'
|
|
25
|
+
for item in explanations
|
|
26
|
+
)
|
|
27
|
+
metrics = escape(json.dumps(report['metrics'], ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False))
|
|
28
|
+
return ('''<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
30
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
|
|
31
|
+
<title>Backtest Doctor — Audit</title>
|
|
32
|
+
<style>:root{color-scheme:light}body{font:15px/1.6 system-ui, sans-serif;margin:0;background:#f4f7fb;color:#17253a}
|
|
33
|
+
main{max-width:900px;margin:40px auto;padding:0 22px}h1{font-size:30px;margin:0}h2{font-size:18px;margin-top:28px}
|
|
34
|
+
.kicker{color:#60738c;text-transform:uppercase;letter-spacing:.14em;font-size:11px;font-weight:700}
|
|
35
|
+
.top{background:#142c48;color:#fff;padding:27px;border-radius:16px}.top p{color:#c7d9e8}
|
|
36
|
+
.status{display:inline-block;background:#fff;color:#142c48;border-radius:8px;padding:5px 14px;font-weight:800}
|
|
37
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:12px;margin-top:16px}
|
|
38
|
+
.panel,.section{background:#fff;border:1px solid #dce5f1;border-radius:12px;padding:16px}
|
|
39
|
+
.panel strong{display:block;font-size:24px}.panel span{font-size:12px;color:#60738c}
|
|
40
|
+
.section{display:flex;gap:12px;align-items:center;margin-bottom:8px}.section small{color:#60738c;margin-left:auto}
|
|
41
|
+
.state{background:#eaf0f7;border-radius:6px;padding:2px 8px;font-size:12px}
|
|
42
|
+
table{border-collapse:collapse;background:white;width:100%;font-size:13px;overflow-wrap:anywhere}
|
|
43
|
+
th,td{border-bottom:1px solid #dde7f1;padding:9px;text-align:left}
|
|
44
|
+
pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#fff;border:1px solid #dce5f1;padding:16px;border-radius:10px}
|
|
45
|
+
small,.foot{color:#60738c}details{margin-bottom:32px}
|
|
46
|
+
</style></head><body><main><div class="top"><div class="kicker">Offline diagnostic report · no telemetry</div>
|
|
47
|
+
<h1>Backtest Doctor</h1><p>Evidence checks are not proof of strategy profitability.</p>
|
|
48
|
+
<span class="status">'''+title+'''</span></div><div class="grid">
|
|
49
|
+
<div class="panel"><strong>'''+str(report['errors'])+'''</strong><span>Errors</span></div>
|
|
50
|
+
<div class="panel"><strong>'''+str(report['warnings'])+'''</strong><span>Warnings</span></div>
|
|
51
|
+
<div class="panel"><strong>'''+str(report['rows_examined'])+'''</strong><span>Rows examined</span></div></div>
|
|
52
|
+
<h2>Sections</h2>'''+(''.join(parts) or '<p>No combined sections in this mode.</p>')+'''
|
|
53
|
+
<h2>Evidence findings</h2><table><thead><tr><th>Mode</th><th>Code</th><th>Severity</th><th>Row</th></tr></thead><tbody>'''
|
|
54
|
+
+rows+'''</tbody></table>'''+('<h2>How to investigate</h2>'+guide if explanations else '')+'''<h2>Aggregate metrics</h2><details><summary>Show summary data</summary><pre>'''+metrics+'''</pre></details>
|
|
55
|
+
<p class="foot">Input rows, filenames, identifiers, account data and strategy signals are excluded from the report.
|
|
56
|
+
Metrics use simplifying assumptions. No external scripts, fonts, images or network requests.</p></main></body></html>''')
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Offline, declarative CSV column aliases; never executes user configuration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
MAX_SCHEMA_BYTES = 16 * 1024
|
|
10
|
+
CANONICAL_FIELDS = {
|
|
11
|
+
'ohlcv': frozenset(('timestamp', 'open', 'high', 'low', 'close', 'volume')),
|
|
12
|
+
'ledger': frozenset(('signal_bar_close', 'entry_time', 'next_bar_open',
|
|
13
|
+
'stop_hit', 'target_hit')),
|
|
14
|
+
'stability': frozenset(('exit_time', 'gross_return')),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _no_duplicate_keys(pairs):
|
|
19
|
+
result = {}
|
|
20
|
+
for name, value in pairs:
|
|
21
|
+
if name in result:
|
|
22
|
+
raise ValueError('duplicate JSON key')
|
|
23
|
+
result[name] = value
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_columns(path):
|
|
28
|
+
"""Read small trusted local JSON, rejecting duplicates and path symlinks."""
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if path.is_symlink():
|
|
31
|
+
raise ValueError('symlink configuration rejected')
|
|
32
|
+
flags = os.O_RDONLY
|
|
33
|
+
if hasattr(os, 'O_NOFOLLOW'):
|
|
34
|
+
flags |= os.O_NOFOLLOW
|
|
35
|
+
fd = os.open(path, flags)
|
|
36
|
+
with os.fdopen(fd, 'rb') as f:
|
|
37
|
+
info = os.fstat(f.fileno())
|
|
38
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_SCHEMA_BYTES:
|
|
39
|
+
raise ValueError('invalid configuration file')
|
|
40
|
+
data = f.read(MAX_SCHEMA_BYTES + 1)
|
|
41
|
+
if len(data) > MAX_SCHEMA_BYTES:
|
|
42
|
+
raise ValueError('oversized configuration file')
|
|
43
|
+
document = json.loads(data.decode('utf-8-sig'), object_pairs_hook=_no_duplicate_keys)
|
|
44
|
+
if not isinstance(document, dict) or not document:
|
|
45
|
+
raise ValueError('invalid configuration structure')
|
|
46
|
+
result = {}
|
|
47
|
+
for mode, aliases in document.items():
|
|
48
|
+
if mode not in CANONICAL_FIELDS or not isinstance(aliases, dict) or not aliases:
|
|
49
|
+
raise ValueError('invalid configuration section')
|
|
50
|
+
if not set(aliases).issubset(CANONICAL_FIELDS[mode]):
|
|
51
|
+
raise ValueError('unknown canonical column')
|
|
52
|
+
for value in aliases.values():
|
|
53
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 128:
|
|
54
|
+
raise ValueError('invalid source column name')
|
|
55
|
+
if len(set(aliases.values())) != len(aliases):
|
|
56
|
+
raise ValueError('ambiguous source column mapping')
|
|
57
|
+
result[mode] = aliases
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_rows(rows, mode, columns):
|
|
62
|
+
"""Rename headers without changing input values; reject collisions."""
|
|
63
|
+
aliases = columns.get(mode, {})
|
|
64
|
+
if not aliases:
|
|
65
|
+
return rows
|
|
66
|
+
normalized = []
|
|
67
|
+
for row in rows:
|
|
68
|
+
for canonical, original in aliases.items():
|
|
69
|
+
if original not in row or (canonical != original and canonical in row):
|
|
70
|
+
raise ValueError('missing or ambiguous mapped source column')
|
|
71
|
+
copy = dict(row)
|
|
72
|
+
for canonical, original in aliases.items():
|
|
73
|
+
copy[canonical] = copy[original]
|
|
74
|
+
normalized.append(copy)
|
|
75
|
+
return normalized
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backtest-doctor
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Offline backtest integrity checks for OHLCV, trade timing, and stability
|
|
5
|
+
Author: suguobin2021
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/suguobin2021/backtest-doctor
|
|
8
|
+
Project-URL: Documentation, https://github.com/suguobin2021/backtest-doctor/blob/main/docs/QUICKSTART.md
|
|
9
|
+
Project-URL: Source, https://github.com/suguobin2021/backtest-doctor
|
|
10
|
+
Project-URL: Issues, https://github.com/suguobin2021/backtest-doctor/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/suguobin2021/backtest-doctor/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: backtesting,audit,data-validation,lookahead-bias,trading,finance
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Backtest Doctor
|
|
28
|
+
|
|
29
|
+
[](LICENSE)
|
|
30
|
+
[](pyproject.toml)
|
|
31
|
+
|
|
32
|
+
**An offline, zero-runtime-dependency integrity check for exported backtests.** Validate timestamped OHLCV data, declared signal-to-fill timing, and how aggregate trade returns change under transaction costs. Produce deterministic, minimal JSON and self-contained HTML reports without including raw trades or filenames.
|
|
33
|
+
|
|
34
|
+
> This tool checks the evidence you supply. It does **not** inspect or execute strategy source code; it cannot prove a strategy is free of look-ahead bias, overfitting, survivorship bias, or execution risk. `PASS` means only that the implemented checks found no violations in the supplied fields.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+. The independently verified public package is currently a **prerelease**, not a stable release. Install the verified version explicitly:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
python -m pip install --pre "backtest-doctor==0.4.0rc4"
|
|
42
|
+
python -m backtest_doctor --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Alternatively, install from the [audited GitHub Release](https://github.com/suguobin2021/backtest-doctor/releases/tag/v0.4.0rc4) or from this source checkout:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
python -m pip install .
|
|
49
|
+
python -m backtest_doctor --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
[PyPI project](https://pypi.org/project/backtest-doctor/0.4.0rc4/) · [Distribution guidance](docs/PYPI_PUBLISHING.md).
|
|
53
|
+
|
|
54
|
+
## 30-second synthetic demo
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
# Run from the repository root; ALL sample files are synthetic.
|
|
58
|
+
python -m backtest_doctor bundle \
|
|
59
|
+
--ohlcv-file examples/ohlcv_good.csv \
|
|
60
|
+
--ledger-file examples/ledger_bad.csv \
|
|
61
|
+
--stability-file examples/stability_demo.csv \
|
|
62
|
+
--html doctor-report.html --fail-on-warn
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The intentionally bad ledger produces `FAIL` and exits **1**; this is expected, not an installation failure. Try `examples/ledger_good.csv` instead to see a clean causal timing check. Stability may still return `WARN` for small samples. The command exits `0` for PASS, `1` for FAIL (and WARN with `--fail-on-warn`), and `2` for rejected input. For an HTML report opened locally, use a **new filename**; the CLI will not overwrite an existing file.
|
|
66
|
+
|
|
67
|
+
For a **complete cross-platform, beginner-friendly walkthrough**, follow [the synthetic quickstart](docs/QUICKSTART.md). For the manual, secretless PyPI publishing process, see [the distribution guide](docs/PYPI_PUBLISHING.md).
|
|
68
|
+
|
|
69
|
+
For CI integration in other repositories, see the [experimental GitHub Action](docs/ACTION_USAGE.md).
|
|
70
|
+
|
|
71
|
+
Verified public-only [three-framework format guide](docs/FRAMEWORK_COMPATIBILITY.md), [synthetic showcase](docs/SHOWCASE.md), [Android Termux guide](docs/TERMUX.md), and [changelog](CHANGELOG.md).
|
|
72
|
+
|
|
73
|
+
Add `--explain` to any audit command for **fixed, data-free diagnostic explanations and investigation suggestions**. Explanations are opt-in, preserving the default JSON format. See the [finding guide](docs/FINDING_CODES.md).
|
|
74
|
+
|
|
75
|
+
**NEW: experimental framework-export CSV adapters:** inspect settled trade summaries from `backtesting.py`, `vectorbt`, or a manually prepared Freqtrade CSV subset (not native Freqtrade ZIP/JSON):
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
python -m backtest_doctor import-trades examples/backtesting_py_synthetic.csv --framework backtesting-py
|
|
79
|
+
python -m backtest_doctor import-trades examples/vectorbt_synthetic.csv --framework vectorbt
|
|
80
|
+
python -m backtest_doctor import-trades examples/freqtrade_csv_synthetic.csv --framework freqtrade-csv
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
These checks cannot verify causal signal timing, and they do not double-count framework-reported costs by default. See [framework CSV contracts, known limits and privacy model](docs/FRAMEWORK_TRADES.md).
|
|
84
|
+
|
|
85
|
+
For synthetic examples of alternate CSV column names (without copying any private exports), see [declarative column mapping](docs/COLUMN_MAPPING.md).
|
|
86
|
+
|
|
87
|
+
## Checks
|
|
88
|
+
|
|
89
|
+
| Mode | Input | Detects / summarizes |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `ohlcv` | CSV with `timestamp,open,high,low,close,volume` | Invalid candle geometry, duplicate/non-monotone timestamps, irregular cadence (when configured) |
|
|
92
|
+
| `ledger` | CSV with `signal_bar_close,entry_time,next_bar_open` | Entry before or at signal close; entry before claimed next bar; both stop and target hit within the same bar |
|
|
93
|
+
| `stability` | CSV with `exit_time,gross_return` | Trade count, time-coverage warnings, cost-scenario expectancy, PF, win rate, simplified sequential equity/drawdown proxies |
|
|
94
|
+
| `bundle` | All three CSVs | All checks, one aggregate report and exit status |
|
|
95
|
+
|
|
96
|
+
Single-mode invocation:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
python -m backtest_doctor ohlcv examples/ohlcv_good.csv --interval-seconds 60
|
|
100
|
+
python -m backtest_doctor ledger examples/ledger_bad.csv
|
|
101
|
+
python -m backtest_doctor stability examples/stability_demo.csv
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Profit factor is undefined (reported as `null`) if no negative trades exist.** All stability returns are fractions: `0.01` means 1%. Compounded returns and drawdown are explicitly **proxies**, not portfolio NAV or account-level PnL. Overlapping trades, leverage, funding, slippage, execution limits and corporate actions are not reconstructed. Costs are specified in basis points per trade (default 0, 8, 20 bps).
|
|
105
|
+
|
|
106
|
+
## Privacy model
|
|
107
|
+
|
|
108
|
+
No networking, telemetry, trade execution, or strategy source evaluation. Reads local regular CSV files only (up to 10 MiB, 100,000 rows) and emits aggregate metrics and coded findings only. It is intentionally **not a security sandbox**: do not use it to evaluate untrusted adversarial filesystem access, and inspect reports before sharing. See [SECURITY.md](SECURITY.md).
|
|
109
|
+
|
|
110
|
+
This repo uses synthetic fixtures only. It has no live trading records, private quantitative research databases, exchange API keys, real account IDs, strategy-specific parameters, server credentials, or licensed market datasets. Only explicitly allowlisted code, docs, tests, and synthetic fixtures are bundled by the build process.
|
|
111
|
+
|
|
112
|
+
## Contribute and reproducibility
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
python -m unittest discover -s tests -v
|
|
116
|
+
python -m pip wheel . --no-deps -w dist
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md), [docs/LIMITATIONS.md](docs/LIMITATIONS.md), and [docs/RELEASE_CHECKLIST.md](docs/RELEASE_CHECKLIST.md). MIT-licensed. No claim of investment performance or financial advice.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
backtest_doctor/__init__.py,sha256=nTIQFZSTonCECAWH-oW2CRaqkvS-3tlQ5TOHxUVC-HE,103
|
|
2
|
+
backtest_doctor/__main__.py,sha256=ee5vE0xcUZM3fPmxicvcw-IJXkm6nOwxARREHBWpF8Q,47
|
|
3
|
+
backtest_doctor/adapters.py,sha256=DV-83ElxXgk6reWkSPvRC0HjecK1QApSY8e-39R4vqk,3679
|
|
4
|
+
backtest_doctor/audit.py,sha256=LznOMyje2vyL8jLelo85SYTwpIOrVq9DhzkytPNyCDI,9064
|
|
5
|
+
backtest_doctor/bundle.py,sha256=MxscUW2JATGB5BHt7G0D3SpRP2fMtssk15ZkXE-TkaE,1736
|
|
6
|
+
backtest_doctor/cli.py,sha256=KcWz5zuN2Io6cHiEWLsaN6u7KzQv5kDGxYwrN8rVlD4,4782
|
|
7
|
+
backtest_doctor/explain.py,sha256=xLFvh1kf2rWXX8QinfQVHVHqkvnmDj3xknGoLYFKZOg,4245
|
|
8
|
+
backtest_doctor/report.py,sha256=1JjTwlmX9MZMPGCzLX64kcN-4s0VoGeekoh0xsd18UY,4214
|
|
9
|
+
backtest_doctor/schema.py,sha256=tIRuM-UzwDrbflFro2unMwm0ADMfwDIE5oXqSelgxP8,2903
|
|
10
|
+
backtest_doctor-0.4.0.dist-info/licenses/LICENSE,sha256=BSP5cQzsOReke6y6KNmiVrZUZWdUs7TPaTm5MxJf4ow,1068
|
|
11
|
+
backtest_doctor-0.4.0.dist-info/METADATA,sha256=zzjmNdgU6x_vuQ10EumcPxJAwdHxMVE0Xl5KFTCwLJA,7425
|
|
12
|
+
backtest_doctor-0.4.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
backtest_doctor-0.4.0.dist-info/entry_points.txt,sha256=IOoRcp1cz6dUkeJqJzITn4JtrebjI5wmPnF9eDjKmeo,61
|
|
14
|
+
backtest_doctor-0.4.0.dist-info/top_level.txt,sha256=c5VjrRGve1aSEQ42W3h_KrG-pOgq9nfljyczDH7Y4rE,16
|
|
15
|
+
backtest_doctor-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
Copyright (c) 2026 suguobin2021
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backtest_doctor
|