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 +16 -0
- auditkit/checks/__init__.py +5 -0
- auditkit/checks/fees.py +184 -0
- auditkit/checks/invariants.py +183 -0
- auditkit/checks/lookahead.py +252 -0
- auditkit/checks/mutants.py +317 -0
- backtest_audit_kit-0.1.0.dist-info/METADATA +167 -0
- backtest_audit_kit-0.1.0.dist-info/RECORD +11 -0
- backtest_audit_kit-0.1.0.dist-info/WHEEL +5 -0
- backtest_audit_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- backtest_audit_kit-0.1.0.dist-info/top_level.txt +1 -0
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"]
|
auditkit/checks/fees.py
ADDED
|
@@ -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,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
|