backtest-audit-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auditkit/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """auditkit — a tiny, dependency-free toolkit for auditing backtests.
2
+
3
+ The toolkit answers one question: *is this strategy real, or did the backtest
4
+ lie to you?* It ships four independent checkers:
5
+
6
+ * ``auditkit.checks.lookahead`` — AST-based look-ahead / future-function lint.
7
+ * ``auditkit.checks.invariants`` — ledger invariants (equity identity, wd_cum, monotonic counters).
8
+ * ``auditkit.checks.mutants`` — mutation testing: which bugs would your tests *not* catch?
9
+ * ``auditkit.checks.fees`` — fee / funding / slippage accounting checks.
10
+
11
+ Everything is pure standard library (Python 3.9+). No pandas, no numpy,
12
+ no network access.
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+ __all__ = ["checks"]
@@ -0,0 +1,5 @@
1
+ """Checkers bundled with auditkit."""
2
+
3
+ from . import fees, invariants, lookahead, mutants
4
+
5
+ __all__ = ["lookahead", "invariants", "mutants", "fees"]
@@ -0,0 +1,184 @@
1
+ """Fee / funding / slippage accounting.
2
+
3
+ Costs are where optimistic backtests hide. Three failure modes dominate:
4
+
5
+ * **one-sided fees** — the engine charges the entry fee but forgets the exit
6
+ (or vice-versa), so every trade is quietly cheaper than reality;
7
+ * **funding charged once** — a perp position held for ``k`` bars should pay
8
+ funding ``k`` times, but the engine deducts a single period;
9
+ * **double-counted slippage** — slippage is applied to both the raw fill *and*
10
+ the already-slippaged price, charging it twice per fill.
11
+
12
+ Each trade is a ``dict``::
13
+
14
+ {
15
+ "id": "t1",
16
+ "entry_notional": 10_000.0,
17
+ "exit_notional": 10_000.0, # optional, defaults to entry_notional
18
+ "holding_bars": 8,
19
+ "fee_paid": 10.0, # total round-trip fee actually charged
20
+ "funding_paid": 8.0, # total funding actually charged
21
+ "slippage_paid": 4.0, # total slippage cost actually charged
22
+ }
23
+
24
+ Rates are in **basis points** (1 bp = 0.01%).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from typing import List, Optional, Sequence
31
+
32
+ __all__ = ["FeeIssue", "check_trades", "check_trade", "DEFAULT_RATES"]
33
+
34
+ BPS = 10_000.0
35
+ DEFAULT_RATES = {
36
+ "fee_bps": 5.0, # per side (taker)
37
+ "funding_bps_per_bar": 1.0,
38
+ "slippage_bps": 2.0, # per fill
39
+ }
40
+ REL_TOL = 0.05 # 5% tolerance on each estimate
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FeeIssue:
45
+ rule: str
46
+ trade_id: object
47
+ message: str
48
+ expected: Optional[float] = None
49
+ actual: Optional[float] = None
50
+
51
+ def as_dict(self) -> dict:
52
+ return {
53
+ "rule": self.rule,
54
+ "trade_id": self.trade_id,
55
+ "message": self.message,
56
+ "expected": self.expected,
57
+ "actual": self.actual,
58
+ }
59
+
60
+ def __str__(self) -> str: # pragma: no cover - cosmetic
61
+ return f"[{self.rule}] trade={self.trade_id}: {self.message}"
62
+
63
+
64
+ def _f(trade: dict, key: str, default: float = 0.0) -> float:
65
+ val = trade.get(key, default)
66
+ try:
67
+ return float(val)
68
+ except (TypeError, ValueError):
69
+ return default
70
+
71
+
72
+ def _outside(actual: float, expected: float, rel_tol: float) -> Optional[str]:
73
+ """Return ``"low"``, ``"high"``, or ``None`` relative to ``expected``."""
74
+ if expected <= 0:
75
+ return "high" if actual > 1e-9 else None
76
+ ratio = actual / expected
77
+ if ratio < 1 - rel_tol:
78
+ return "low"
79
+ if ratio > 1 + rel_tol:
80
+ return "high"
81
+ return None
82
+
83
+
84
+ def check_trade(
85
+ trade: dict,
86
+ fee_bps: float = DEFAULT_RATES["fee_bps"],
87
+ funding_bps_per_bar: float = DEFAULT_RATES["funding_bps_per_bar"],
88
+ slippage_bps: float = DEFAULT_RATES["slippage_bps"],
89
+ rel_tol: float = REL_TOL,
90
+ ) -> List[FeeIssue]:
91
+ issues: List[FeeIssue] = []
92
+ tid = trade.get("id", "?")
93
+ entry = _f(trade, "entry_notional")
94
+ exit_n = _f(trade, "exit_notional", entry) or entry
95
+ bars = int(_f(trade, "holding_bars", 1)) or 1
96
+
97
+ # --- round-trip fees (both sides) ------------------------------------- #
98
+ expected_fee = (entry + exit_n) * fee_bps / BPS
99
+ actual_fee = _f(trade, "fee_paid", float("nan"))
100
+ if actual_fee == actual_fee: # not NaN -> provided
101
+ side = _outside(actual_fee, expected_fee, rel_tol)
102
+ if side == "low":
103
+ issues.append(
104
+ FeeIssue(
105
+ "ONE_SIDED_FEE",
106
+ tid,
107
+ f"fee {actual_fee:.4f} < round-trip expectation "
108
+ f"{expected_fee:.4f} (entry+exit) -- likely only one side charged",
109
+ expected_fee,
110
+ actual_fee,
111
+ )
112
+ )
113
+ elif side == "high":
114
+ issues.append(
115
+ FeeIssue(
116
+ "FEE_OVERCHARGE",
117
+ tid,
118
+ f"fee {actual_fee:.4f} > round-trip expectation {expected_fee:.4f}",
119
+ expected_fee,
120
+ actual_fee,
121
+ )
122
+ )
123
+
124
+ # --- funding per bar -------------------------------------------------- #
125
+ expected_funding = entry * funding_bps_per_bar / BPS * bars
126
+ actual_funding = _f(trade, "funding_paid", float("nan"))
127
+ if actual_funding == actual_funding and bars > 1:
128
+ side = _outside(actual_funding, expected_funding, rel_tol)
129
+ if side == "low":
130
+ issues.append(
131
+ FeeIssue(
132
+ "FUNDING_ONCE",
133
+ tid,
134
+ f"funding {actual_funding:.4f} for {bars} bars < "
135
+ f"{expected_funding:.4f} -- charged once, not per bar",
136
+ expected_funding,
137
+ actual_funding,
138
+ )
139
+ )
140
+ elif side == "high":
141
+ issues.append(
142
+ FeeIssue(
143
+ "FUNDING_OVERCHARGE",
144
+ tid,
145
+ f"funding {actual_funding:.4f} > {expected_funding:.4f} "
146
+ f"for {bars} bars",
147
+ expected_funding,
148
+ actual_funding,
149
+ )
150
+ )
151
+
152
+ # --- slippage once per fill (round trip = two fills) ------------------ #
153
+ expected_slip = (entry + exit_n) * slippage_bps / BPS
154
+ actual_slip = _f(trade, "slippage_paid", float("nan"))
155
+ if actual_slip == actual_slip:
156
+ side = _outside(actual_slip, expected_slip, rel_tol)
157
+ if side == "high":
158
+ issues.append(
159
+ FeeIssue(
160
+ "SLIPPAGE_MULTIPLE",
161
+ tid,
162
+ f"slippage {actual_slip:.4f} > one-pass expectation "
163
+ f"{expected_slip:.4f} -- slippage looks double-counted",
164
+ expected_slip,
165
+ actual_slip,
166
+ )
167
+ )
168
+
169
+ return issues
170
+
171
+
172
+ def check_trades(
173
+ trades: Sequence[dict],
174
+ fee_bps: float = DEFAULT_RATES["fee_bps"],
175
+ funding_bps_per_bar: float = DEFAULT_RATES["funding_bps_per_bar"],
176
+ slippage_bps: float = DEFAULT_RATES["slippage_bps"],
177
+ rel_tol: float = REL_TOL,
178
+ ) -> List[FeeIssue]:
179
+ out: List[FeeIssue] = []
180
+ for trade in trades:
181
+ out.extend(
182
+ check_trade(trade, fee_bps, funding_bps_per_bar, slippage_bps, rel_tol)
183
+ )
184
+ return out
@@ -0,0 +1,183 @@
1
+ """Ledger invariants.
2
+
3
+ A backtest is an accounting system; accounting systems have invariants. When a
4
+ result looks impossible it is usually because one of these was violated
5
+ somewhere in the loop, and the bug silently reshaped the equity curve.
6
+
7
+ The checker consumes a ``ledger``: an ordered list of per-step ``dict`` records.
8
+ Only the keys you provide are checked; the canonical ones are:
9
+
10
+ =============== ==========================================================
11
+ key meaning
12
+ =============== ==========================================================
13
+ ``step``/``bar`` optional step label (used in messages)
14
+ ``cap`` total equity (cash + market value of open positions)
15
+ ``cash`` free cash (excludes position value)
16
+ ``positions`` ``{symbol: market_value}``
17
+ ``wd`` amount withdrawn on this step (default ``0``)
18
+ ``wd_cum`` cumulative withdrawals to date
19
+ ``bust`` ``True`` if this step returned via the liquidation path
20
+ ``trades`` counter, must be non-decreasing (also ``n_events``, ``bars``,
21
+ ``fills``)
22
+ =============== ==========================================================
23
+
24
+ Rules
25
+ -----
26
+ ``EQUITY_IDENTITY`` ``cap == cash + sum(positions.values())`` within tolerance.
27
+ ``NEGATIVE_BALANCE`` ``cap`` / ``cash`` / ``equity`` never below zero.
28
+ ``NEGATIVE_POSITION`` no negative position values.
29
+ ``WD_MONOTONIC`` ``wd_cum`` is non-decreasing.
30
+ ``WD_ACCRUAL`` withdrawing ``wd`` must grow ``wd_cum`` by exactly ``wd``.
31
+ ``BUST_WD_CARRY`` the liquidation/bust path must *carry* ``wd_cum`` forward,
32
+ not reset it (the bug that erased history in case 2/3).
33
+ ``COUNTER_MONOTONIC`` counters never decrease.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass
39
+ from typing import Dict, Iterable, List, Optional, Sequence
40
+
41
+ __all__ = ["Violation", "check_ledger", "DEFAULT_COUNTERS", "DEFAULT_TOL"]
42
+
43
+ DEFAULT_TOL = 1e-6
44
+ DEFAULT_COUNTERS: Sequence[str] = ("trades", "n_events", "bars", "fills")
45
+ _BALANCE_KEYS = ("cap", "cash", "equity")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Violation:
50
+ rule: str
51
+ step: object
52
+ message: str
53
+
54
+ def as_dict(self) -> dict:
55
+ return {"rule": self.rule, "step": self.step, "message": self.message}
56
+
57
+ def __str__(self) -> str: # pragma: no cover - cosmetic
58
+ return f"[{self.rule}] step={self.step}: {self.message}"
59
+
60
+
61
+ def _num(rec: dict, key: str, default: float = 0.0) -> float:
62
+ val = rec.get(key, default)
63
+ if val is None:
64
+ return default
65
+ try:
66
+ return float(val)
67
+ except (TypeError, ValueError):
68
+ return default
69
+
70
+
71
+ def _step(rec: dict, fallback: int) -> object:
72
+ return rec.get("step", rec.get("bar", fallback))
73
+
74
+
75
+ def check_ledger(
76
+ ledger: Sequence[dict],
77
+ tol: float = DEFAULT_TOL,
78
+ counters: Iterable[str] = DEFAULT_COUNTERS,
79
+ ) -> List[Violation]:
80
+ """Run every invariant over ``ledger`` and return all violations found."""
81
+ violations: List[Violation] = []
82
+ counters = tuple(counters)
83
+ prev: Optional[dict] = None
84
+
85
+ for idx, rec in enumerate(ledger):
86
+ step = _step(rec, idx)
87
+
88
+ # --- equity identity -------------------------------------------- #
89
+ if "cap" in rec and "cash" in rec:
90
+ pos = rec.get("positions") or {}
91
+ pos_value = sum(_num({"v": v}, "v") for v in pos.values())
92
+ diff = _num(rec, "cap") - (_num(rec, "cash") + pos_value)
93
+ if abs(diff) > tol:
94
+ violations.append(
95
+ Violation(
96
+ "EQUITY_IDENTITY",
97
+ step,
98
+ f"cap={_num(rec, 'cap'):.6f} but cash+positions="
99
+ f"{_num(rec, 'cash') + pos_value:.6f} (diff {diff:+.6f})",
100
+ )
101
+ )
102
+
103
+ # --- non-negative balances -------------------------------------- #
104
+ for key in _BALANCE_KEYS:
105
+ if key in rec and _num(rec, key) < -tol:
106
+ violations.append(
107
+ Violation("NEGATIVE_BALANCE", step, f"{key}={_num(rec, key):.6f} < 0")
108
+ )
109
+ for sym, val in (rec.get("positions") or {}).items():
110
+ if _num({"v": val}, "v") < -tol:
111
+ violations.append(
112
+ Violation(
113
+ "NEGATIVE_POSITION", step, f"position {sym}={val} < 0"
114
+ )
115
+ )
116
+
117
+ if prev is not None:
118
+ # --- wd_cum ----------------------------------------------- #
119
+ if "wd_cum" in rec and "wd_cum" in prev:
120
+ wd_cum, prev_wd_cum = _num(rec, "wd_cum"), _num(prev, "wd_cum")
121
+ if wd_cum < prev_wd_cum - tol:
122
+ violations.append(
123
+ Violation(
124
+ "WD_MONOTONIC",
125
+ step,
126
+ f"wd_cum decreased {prev_wd_cum:.6f} -> {wd_cum:.6f}",
127
+ )
128
+ )
129
+ wd = _num(rec, "wd", 0.0)
130
+ if wd > tol:
131
+ grew = wd_cum - prev_wd_cum
132
+ if abs(grew - wd) > tol:
133
+ violations.append(
134
+ Violation(
135
+ "WD_ACCRUAL",
136
+ step,
137
+ f"withdrew {wd:.6f} but wd_cum grew {grew:+.6f}",
138
+ )
139
+ )
140
+
141
+ # --- bust must carry wd_cum ------------------------------- #
142
+ if rec.get("bust"):
143
+ if "wd_cum" in prev and _num(prev, "wd_cum") > tol:
144
+ if "wd_cum" not in rec:
145
+ violations.append(
146
+ Violation(
147
+ "BUST_WD_CARRY",
148
+ step,
149
+ "bust path returned cap without wd_cum "
150
+ f"(prev wd_cum={_num(prev, 'wd_cum'):.6f})",
151
+ )
152
+ )
153
+ elif _num(rec, "wd_cum") < _num(prev, "wd_cum") - tol:
154
+ violations.append(
155
+ Violation(
156
+ "BUST_WD_CARRY",
157
+ step,
158
+ "bust path dropped wd_cum "
159
+ f"{_num(prev, 'wd_cum'):.6f} -> {_num(rec, 'wd_cum'):.6f}",
160
+ )
161
+ )
162
+
163
+ # --- counters --------------------------------------------- #
164
+ for c in counters:
165
+ if c in rec and c in prev and _num(rec, c) < _num(prev, c) - tol:
166
+ violations.append(
167
+ Violation(
168
+ "COUNTER_MONOTONIC",
169
+ step,
170
+ f"{c} decreased {_num(prev, c):g} -> {_num(rec, c):g}",
171
+ )
172
+ )
173
+
174
+ prev = rec
175
+
176
+ return violations
177
+
178
+
179
+ def violations_by_rule(violations: Sequence[Violation]) -> Dict[str, int]:
180
+ out: Dict[str, int] = {}
181
+ for v in violations:
182
+ out[v.rule] = out.get(v.rule, 0) + 1
183
+ return out
@@ -0,0 +1,252 @@
1
+ """Look-ahead / future-function detection.
2
+
3
+ A *look-ahead* (a.k.a. future leak) is code that lets a strategy decision on
4
+ bar ``i`` see information that only exists on bars ``i+1...``. In a backtest
5
+ this silently inflates results, and it is the single most common reason a
6
+ "profitable" strategy dies in production.
7
+
8
+ This module is a *heuristic linter*, not a proof engine. It parses source with
9
+ ``ast`` and flags three shapes that are strongly correlated with future leaks:
10
+
11
+ ``NEGATIVE_SHIFT``
12
+ ``series.shift(-1)`` / ``pct_change(-1)`` / ``diff(-1)`` — pulls the next
13
+ value into the current row. Positive shifts read the past and are fine.
14
+
15
+ ``FUTURE_SLICE`` / ``FUTURE_INDEX``
16
+ ``data[i + 1:]`` (open-ended future slice) or ``data[i + 1]`` (direct read
17
+ of the next bar), where the lower bound is an anchor plus a positive
18
+ constant.
19
+
20
+ ``FUTURE_WINDOW_LOOP``
21
+ ``for j in range(ei + 1, min(ei + 1 + TO, n)):`` — a forward window scan.
22
+ When such a loop is *nested inside* another bar loop it is the classic
23
+ "decide the exit on the entry bar" bug and is reported ``HIGH``. A
24
+ stand-alone forward loop is reported ``MEDIUM`` for manual review, because
25
+ a correctly written simulation pass also walks forward (see note below).
26
+
27
+ Design note / honest limitation
28
+ -------------------------------
29
+ Statics cannot tell a *simulation* pass from a *decision* leak. The fixed
30
+ version of case 1 avoids the ambiguity entirely by evaluating the exit **only
31
+ on the current bar** while the backtest advances, so it produces zero findings.
32
+ If you do keep a separate forward simulation pass, expect a ``MEDIUM`` finding;
33
+ that is intentional and means "a human should confirm this pass cannot feed
34
+ back into the entry decision".
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import ast
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+ from typing import List, Optional, Sequence
43
+
44
+ __all__ = [
45
+ "Finding",
46
+ "LookaheadDetector",
47
+ "detect",
48
+ "detect_file",
49
+ "format_findings",
50
+ "SEVERITY_ORDER",
51
+ ]
52
+
53
+ SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3}
54
+
55
+ #: Methods that reach into the future when called with a negative argument.
56
+ FUTURE_METHODS = frozenset({"shift", "pct_change", "diff"})
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Finding:
61
+ """One suspicious construct found in the source."""
62
+
63
+ rule: str
64
+ severity: str
65
+ lineno: int
66
+ message: str
67
+ snippet: str = ""
68
+
69
+ def as_dict(self) -> dict:
70
+ return {
71
+ "rule": self.rule,
72
+ "severity": self.severity,
73
+ "lineno": self.lineno,
74
+ "message": self.message,
75
+ "snippet": self.snippet,
76
+ }
77
+
78
+ def __str__(self) -> str: # pragma: no cover - cosmetic
79
+ return f"[{self.severity}] {self.rule} L{self.lineno}: {self.message}"
80
+
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # small AST helpers
84
+ # --------------------------------------------------------------------------- #
85
+ def _is_int_const(node: ast.AST) -> bool:
86
+ return (
87
+ isinstance(node, ast.Constant)
88
+ and isinstance(node.value, int)
89
+ and not isinstance(node.value, bool)
90
+ )
91
+
92
+
93
+ def _is_positive_int(node: ast.AST) -> bool:
94
+ return _is_int_const(node) and node.value > 0 # type: ignore[attr-defined]
95
+
96
+
97
+ def _is_negative_int(node: ast.AST) -> bool:
98
+ if _is_int_const(node) and node.value < 0: # type: ignore[attr-defined]
99
+ return True
100
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
101
+ return _is_positive_int(node.operand)
102
+ return False
103
+
104
+
105
+ def _adds_positive_int(node: ast.AST) -> bool:
106
+ """True iff ``node`` looks like ``<anchor> + k`` with ``k`` a positive int.
107
+
108
+ A bare constant is *not* a match, so ``range(1, n)`` stays clean.
109
+ """
110
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
111
+ return _is_positive_int(node.right) or _is_positive_int(node.left)
112
+ return False
113
+
114
+
115
+ def _is_range_call(node: ast.AST) -> bool:
116
+ return (
117
+ isinstance(node, ast.Call)
118
+ and isinstance(node.func, ast.Name)
119
+ and node.func.id == "range"
120
+ )
121
+
122
+
123
+ def _call_name(node: ast.Call) -> Optional[str]:
124
+ fn = node.func
125
+ if isinstance(fn, ast.Attribute):
126
+ return fn.attr
127
+ if isinstance(fn, ast.Name):
128
+ return fn.id
129
+ return None
130
+
131
+
132
+ # --------------------------------------------------------------------------- #
133
+ # visitor
134
+ # --------------------------------------------------------------------------- #
135
+ class LookaheadDetector(ast.NodeVisitor):
136
+ """Walk a parsed module and collect :class:`Finding` objects."""
137
+
138
+ def __init__(self) -> None:
139
+ self.findings: List[Finding] = []
140
+ self._loop_depth = 0 # number of enclosing ``for ... in range(...)`` loops
141
+
142
+ # -- loops -------------------------------------------------------------- #
143
+ def visit_For(self, node: ast.For) -> None: # noqa: N802 (ast API)
144
+ if _is_range_call(node.iter):
145
+ self._check_range(node, node.iter) # type: ignore[arg-type]
146
+ self._loop_depth += 1
147
+ self.generic_visit(node)
148
+ self._loop_depth -= 1
149
+ else:
150
+ self.generic_visit(node)
151
+
152
+ def _check_range(self, loop: ast.For, call: ast.Call) -> None:
153
+ if len(call.args) < 2:
154
+ return
155
+ start = call.args[0]
156
+ if not _adds_positive_int(start):
157
+ return
158
+ severity = "HIGH" if self._loop_depth >= 1 else "MEDIUM"
159
+ target = _target_name(loop.target)
160
+ self.findings.append(
161
+ Finding(
162
+ rule="FUTURE_WINDOW_LOOP",
163
+ severity=severity,
164
+ lineno=loop.lineno,
165
+ message=(
166
+ f"forward window scan `range({ast.unparse(start)}, ...)` over "
167
+ f"`{target}` starts strictly after an anchor; "
168
+ + (
169
+ "nested inside another bar loop -> looks like the exit is "
170
+ "decided on the entry bar"
171
+ if severity == "HIGH"
172
+ else "stand-alone forward pass -- confirm it cannot feed the entry decision"
173
+ )
174
+ ),
175
+ snippet=ast.unparse(call),
176
+ )
177
+ )
178
+
179
+ # -- calls -------------------------------------------------------------- #
180
+ def visit_Call(self, node: ast.Call) -> None: # noqa: N802
181
+ name = _call_name(node)
182
+ if name in FUTURE_METHODS and node.args and _is_negative_int(node.args[0]):
183
+ self.findings.append(
184
+ Finding(
185
+ rule="NEGATIVE_SHIFT",
186
+ severity="HIGH",
187
+ lineno=node.lineno,
188
+ message=(
189
+ f"`{name}({ast.unparse(node.args[0])})` reads the future "
190
+ "(positive lag reads the past)"
191
+ ),
192
+ snippet=ast.unparse(node),
193
+ )
194
+ )
195
+ self.generic_visit(node)
196
+
197
+ # -- indexing ----------------------------------------------------------- #
198
+ def visit_Subscript(self, node: ast.Subscript) -> None: # noqa: N802
199
+ sl = node.slice
200
+ if isinstance(sl, ast.Slice):
201
+ if sl.lower is not None and _adds_positive_int(sl.lower):
202
+ self.findings.append(
203
+ Finding(
204
+ rule="FUTURE_SLICE",
205
+ severity="HIGH" if self._loop_depth >= 1 else "MEDIUM",
206
+ lineno=node.lineno,
207
+ message=(
208
+ "slice lower bound is an anchor plus a positive "
209
+ "constant -> the slice reaches into the future"
210
+ ),
211
+ snippet=ast.unparse(node),
212
+ )
213
+ )
214
+ elif _adds_positive_int(sl):
215
+ self.findings.append(
216
+ Finding(
217
+ rule="FUTURE_INDEX",
218
+ severity="MEDIUM",
219
+ lineno=node.lineno,
220
+ message="direct index of the next bar (anchor + positive constant)",
221
+ snippet=ast.unparse(node),
222
+ )
223
+ )
224
+ self.generic_visit(node)
225
+
226
+
227
+ def _target_name(target: ast.AST) -> str:
228
+ if isinstance(target, ast.Name):
229
+ return target.id
230
+ return ast.unparse(target)
231
+
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # public API
235
+ # --------------------------------------------------------------------------- #
236
+ def detect(source: str, filename: str = "<string>") -> List[Finding]:
237
+ """Return all findings for ``source``, sorted by line number."""
238
+ tree = ast.parse(source, filename=filename)
239
+ visitor = LookaheadDetector()
240
+ visitor.visit(tree)
241
+ return sorted(visitor.findings, key=lambda f: (f.lineno, f.rule))
242
+
243
+
244
+ def detect_file(path: str | Path) -> List[Finding]:
245
+ p = Path(path)
246
+ return detect(p.read_text(encoding="utf-8"), filename=str(p))
247
+
248
+
249
+ def format_findings(findings: Sequence[Finding]) -> str:
250
+ if not findings:
251
+ return "clean: no look-ahead patterns detected"
252
+ return "\n".join(str(f) for f in findings)
@@ -0,0 +1,317 @@
1
+ """Mutation testing: which bugs would your tests *not* catch?
2
+
3
+ Your backtest engine has a test suite. It passes. That proves very little:
4
+ tests pass on code that is wrong in a way the tests never probe. Mutation
5
+ testing measures that gap mechanically.
6
+
7
+ For a target function we generate *mutants* — single, small, semantically real
8
+ changes (flip a comparator, off-by-one a constant, swap non-commutative
9
+ operands, delete a fee/commission term). Each mutant is executed against your
10
+ suite. If the suite still passes, the mutant **survived** — that mutation is a
11
+ coverage blind spot, and the same blind spot will happily hide a real bug.
12
+
13
+ Public API
14
+ ----------
15
+ ``generate_mutants(source)`` -> ``list[Mutant]``
16
+ ``run_mutation_tests(source, test_suite)`` -> ``MutationResult``
17
+
18
+ A ``test_suite`` is a callable ``(namespace: dict) -> None`` where
19
+ ``namespace`` is the module-level globals of the (mutated) target module. It
20
+ should raise (e.g. ``assert``) when the behaviour is wrong, and return quietly
21
+ otherwise.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ from dataclasses import dataclass, field
28
+ from typing import Callable, Dict, Iterable, List, Optional, Tuple
29
+
30
+ __all__ = [
31
+ "Mutant",
32
+ "MutationResult",
33
+ "generate_mutants",
34
+ "run_mutation_tests",
35
+ "DEFAULT_KINDS",
36
+ "COMPARE_FLIP",
37
+ "FEE_WORDS",
38
+ ]
39
+
40
+ DEFAULT_KINDS = ("compare", "offbyone", "swap", "delete_fee")
41
+
42
+ COMPARE_FLIP = {
43
+ ast.Lt: ast.LtE,
44
+ ast.LtE: ast.Lt,
45
+ ast.Gt: ast.GtE,
46
+ ast.GtE: ast.Gt,
47
+ ast.Eq: ast.NotEq,
48
+ ast.NotEq: ast.Eq,
49
+ }
50
+
51
+ #: Only non-commutative operators are worth swapping (``a+b == b+a``).
52
+ SWAP_OPS = (ast.Sub, ast.Div)
53
+
54
+ FEE_WORDS = ("fee", "commission", "funding", "slip", "borrow", "cost")
55
+
56
+ TestSuite = Callable[[Dict[str, object]], None]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Mutant:
61
+ id: str
62
+ kind: str
63
+ description: str
64
+ source: str
65
+
66
+
67
+ @dataclass
68
+ class MutationResult:
69
+ total: int
70
+ killed: int
71
+ survived: List[Mutant]
72
+ score: float
73
+ details: List[Tuple[str, str, str]] = field(default_factory=list)
74
+
75
+ @property
76
+ def survivors(self) -> List[Mutant]:
77
+ return self.survived
78
+
79
+ def report(self) -> str:
80
+ lines = [
81
+ f"mutants generated : {self.total}",
82
+ f"killed : {self.killed}",
83
+ f"survived : {len(self.survived)}",
84
+ f"mutation score : {self.score * 100:.1f}%",
85
+ ]
86
+ if self.survived:
87
+ lines.append("blind spots (surviving mutants):")
88
+ for m in self.survived:
89
+ lines.append(f" - [{m.id}] {m.description}")
90
+ return "\n".join(lines)
91
+
92
+
93
+ # --------------------------------------------------------------------------- #
94
+ # source-level mutation
95
+ # --------------------------------------------------------------------------- #
96
+ def _walk_deterministic(tree: ast.AST) -> List[ast.AST]:
97
+ return list(ast.walk(tree))
98
+
99
+
100
+ def _apply_nth(source: str, predicate, mutate, index: int) -> str:
101
+ """Re-parse ``source``, mutate the ``index``-th node matching ``predicate``."""
102
+ tree = ast.parse(source)
103
+ seen = 0
104
+ for node in _walk_deterministic(tree):
105
+ if predicate(node):
106
+ if seen == index:
107
+ mutate(node)
108
+ ast.fix_missing_locations(tree)
109
+ return ast.unparse(tree)
110
+ seen += 1
111
+ raise IndexError(f"no node #{index} matched predicate")
112
+
113
+
114
+ def _compare_pred(node: ast.AST) -> bool:
115
+ return (
116
+ isinstance(node, ast.Compare)
117
+ and len(node.ops) == 1
118
+ and type(node.ops[0]) in COMPARE_FLIP
119
+ )
120
+
121
+
122
+ def _offbyone_pred(node: ast.AST) -> bool:
123
+ return (
124
+ isinstance(node, ast.Constant)
125
+ and isinstance(node.value, int)
126
+ and not isinstance(node.value, bool)
127
+ )
128
+
129
+
130
+ def _swap_pred(node: ast.AST) -> bool:
131
+ return isinstance(node, ast.BinOp) and isinstance(node.op, SWAP_OPS)
132
+
133
+
134
+ def _stmt_has_fee_word(stmt: ast.stmt) -> bool:
135
+ for node in ast.walk(stmt):
136
+ name = None
137
+ if isinstance(node, ast.Name):
138
+ name = node.id
139
+ elif isinstance(node, ast.Attribute):
140
+ name = node.attr
141
+ if name and any(w in name.lower() for w in FEE_WORDS):
142
+ return True
143
+ return False
144
+
145
+
146
+ def _fee_stmts(tree: ast.AST) -> List[ast.stmt]:
147
+ """Statements that directly live in a function/module body and mention a fee.
148
+
149
+ Entire function/class definitions are never candidates -- deleting a whole
150
+ function is not a realistic "forgot the fee" edit.
151
+ """
152
+ out: List[ast.stmt] = []
153
+ for node in _walk_deterministic(tree):
154
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module)):
155
+ for stmt in node.body:
156
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
157
+ continue
158
+ if _stmt_has_fee_word(stmt):
159
+ out.append(stmt)
160
+ return out
161
+
162
+
163
+ def _delete_nth_fee_stmt(source: str, index: int) -> str:
164
+ tree = ast.parse(source)
165
+ targets = _fee_stmts(tree)
166
+ if index >= len(targets):
167
+ raise IndexError("no such fee statement")
168
+ fingerprint = ast.unparse(targets[index])
169
+
170
+ class _Deleter(ast.NodeTransformer):
171
+ def __init__(self) -> None:
172
+ self.done = False
173
+
174
+ def generic_visit(self, node: ast.AST) -> ast.AST:
175
+ if not self.done:
176
+ for attr in ("body", "orelse", "finalbody"):
177
+ body = getattr(node, attr, None)
178
+ if isinstance(body, list):
179
+ for i, stmt in enumerate(body):
180
+ if isinstance(stmt, ast.stmt) and ast.unparse(stmt) == fingerprint:
181
+ del body[i]
182
+ self.done = True
183
+ break
184
+ return super().generic_visit(node)
185
+
186
+ _Deleter().visit(tree)
187
+ ast.fix_missing_locations(tree)
188
+ return ast.unparse(tree)
189
+
190
+
191
+ def generate_mutants(
192
+ source: str, kinds: Optional[Iterable[str]] = None
193
+ ) -> List[Mutant]:
194
+ """Generate first-order mutants for ``source``."""
195
+ kinds = tuple(kinds) if kinds is not None else DEFAULT_KINDS
196
+ kindset = set(kinds)
197
+ base = ast.parse(source)
198
+ mutants: List[Mutant] = []
199
+
200
+ if "compare" in kindset:
201
+ for i, node in enumerate(
202
+ [n for n in _walk_deterministic(base) if _compare_pred(n)]
203
+ ):
204
+ old, new = type(node.ops[0]), COMPARE_FLIP[type(node.ops[0])]
205
+ mid = f"cmp{i}"
206
+
207
+ def _mutate(n, old=old, new=new):
208
+ n.ops[0] = new()
209
+
210
+ mutants.append(
211
+ Mutant(
212
+ mid,
213
+ "compare",
214
+ f"flip `{old.__name__}` -> `{new.__name__}` at L{node.lineno}",
215
+ _apply_nth(source, _compare_pred, _mutate, i),
216
+ )
217
+ )
218
+
219
+ if "offbyone" in kindset:
220
+ for i, node in enumerate(
221
+ [n for n in _walk_deterministic(base) if _offbyone_pred(n)]
222
+ ):
223
+ for delta, tag in ((1, "up"), (-1, "down")):
224
+ mid = f"ob{tag}{i}"
225
+
226
+ def _mutate(n, delta=delta):
227
+ n.value = n.value + delta
228
+
229
+ mutants.append(
230
+ Mutant(
231
+ mid,
232
+ "offbyone",
233
+ f"constant {node.value} -> {node.value + delta} at L{node.lineno}",
234
+ _apply_nth(source, _offbyone_pred, _mutate, i),
235
+ )
236
+ )
237
+
238
+ if "swap" in kindset:
239
+ for i, node in enumerate(
240
+ [n for n in _walk_deterministic(base) if _swap_pred(n)]
241
+ ):
242
+ mid = f"swap{i}"
243
+
244
+ def _mutate(n):
245
+ n.left, n.right = n.right, n.left
246
+
247
+ mutants.append(
248
+ Mutant(
249
+ mid,
250
+ "swap",
251
+ f"swap operands of `{type(node.op).__name__}` at L{node.lineno}",
252
+ _apply_nth(source, _swap_pred, _mutate, i),
253
+ )
254
+ )
255
+
256
+ if "delete_fee" in kindset:
257
+ for i, stmt in enumerate(_fee_stmts(base)):
258
+ mid = f"delfee{i}"
259
+ mutants.append(
260
+ Mutant(
261
+ mid,
262
+ "delete_fee",
263
+ f"delete fee/cost statement `{ast.unparse(stmt)}` at L{stmt.lineno}",
264
+ _delete_nth_fee_stmt(source, i),
265
+ )
266
+ )
267
+
268
+ # de-duplicate identical sources (e.g. equivalent flips)
269
+ seen: set = set()
270
+ unique: List[Mutant] = []
271
+ for m in mutants:
272
+ if m.source in seen:
273
+ continue
274
+ seen.add(m.source)
275
+ unique.append(m)
276
+ return unique
277
+
278
+
279
+ # --------------------------------------------------------------------------- #
280
+ # execution
281
+ # --------------------------------------------------------------------------- #
282
+ def run_mutation_tests(
283
+ source: str,
284
+ test_suite: TestSuite,
285
+ name: str = "target",
286
+ kinds: Optional[Iterable[str]] = None,
287
+ ) -> MutationResult:
288
+ """Run ``test_suite`` against every mutant of ``source``."""
289
+ mutants = generate_mutants(source, kinds)
290
+ survived: List[Mutant] = []
291
+ details: List[Tuple[str, str, str]] = []
292
+ killed = 0
293
+
294
+ for mutant in mutants:
295
+ try:
296
+ namespace: Dict[str, object] = {
297
+ "__name__": f"auditkit_mutant_{mutant.id}",
298
+ "__builtins__": __builtins__,
299
+ }
300
+ exec(compile(mutant.source, f"<{name}:{mutant.id}>", "exec"), namespace)
301
+ except Exception as exc: # mutant does not even import -> killed
302
+ killed += 1
303
+ details.append((mutant.id, "killed", f"load failed: {exc!r}"))
304
+ continue
305
+
306
+ try:
307
+ test_suite(namespace)
308
+ except Exception as exc:
309
+ killed += 1
310
+ details.append((mutant.id, "killed", f"{type(exc).__name__}: {exc}"))
311
+ else:
312
+ survived.append(mutant)
313
+ details.append((mutant.id, "survived", mutant.description))
314
+
315
+ total = len(mutants)
316
+ score = killed / total if total else 1.0
317
+ return MutationResult(total, killed, survived, score, details)
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: backtest-audit-kit
3
+ Version: 0.1.0
4
+ Summary: Audit a backtest: look-ahead lint, ledger invariants, mutation coverage, cost checks. Pure standard library.
5
+ Author: rytjy
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rytjy/backtest-audit-kit
8
+ Project-URL: Repository, https://github.com/rytjy/backtest-audit-kit
9
+ Keywords: backtesting,lookahead-bias,audit,trading,quant,mutation-testing,invariants,backtest-overfitting
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Financial and Insurance Industry
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business :: Financial :: Investment
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # backtest-audit-kit
23
+
24
+ **"I verify whether your strategy is real — or whether the backtest lied to you."**
25
+
26
+ A tiny, dependency-free toolkit that audits a backtest the way an accountant
27
+ audits a ledger and a QA engineer audits a test suite. It does not sell you
28
+ returns; it finds the four ways a backtest most often lies.
29
+
30
+ > **回测审计 / 策略代码复核服务** — 一页说明与联系方式 → **[PORTFOLIO.md](PORTFOLIO.md)**
31
+ > **Backtest audits · strategy-code review** — one-page overview → **[PORTFOLIO.md](PORTFOLIO.md)**
32
+
33
+ ---
34
+
35
+ ## English
36
+
37
+ **What it is.** A pure standard-library (Python 3.9+) kit of four independent
38
+ checkers for a trading backtest:
39
+
40
+ | Checker | Question it answers |
41
+ |---|---|
42
+ | `checks/lookahead.py` | Does any decision on bar `i` see bars `i+1...`? (AST-based future-function lint) |
43
+ | `checks/invariants.py` | Does the ledger obey accounting invariants? (equity identity, `wd_cum` carry, monotonic counters, non-negative balances) |
44
+ | `checks/mutants.py` | Which bugs would your test suite *not* catch? (mutation testing; survivors = coverage blind spots) |
45
+ | `checks/fees.py` | Are fees two-sided, funding charged per bar, and slippage counted once per fill? |
46
+
47
+ **Who it is for.** Quant developers, prop-desk researchers, and anyone who has
48
+ ever shipped a strategy that looked great in a notebook and died in
49
+ production. If you are reviewing someone else's backtest — or your own — this
50
+ gives you a checklist, reproduction scripts, and before/after numbers instead
51
+ of opinions.
52
+
53
+ **What I deliver.** ① A ranked findings list plus a runnable reproduction
54
+ script for each problem. ② A minimal fix patch. ③ A before/after reconciliation
55
+ of the headline numbers (win rate, net PnL, multiple, MDD, t-stat).
56
+
57
+ **Sample cases** (full write-ups in `cases/`):
58
+
59
+ 1. **Look-ahead** — the exit scan ran on the entry bar, so every signal knew
60
+ its outcome. Fixed: 91 trades / 90.1% / **+$214.37** → 26 trades / 65.4% /
61
+ **-$0.12**; a tick-level re-run gives 27 trades / 66.7% / **+$0.37**. The
62
+ leak was worth **≈ $214** of imaginary PnL — the whole profit.
63
+ 2. **Withdrawal accounting** — the bust path dropped `wd_cum`, so any strategy
64
+ with withdrawals was systematically understated. Fixed: same 2024–26 config
65
+ **7.82x → 215.27x** (a ≈ 27x gap; withdrawal rules "double-keep-half" vs
66
+ "double-keep-100" differ by ~27x).
67
+ 3. **Drawdown metric** — the circuit-breaker reset the running peak, so the
68
+ "reported MDD" was only the local drawdown. Investor-view raw drawdown
69
+ **99.7%** vs reported **53.1%** — a **46 pp** gap. Conclusion: MDD must be
70
+ written twice (breaker-local *and* raw).
71
+ (*Bonus case 4:* 5-minute bar clustering inflated `n` ~11x and turned
72
+ t = 15.60 into t = 1.75 after a 24h cooldown.)
73
+ 4. **Cost omission** (`cases/case5_cost_omission.md`) — a high-turnover 50x
74
+ book charged **no** fee, funding or slippage; its take-profit needed only a
75
+ **+0.08%** move while one round trip costs **0.09%** (the venue's real 4.5 bps
76
+ taker), so the target sat below the toll and every win is a net loss. At the
77
+ venue rate the omitted cost ran **~5.4x the starting capital** and **~17.8x the
78
+ reported profit**.
79
+
80
+ **Install.**
81
+
82
+ ```bash
83
+ pip install backtest-audit-kit
84
+ ```
85
+
86
+ **Use it in code** (four checkers, pure standard library):
87
+
88
+ ```python
89
+ from auditkit.checks import lookahead
90
+ findings = lookahead.detect_file("strategy.py") # future-data lint
91
+ print(lookahead.format_findings(findings))
92
+ ```
93
+
94
+ **How to run.**
95
+
96
+ ```bash
97
+ cd portfolio/backtest-audit-kit
98
+ python3 -m unittest discover tests # stdlib only, no install
99
+ # or, if you have pytest:
100
+ python3 -m pytest tests/ -q
101
+ python3 examples/run_demo.py # see all four checkers in action
102
+ ```
103
+
104
+ **Honesty note.** The look-ahead checker is a heuristic linter, not a proof
105
+ engine: it flags the *shapes* that leak, and a stand-alone forward-simulation
106
+ pass is reported `MEDIUM` for human review rather than being declared safe.
107
+ Mutation testing reports blind spots; it never claims your strategy is
108
+ profitable. No checker here predicts returns.
109
+
110
+ ---
111
+
112
+ ## 中文
113
+
114
+ **是什么。** 一个纯 Python 标准库(3.9+)实现的回测审计工具包,四个互相独立
115
+ 的检查器:
116
+
117
+ | 检查器 | 回答的问题 |
118
+ |---|---|
119
+ | `checks/lookahead.py` | 在 bar `i` 上的决策,是否读到了 `i+1...` 的数据?(基于 AST 的前视/未来函数检测) |
120
+ | `checks/invariants.py` | 账本是否满足会计不变量?(权益恒等式、`wd_cum` 结转、计数器单调、余额非负) |
121
+ | `checks/mutants.py` | 你的测试套件**抓不到**哪些 bug?(变异测试;存活的变异体 = 覆盖盲区) |
122
+ | `checks/fees.py` | 手续费是否双边计、资金费是否逐 bar 计、滑点是否每笔只算一次? |
123
+
124
+ **给谁用。** 量化开发者、自营研究岗、以及任何"回测很美、实盘就死"过的
125
+ 人。带看别人的回测(或自己的)时,它给你的是**问题清单 + 复现脚本 + 修复前后
126
+ 的数字**,而不是一句"我觉得不太行"。
127
+
128
+ **交付物承诺。** ① 按严重度排序的问题清单,每个问题配一个可运行的复现脚本。
129
+ ② 最小修复补丁。③ 修复前后的对账数字(胜率 / 净利 / 倍数 / 回撤 / t 值)。
130
+
131
+ **案例摘要**(完整写在 `cases/`):
132
+
133
+ 1. **前视 bug** —— 出场扫描写在开仓 bar 的循环内,每个信号都提前知道结果。
134
+ 修复后:91 笔 / 90.1% / **+$214.37** → 26 笔 / 65.4% / **-$0.12**;tick 级
135
+ 复跑得 27 笔 / 66.7% / **+$0.37**。这个泄漏值 **约 $214 的虚假收益**,
136
+ 即全部利润。
137
+ 2. **提现会计 bug** —— 爆仓返程丢了 `wd_cum`,导致所有带提现的策略历史收益被
138
+ 系统性低估。修复后:同一配置 2024–26 **7.82x → 215.27x**(相差约 27 倍;
139
+ 提现规则"翻 2 留 1"与"翻 200 留 100"之间也差约 27 倍)。
140
+ 3. **回撤口径 bug** —— 熔断触发时把 peak 重置为 cap,"报告 MDD"只是每段熔断
141
+ 后的局部回撤。投资者口径的原始回撤 **99.7%** vs 报告 **53.1%**,**差 46
142
+ 个百分点**。结论:MDD 必须双写(熔断口径 + 原始口径)。
143
+ (**附赠案例 4**:一段暴跌连打十几根 5min bar 让样本量虚增约 11 倍,t 值从
144
+ 15.60 在 24h 冷却去重后掉到 1.75。)
145
+ 4. **成本漏算**(`cases/case5_cost_omission.md`)—— 一个高换手 50x 账本**完全没算**手续费/资金费/滑点;止盈只需价格动 **+0.08%**,而一趟往返 **0.09%**(场馆真实 4.5bps taker)⇒ 目标在成本之下,每笔赢单净亏。按场馆真实费率,漏掉的成本约 **5.4× 本金**、**17.8× 账面收益**。
146
+
147
+ **怎么跑。**
148
+
149
+ ```bash
150
+ cd portfolio/backtest-audit-kit
151
+ python3 -m unittest discover tests # 只用标准库,无需安装
152
+ # 若装了 pytest:
153
+ python3 -m pytest tests/ -q
154
+ python3 examples/run_demo.py # 四个检查器全跑一遍
155
+ ```
156
+
157
+ **诚实声明。** 前视检查器是启发式 linter,不是证明器:它抓的是会泄漏的**代码
158
+ 形态**,独立的"向前模拟"循环会被标为 `MEDIUM` 交人工复核,而不是被判安全。
159
+ 变异测试只报告覆盖盲区,从不声称你的策略赚钱。本工具包不预测任何收益。
160
+
161
+ ---
162
+
163
+ ## Also in this account · 另见
164
+
165
+ - [`outpost/`](./outpost/) — 一个"只放能跑、能查、能被否证的东西"的据点:
166
+ 单文件表格整理工具(零依赖、离线自检 14 项)+ 一份**被自己推翻**的回测(做空山寨无 edge)
167
+ + 一次"两条腿都断了"的数据源修复。不写客户名、不写"我能做什么"。
@@ -0,0 +1,11 @@
1
+ auditkit/__init__.py,sha256=Z13wkb6SMQZBgqtx_wRTKEm-vWuMZ_M8vDFWZ5edVK0,708
2
+ auditkit/checks/__init__.py,sha256=AZigLxdMbxcwLCZmJKrVODEgzHR6nQvfVbjyPm_vRV0,148
3
+ auditkit/checks/fees.py,sha256=hiOJoFg0BLxWwe54KlwnuruV5WaK1Q8UwNhx5c5EI_s,6261
4
+ auditkit/checks/invariants.py,sha256=FdDooxn0yVhgtalNYYdc9lzqq5hHKRPPP0AM1-dTz4I,7204
5
+ auditkit/checks/lookahead.py,sha256=mwFrP5x6vjsbN5hsczaSelZ4BDXKsJdIgIivPBj0OrQ,9185
6
+ auditkit/checks/mutants.py,sha256=eh3rKrEI0Cb3439VB9x5O26tnvnqReAMz-5Z-dabfLI,10188
7
+ backtest_audit_kit-0.1.0.dist-info/licenses/LICENSE,sha256=rOsDuKcMp6cQ1ShvEiEmEMh_QK-tTgFdHBTB5bEIqYY,1062
8
+ backtest_audit_kit-0.1.0.dist-info/METADATA,sha256=VTc3pyWGqedT-bOZU3KeMA49hhFYXWrSh1PDPgMkMZc,8550
9
+ backtest_audit_kit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ backtest_audit_kit-0.1.0.dist-info/top_level.txt,sha256=kQBTO9qJ9E8TUvF_DHOn19QTe_3B8AcCxlLR-TzBKx8,9
11
+ backtest_audit_kit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rytjy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ auditkit