gstrat 1.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __pycache__/
gstrat-1.9.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GStrat
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.
gstrat-1.9.0/PKG-INFO ADDED
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: gstrat
3
+ Version: 1.9.0
4
+ Summary: Reference backtest engine for GStrat-generated Python strategies.
5
+ Project-URL: Homepage, https://gstrat.net
6
+ Author: GStrat
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: backtest,quant,strategy,trading
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Financial and Insurance Industry
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Office/Business :: Financial :: Investment
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: numpy>=1.26
16
+ Requires-Dist: pandas>=2.2.2
17
+ Description-Content-Type: text/markdown
18
+
19
+ # gstrat
20
+
21
+ The reference backtest engine that [GStrat](https://gstrat.net) generates strategies against.
22
+
23
+ A GStrat-generated strategy is a plain Python script: it builds indicator and signal columns on a
24
+ `pandas` DataFrame, registers entry rules and their exits, and runs a bar-by-bar fill simulation over
25
+ them. This package is that simulation — the same engine the generated script carries — so results are
26
+ reproducible on your own machine, against your own data, with nothing executed server-side.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install gstrat
32
+ ```
33
+
34
+ Requires Python 3.11+, `pandas` 2.2.2+ and `numpy` 1.26+.
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ import pandas as pd
40
+ from gstrat import Strategy
41
+
42
+ df = pd.DataFrame({
43
+ "Open": [100.0, 101.0, 103.0, 102.0, 100.0],
44
+ "High": [101.0, 103.5, 104.0, 103.0, 100.5],
45
+ "Low": [ 99.5, 100.5, 101.5, 98.0, 99.0],
46
+ "Close": [100.5, 103.0, 102.0, 98.5, 100.0],
47
+ })
48
+
49
+ # Signals and exit distances are ordinary columns you compute yourself.
50
+ df["go_long"] = df["Close"] > df["Open"]
51
+ df["stop_distance"] = 2.0
52
+
53
+ strategy = Strategy(df)
54
+ position = strategy.entry("breakout", 1, df["go_long"], qty=10)
55
+ position.exit_stop(df["stop_distance"])
56
+
57
+ result = strategy.backtest(initial_equity=100_000, commission_pct=0.05, slippage_ticks=1)
58
+ print(result)
59
+ ```
60
+
61
+ `Strategy.entry(entry_id, direction, when, qty)` registers a rule — `direction` is `1` for long and
62
+ `-1` for short, `when` is a boolean column, and `qty` is a number, a column, or a
63
+ `Strategy.size_risk(risk, distance)` spec that sizes off running equity. The returned position takes
64
+ `exit_stop`, `exit_profit` and `exit_trail` (each `mode="price"` or `mode="percent"`), plus
65
+ `exit_signal(when)` to close at market on a condition.
66
+
67
+ `backtest()` returns a result carrying the DataFrame and the list of trades. Printing it gives the
68
+ run's span, its warm-up, the headline numbers and every trade with the reason it closed — `stop`,
69
+ `target`, `trail`, `signal`, `reversal`, or `still open` for one still running (stored as `open`). Colour is used when the
70
+ output is a terminal and is suppressed by `NO_COLOR`; `result.report(color=False)` is the explicit
71
+ form. `.summary()` still returns the same five numbers as a dict, `.trades` the raw records, and
72
+ `.warmup_bars` the bars before every numeric column beyond the prices held a value, not counting
73
+ the columns a `per_bar` hook writes.
74
+
75
+ ## Fill model
76
+
77
+ The engine is deliberately explicit about execution, and the module docstring is its specification —
78
+ read it with `python -c "import gstrat; print(gstrat.__doc__)"`. In short: a signal on a bar's close
79
+ fills at the next bar's open; stop and trailing fills take slippage while limit fills do not;
80
+ commission is a percent of notional on each filled side; an order's size is resolved on the bar that
81
+ placed it, not the bar that fills it; and the intrabar path checks the extreme nearer the open first.
82
+
83
+ ## Version
84
+
85
+ The distribution version is the engine version — there is no second one. Both of these report it:
86
+
87
+ ```python
88
+ import gstrat, importlib.metadata
89
+ gstrat.ENGINE_VERSION # e.g. "1.9.0"
90
+ importlib.metadata.version("gstrat")
91
+ ```
92
+
93
+ Every release moves the version, and the component says what moved with it:
94
+
95
+ - **major** — something you can observe changed: a signature, a number a previous release returned
96
+ for the same input, or a call that used to raise. Pin with `gstrat~=1.8` if you want to review
97
+ those before adopting them; a fill-model correction lands here, because two runs of the same
98
+ strategy giving different numbers is a break whatever caused it.
99
+ - **minor** — a new capability. Nothing you already read changes value.
100
+ - **patch** — no API change and no value changes anywhere.
101
+
102
+ A generated script and the `gstrat` it runs against should carry the same version. The generated
103
+ script names the version it was built for in its header.
104
+
105
+ ## License
106
+
107
+ MIT.
gstrat-1.9.0/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # gstrat
2
+
3
+ The reference backtest engine that [GStrat](https://gstrat.net) generates strategies against.
4
+
5
+ A GStrat-generated strategy is a plain Python script: it builds indicator and signal columns on a
6
+ `pandas` DataFrame, registers entry rules and their exits, and runs a bar-by-bar fill simulation over
7
+ them. This package is that simulation — the same engine the generated script carries — so results are
8
+ reproducible on your own machine, against your own data, with nothing executed server-side.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install gstrat
14
+ ```
15
+
16
+ Requires Python 3.11+, `pandas` 2.2.2+ and `numpy` 1.26+.
17
+
18
+ ## Quickstart
19
+
20
+ ```python
21
+ import pandas as pd
22
+ from gstrat import Strategy
23
+
24
+ df = pd.DataFrame({
25
+ "Open": [100.0, 101.0, 103.0, 102.0, 100.0],
26
+ "High": [101.0, 103.5, 104.0, 103.0, 100.5],
27
+ "Low": [ 99.5, 100.5, 101.5, 98.0, 99.0],
28
+ "Close": [100.5, 103.0, 102.0, 98.5, 100.0],
29
+ })
30
+
31
+ # Signals and exit distances are ordinary columns you compute yourself.
32
+ df["go_long"] = df["Close"] > df["Open"]
33
+ df["stop_distance"] = 2.0
34
+
35
+ strategy = Strategy(df)
36
+ position = strategy.entry("breakout", 1, df["go_long"], qty=10)
37
+ position.exit_stop(df["stop_distance"])
38
+
39
+ result = strategy.backtest(initial_equity=100_000, commission_pct=0.05, slippage_ticks=1)
40
+ print(result)
41
+ ```
42
+
43
+ `Strategy.entry(entry_id, direction, when, qty)` registers a rule — `direction` is `1` for long and
44
+ `-1` for short, `when` is a boolean column, and `qty` is a number, a column, or a
45
+ `Strategy.size_risk(risk, distance)` spec that sizes off running equity. The returned position takes
46
+ `exit_stop`, `exit_profit` and `exit_trail` (each `mode="price"` or `mode="percent"`), plus
47
+ `exit_signal(when)` to close at market on a condition.
48
+
49
+ `backtest()` returns a result carrying the DataFrame and the list of trades. Printing it gives the
50
+ run's span, its warm-up, the headline numbers and every trade with the reason it closed — `stop`,
51
+ `target`, `trail`, `signal`, `reversal`, or `still open` for one still running (stored as `open`). Colour is used when the
52
+ output is a terminal and is suppressed by `NO_COLOR`; `result.report(color=False)` is the explicit
53
+ form. `.summary()` still returns the same five numbers as a dict, `.trades` the raw records, and
54
+ `.warmup_bars` the bars before every numeric column beyond the prices held a value, not counting
55
+ the columns a `per_bar` hook writes.
56
+
57
+ ## Fill model
58
+
59
+ The engine is deliberately explicit about execution, and the module docstring is its specification —
60
+ read it with `python -c "import gstrat; print(gstrat.__doc__)"`. In short: a signal on a bar's close
61
+ fills at the next bar's open; stop and trailing fills take slippage while limit fills do not;
62
+ commission is a percent of notional on each filled side; an order's size is resolved on the bar that
63
+ placed it, not the bar that fills it; and the intrabar path checks the extreme nearer the open first.
64
+
65
+ ## Version
66
+
67
+ The distribution version is the engine version — there is no second one. Both of these report it:
68
+
69
+ ```python
70
+ import gstrat, importlib.metadata
71
+ gstrat.ENGINE_VERSION # e.g. "1.9.0"
72
+ importlib.metadata.version("gstrat")
73
+ ```
74
+
75
+ Every release moves the version, and the component says what moved with it:
76
+
77
+ - **major** — something you can observe changed: a signature, a number a previous release returned
78
+ for the same input, or a call that used to raise. Pin with `gstrat~=1.8` if you want to review
79
+ those before adopting them; a fill-model correction lands here, because two runs of the same
80
+ strategy giving different numbers is a break whatever caused it.
81
+ - **minor** — a new capability. Nothing you already read changes value.
82
+ - **patch** — no API change and no value changes anywhere.
83
+
84
+ A generated script and the `gstrat` it runs against should carry the same version. The generated
85
+ script names the version it was built for in its header.
86
+
87
+ ## License
88
+
89
+ MIT.
gstrat-1.9.0/engine.py ADDED
@@ -0,0 +1,865 @@
1
+ """Backtest engine, consuming the signal and parameter columns
2
+ already on the frame. `Strategy.backtest()` runs this fill model over a
3
+ strategy's registered entry/exit rules and returns trade results the caller
4
+ can use directly, with nothing computed remotely.
5
+
6
+ Fill model:
7
+ - entry signal on bar close -> market fill at next bar open
8
+ - an opposite-direction entry signal while in a position REVERSES: the open
9
+ trade is closed at the next bar's open and the new trade opens at that same
10
+ price (one entry per direction, no adds: a same-direction signal while in
11
+ a position is ignored)
12
+ - a rule's exitSignal column is a market exit to FLAT, not a reversal: true
13
+ on bar close -> the position closes at the next bar's open and nothing is
14
+ opened in its place. It is queued only while a position from that same
15
+ rule is open, so a signal with nothing to close is dropped rather than
16
+ held to flatten a later position; and because it fills at the open it
17
+ pre-empts every stop/limit/trail level that bar, which never becomes a
18
+ working order. It also pre-empts an opposite-direction entry filling at that
19
+ same open: the position is already flat when the entry arrives, so the trade
20
+ closes for reason "signal" rather than "reversal" and the new one opens
21
+ beside it. Both paths fill at the same price, so only the reason differs
22
+ - exit orders are (re)placed at each bar CLOSE and govern the NEXT bar, so on
23
+ check bar B every parameter (stop distance, trail offset) is read at bar
24
+ B-1, and fraction-mode trail offsets multiply B-1's close
25
+ - an order's qty is resolved on the bar that PLACED it, from that bar's
26
+ distance column and the equity as of its close (an open position marked to
27
+ that close), never from the fill bar, whose indicators need the whole bar to
28
+ close before they exist
29
+ - a fixed stop-loss/take-profit level (re)placed on the bar a position is
30
+ entered is not yet a checkable working order against that same bar's
31
+ range (the earliest an order can fill is the next tick after it was
32
+ placed) — check_stop/check_limit never fire on position["entryBar"]; the
33
+ trail is exempt from this rule (see the arming bullet below)
34
+ - stop/limit levels are rounded to the tick AWAY from the placement bar's
35
+ close; trailing levels are never tick-rounded
36
+ - intrabar path: the extreme closer to the open trades first, then the other
37
+ extreme, then the close. Stops/limits fill at their level (at the open if
38
+ the bar opens beyond it); the trail updates its peak at the favorable
39
+ extreme's position in the path, fills at the level with NO open-gap fill,
40
+ and the close is a final trail checkpoint after a late peak update
41
+ - check order follows the path: bars whose favorable extreme trades first
42
+ check limit -> stop -> trail; the others check stop -> trail -> limit
43
+ - the trail arms on the entry bar with that bar's favorable extreme as the
44
+ initial peak; a trail whose offset column is not defined yet keeps tracking
45
+ that peak and only waits to check a level, so a distance with warm-up trails
46
+ from the entry bar rather than from wherever it first becomes readable
47
+ - stop distances are absolute price by default; mode="percent" on
48
+ exit_stop / exit_profit / exit_trail (a rule's slStopMode / tpStopMode /
49
+ tslStopMode = "fraction") reads the column as a fraction of the
50
+ reference price instead (entry price for sl/tp, placement-bar close for the
51
+ trail), so a percent is written as the fraction it stands for: 2% is 0.02
52
+ - slippage worsens every market, stop and trailing fill by slippage_ticks;
53
+ limit/take-profit fills are exempt, since a limit fills at its price or
54
+ better, and a slipped fill is not bounded into the bar's range
55
+ - a stop that gaps through its level fills at the bar's open and is slipped
56
+ from there: the open models the trigger, and the fill follows it
57
+ - stop/take-profit levels are measured from the FILLED entry price, so
58
+ slippage shifts them by the same amount and can change which bar an exit
59
+ lands on
60
+ - commission is a percent of notional on each filled side, off the filled
61
+ price, and is deducted from the equity that drives risk-based sizing
62
+ - a registered per-bar hook runs once per bar at the point signals are
63
+ evaluated — after this bar's fills and after its intrabar stop/target/trail
64
+ exits — so it sees the position as of this bar's close, and a column it
65
+ writes is readable by this same bar's exit and entry signals
66
+ """
67
+ ENGINE_VERSION = "1.9.0"
68
+
69
+ import math
70
+ import os
71
+ import sys
72
+
73
+ import numpy as np
74
+
75
+ INITIAL_EQUITY = 100_000.0
76
+
77
+ # The frame's own price columns. Every other numeric column on the frame is
78
+ # measured as the strategy's lookback.
79
+ _PRICE_COLUMNS = ("Open", "High", "Low", "Close", "Volume")
80
+
81
+ # numpy kind codes for bool/int/uint/float. A column outside them is not a value
82
+ # a strategy waits for, and reading one would report warm-up wrongly in both
83
+ # directions: a datetime column is finite at bar 0 under pandas' own coercion but
84
+ # raises under float(), and a text column would call the entire run warm-up.
85
+ _NUMERIC_KINDS = "biuf"
86
+
87
+
88
+ def _raw(df, name, bar, store=None):
89
+ """Value of a signal or parameter column at `bar`, taken from the live
90
+ store when the column is one the per-bar hook writes. Those columns hold
91
+ NaN on the frame until the run ends, and a frame read here is not a stale
92
+ value but a silent no-op: the truthiness guards downstream read NaN as true
93
+ and NaN != NaN as false, so the rule never fires and the run ends with no
94
+ trades and no error."""
95
+ if store is not None:
96
+ column = store.get(name)
97
+ if column is not None:
98
+ return column[bar]
99
+ return df[name].iloc[bar]
100
+
101
+
102
+ def _col(df, name, bar, store=None):
103
+ v = _raw(df, name, bar, store)
104
+ return None if v is None or v != v else float(v) # NaN-safe
105
+
106
+
107
+ def _resolve_qty(qty, df, bar, equity, store=None):
108
+ """Resolve a rule's `qty` from the bar that PLACED the order — the signal
109
+ bar — not the bar that fills it. `bar` is that placement index. Reading
110
+ the fill bar instead would size an order that executes at that bar's open
111
+ from indicators the whole bar has to close to produce. A risk-spec
112
+ dict (from Strategy.size_risk) sizes shares off equity and the distance
113
+ column at that bar, so per-bar volatility drives position size; a
114
+ scalar/Series qty is unchanged (fixed sizing)."""
115
+ if isinstance(qty, dict) and qty.get("__kind__") == "risk":
116
+ # _raw, not _col: a NaN distance must stay NaN and yield a NaN size.
117
+ # _col's None would raise TypeError on the division instead.
118
+ distance = _raw(df, qty["distance"], bar, store)
119
+ return (equity * qty["risk"] / 100.0) / distance
120
+ if hasattr(qty, "iloc"):
121
+ # A Series qty was captured at registration, so its only handle on the
122
+ # store is its own name; anything not a declared runtime column keeps
123
+ # reading the Series it was handed.
124
+ name = getattr(qty, "name", None)
125
+ if store is not None and name in store:
126
+ return float(store[name][bar])
127
+ return float(qty.iloc[bar])
128
+ return float(qty)
129
+
130
+
131
+ def _tick_round(value, reference, tick):
132
+ """Rounds stop/limit prices to the tick AWAY from the reference
133
+ (the close of the bar that placed the order)."""
134
+ if not tick or tick <= 0 or not math.isfinite(value) or value == reference:
135
+ return value
136
+ ratio = value / tick
137
+ eps = 1e-9
138
+ steps = math.ceil(ratio - eps) if value > reference else math.floor(ratio + eps)
139
+ return steps * tick
140
+
141
+
142
+ def _slipped(price, side, slip):
143
+ """Adverse fill adjustment. `side` is +1 when the fill buys and -1 when it
144
+ sells, so a buy fills higher and a sell fills lower. Deliberately not
145
+ bounded into the bar's range: a fill worse than the bar's extreme is the
146
+ honest model of an order that had to chase, and bounding it would silently
147
+ cancel the cost on exactly the gapping bars where it is largest."""
148
+ return price + side * slip if slip else price
149
+
150
+
151
+ def _commission(price, size, rate):
152
+ """Cash charged on one filled side, computed off the price actually
153
+ filled at — that is the notional that changed hands."""
154
+ return abs(size) * price * rate if rate else 0.0
155
+
156
+
157
+ def _first_finite_pos(values):
158
+ """Index of the first value a column actually holds, or len(values) when it
159
+ never holds one. float() is guarded rather than assumed total: a nullable
160
+ boolean column passes the dtype filter and still carries values that raise."""
161
+ for i, value in enumerate(values):
162
+ try:
163
+ if math.isfinite(float(value)):
164
+ return i
165
+ except (TypeError, ValueError):
166
+ pass
167
+ return len(values)
168
+
169
+
170
+ def _warmup_bars(df, runtime_columns):
171
+ """Bars before EVERY numeric column beyond the prices holds a value — the
172
+ strategy's lookback, on a frame carrying only what it computed. Runtime
173
+ columns are excluded because they are outputs of the bar
174
+ loop rather than lookback: the ones a position defines stay NaN until the
175
+ first fill, which would report the first ENTRY bar as the strategy's
176
+ lookback, and the whole frame for a strategy that never trades."""
177
+ skip = set(_PRICE_COLUMNS) | set(runtime_columns)
178
+ # By position, not by name: a frame may carry two columns under one name,
179
+ # and df[name] then returns a frame, which has no single dtype.
180
+ return max(
181
+ (
182
+ _first_finite_pos(df.iloc[:, pos].tolist())
183
+ for pos, name in enumerate(df.columns)
184
+ if name not in skip
185
+ and getattr(df.iloc[:, pos].dtype, "kind", "O") in _NUMERIC_KINDS
186
+ ),
187
+ default=0,
188
+ )
189
+
190
+
191
+ class _BarView:
192
+ """One bar of the frame, presented so that an expression written for a whole
193
+ column is correct unchanged for a single bar.
194
+
195
+ Columns materialise to a numpy array on first access rather than reading
196
+ `.iloc[bar]` each time: the positional pandas read is ~34x an array index,
197
+ which is the difference between a hook that disappears into the noise and
198
+ one that dominates the run.
199
+
200
+ An ARRAY rather than a list, and that is a correctness choice, not a
201
+ performance one. Indexing a list of Python floats hands back a Python
202
+ float, whose arithmetic differs from the column's on exactly the cases a
203
+ strategy meets first: `1.0 / 0.0` raises ZeroDivisionError where the column
204
+ yields `inf`, and a position is flat — size 0 — on every bar before the
205
+ first fill. Indexing the array hands back a numpy scalar, which divides,
206
+ compares and propagates NaN identically to the whole-column form. That
207
+ equivalence is what lets one expression serve both surfaces."""
208
+
209
+ def __init__(self, df, store):
210
+ self._df, self._store, self._cache, self._bar = df, store, {}, 0
211
+
212
+ def _seek(self, bar):
213
+ self._bar = bar
214
+
215
+ def __getitem__(self, name):
216
+ if name in self._store:
217
+ return self._store[name][self._bar]
218
+ column = self._cache.get(name)
219
+ if column is None:
220
+ column = self._df[name].to_numpy()
221
+ self._cache[name] = column
222
+ return column[self._bar]
223
+
224
+ def __setitem__(self, name, value):
225
+ if name not in self._store:
226
+ raise KeyError(
227
+ f"per_bar assigned '{name}', which was not declared a runtime "
228
+ f"column: its value would be discarded when the run ends"
229
+ )
230
+ self._store[name][self._bar] = value
231
+
232
+
233
+ class _State:
234
+ """The live position and account as of this bar's close, after this bar's
235
+ fills and exits."""
236
+
237
+ __slots__ = (
238
+ "avg_price", "size", "bars_in_trade", "open_profit",
239
+ "equity", "net_profit", "win_trades", "loss_trades",
240
+ )
241
+
242
+
243
+ def _refresh_state(st, position, close, bar, equity, initial_equity,
244
+ wins, losses):
245
+ """`equity` in is the REALIZED running total. The published `st.equity`
246
+ adds the open position's mark to this bar's close, because that is the
247
+ number risk sizing is already fed (`placement_equity`) and the only one on
248
+ which two spellings of "risk 1% of equity" agree: `size_risk` and a per-bar
249
+ hook computing qty from `st.equity`. `st.net_profit` stays on the realized
250
+ total, which already carries the open trade's entry commission: an entry's
251
+ cost is charged when it fills, not held back until the trade closes."""
252
+ # numpy scalars, matching what the one-bar view hands back for a column:
253
+ # a value that reaches an expression from the state object and one that
254
+ # reaches it through a column have to divide and compare the same way.
255
+ f = np.float64
256
+ nan = f("nan")
257
+ if position is None:
258
+ st.avg_price = nan
259
+ st.size = f(0.0)
260
+ st.bars_in_trade = nan
261
+ st.open_profit = f(0.0)
262
+ else:
263
+ st.avg_price = f(position["entryPrice"])
264
+ st.size = f(position["size"])
265
+ st.bars_in_trade = f(bar - position["entryBar"])
266
+ st.open_profit = f(
267
+ (close - position["entryPrice"]) * position["size"]
268
+ )
269
+ st.equity = f(equity) + st.open_profit
270
+ st.net_profit = f(equity - initial_equity)
271
+ st.win_trades = f(wins)
272
+ st.loss_trades = f(losses)
273
+
274
+
275
+ def simulate(df, rules, tick=0.01, initial_equity=INITIAL_EQUITY,
276
+ commission_pct=0.0, slippage_ticks=0,
277
+ per_bar=None, runtime_columns=()):
278
+ """rules: [{id, dir(+1/-1), entries, slStop?, tpStop?, tslStop?,
279
+ exitSignal?, qty?, slStopMode?, tpStopMode?, tslStopMode?}] — modes are
280
+ "price" (default) or "fraction". `tick` is the symbol's smallest price
281
+ increment (0.01 by default); pass 0 to disable price rounding, which
282
+ also disables slippage, since slippage is measured in ticks."""
283
+ trades = []
284
+ position = None # {"rule", "entryBar", "entryPrice", "size", "peak",
285
+ # "entryCommission", "slippageCost"}
286
+ equity = initial_equity # tracked so risk-based qty specs can size off it
287
+ n = len(df)
288
+ opens = df["Open"].tolist()
289
+ highs = df["High"].tolist()
290
+ lows = df["Low"].tolist()
291
+ closes = df["Close"].tolist()
292
+ pending = None # rule to fill at next open
293
+ pending_exit = False # signal exit to fill flat at next open
294
+ slip = slippage_ticks * tick
295
+ commission_rate = commission_pct / 100.0
296
+ # Populated only with the hook: without one nothing can write these, and
297
+ # a store holding a name that is also a real frame column would shadow it
298
+ # with NaN at every read site below.
299
+ store = (
300
+ {name: [float("nan")] * n for name in runtime_columns}
301
+ if per_bar is not None
302
+ else {}
303
+ )
304
+ view = _BarView(df, store) if per_bar is not None else None
305
+ st = _State() if per_bar is not None else None
306
+ wins = losses = 0
307
+
308
+ def close_position(bar, exit_price, exit_slippage, reason):
309
+ nonlocal equity, wins, losses
310
+ exit_commission = _commission(exit_price, position["size"], commission_rate)
311
+ equity += (exit_price - position["entryPrice"]) * position["size"]
312
+ equity -= exit_commission
313
+ trades.append({
314
+ "entryId": position["rule"]["id"],
315
+ "entryBar": position["entryBar"],
316
+ "entryPrice": position["entryPrice"],
317
+ "exitBar": bar,
318
+ "exitPrice": exit_price,
319
+ "size": position["size"],
320
+ "entryCommission": position["entryCommission"],
321
+ "exitCommission": exit_commission,
322
+ "slippageCost": position["slippageCost"] + exit_slippage,
323
+ "reason": reason,
324
+ })
325
+ # Net of BOTH commissions, so a trade whose price moved in your favour
326
+ # counts as a loss when the two commissions exceed its gross profit.
327
+ # A trade netting exactly zero counts as neither.
328
+ net = ((exit_price - position["entryPrice"]) * position["size"]
329
+ - position["entryCommission"] - exit_commission)
330
+ if net > 0:
331
+ wins += 1
332
+ elif net < 0:
333
+ losses += 1
334
+
335
+ for bar in range(n):
336
+ # Equity as of the PREVIOUS bar's close — the moment an order filling
337
+ # at this bar's open was placed — carrying the then-open position's
338
+ # mark-to-market at that close. Captured before 1a/1b, which flatten
339
+ # that position and would leave only realized equity behind; on a
340
+ # reversal the two differ by the gap between the placing close and the
341
+ # fill open. Read only when `pending` is set, which cannot happen
342
+ # before bar 1.
343
+ placement_equity = equity
344
+ if pending is not None and position is not None:
345
+ placement_equity += (
346
+ closes[bar - 1] - position["entryPrice"]
347
+ ) * position["size"]
348
+
349
+ # 1a) a queued signal exit flattens at this bar's open, before any
350
+ # intrabar order can be checked
351
+ if pending_exit:
352
+ if position is not None:
353
+ d_out = position["rule"]["dir"]
354
+ exit_fill = _slipped(opens[bar], -d_out, slip)
355
+ # Reported as "signal" even when an opposite entry fills at this
356
+ # same open and flips the position: 1a runs first and leaves
357
+ # nothing for 1b to close. The two produce byte-identical fills,
358
+ # so only the label is at stake, and the exit signal is the cause
359
+ # the trader wrote down.
360
+ close_position(
361
+ bar, exit_fill,
362
+ abs(exit_fill - opens[bar]) * abs(position["size"]),
363
+ "signal",
364
+ )
365
+ position = None
366
+ pending_exit = False
367
+
368
+ # 1b) fill a pending entry at this bar's open; an opposite-direction
369
+ # pending reverses (close at this open, then open the new trade)
370
+ if pending is not None:
371
+ if position is not None:
372
+ d_out = position["rule"]["dir"]
373
+ exit_fill = _slipped(opens[bar], -d_out, slip)
374
+ close_position(
375
+ bar, exit_fill,
376
+ abs(exit_fill - opens[bar]) * abs(position["size"]),
377
+ "reversal",
378
+ )
379
+ position = None
380
+ rule = pending
381
+ # `pending` is only ever set in step 3 of an earlier bar, so the
382
+ # placement bar `bar - 1` always exists.
383
+ qty = _resolve_qty(
384
+ rule.get("qty", 1.0), df, bar - 1, placement_equity, store
385
+ )
386
+ entry_price = _slipped(opens[bar], rule["dir"], slip)
387
+ size = qty * rule["dir"]
388
+ entry_commission = _commission(entry_price, size, commission_rate)
389
+ equity -= entry_commission
390
+ position = {
391
+ "rule": rule,
392
+ "entryBar": bar,
393
+ "entryPrice": entry_price,
394
+ "size": size,
395
+ "peak": None, # set when the trail arms (entry bar)
396
+ "entryCommission": entry_commission,
397
+ "slippageCost": abs(entry_price - opens[bar]) * abs(size),
398
+ }
399
+ pending = None
400
+
401
+ # 2) manage open position intrabar
402
+ if position is not None:
403
+ rule = position["rule"]
404
+ d = rule["dir"]
405
+ entry = position["entryPrice"]
406
+ o, h, lo, c = opens[bar], highs[bar], lows[bar], closes[bar]
407
+ placement = bar - 1 # the bar whose close (re)placed the orders
408
+ ref = closes[placement]
409
+ # Path order: the extreme closer to the open trades first;
410
+ # `favorable_first` is true when the position's favorable extreme
411
+ # (high for longs, low for shorts) comes first.
412
+ high_first = abs(h - o) <= abs(o - lo)
413
+ favorable_first = high_first if d == 1 else not high_first
414
+ exit_price = None
415
+ exit_slippage = 0.0
416
+ exit_reason = None
417
+
418
+ def fixed_level(col_name, mode_key, sign):
419
+ value = _col(df, rule[col_name], placement, store)
420
+ if value is None:
421
+ return None
422
+ dist = (
423
+ value * entry
424
+ if rule.get(mode_key) == "fraction"
425
+ else value
426
+ )
427
+ return _tick_round(entry + sign * d * dist, ref, tick)
428
+
429
+ def check_stop():
430
+ nonlocal exit_price, exit_slippage, exit_reason
431
+ if exit_price is not None or not rule.get("slStop"):
432
+ return
433
+ if bar == position["entryBar"]: # not yet a working order
434
+ return
435
+ level = fixed_level("slStop", "slStopMode", -1)
436
+ if level is None:
437
+ return
438
+ if (d == 1 and lo <= level) or (d == -1 and h >= level):
439
+ beyond_at_open = o <= level if d == 1 else o >= level
440
+ raw = o if beyond_at_open else level
441
+ exit_price = _slipped(raw, -d, slip)
442
+ exit_slippage = abs(exit_price - raw) * abs(position["size"])
443
+ exit_reason = "stop"
444
+
445
+ def check_limit():
446
+ nonlocal exit_price, exit_reason
447
+ if exit_price is not None or not rule.get("tpStop"):
448
+ return
449
+ if bar == position["entryBar"]: # not yet a working order
450
+ return
451
+ level = fixed_level("tpStop", "tpStopMode", +1)
452
+ if level is None:
453
+ return
454
+ if (d == 1 and h >= level) or (d == -1 and lo <= level):
455
+ beyond_at_open = o >= level if d == 1 else o <= level
456
+ # Deliberately unslipped, unlike the stop and trail legs: a
457
+ # resting limit fills at its own price or better.
458
+ exit_price = o if beyond_at_open else level
459
+ exit_reason = "target"
460
+
461
+ def check_trail():
462
+ nonlocal exit_price, exit_slippage, exit_reason
463
+ if not rule.get("tslStop"):
464
+ return
465
+ favorable = h if d == 1 else lo
466
+ adverse = lo if d == 1 else h
467
+ arming = bar == position["entryBar"]
468
+ value = _col(df, rule["tslStop"], placement, store)
469
+ if value is None:
470
+ # No offset yet, so no level to check — but the peak still
471
+ # has to track. A distance with warm-up (ATR, stdev, a
472
+ # rolling extreme) is undefined over its first bars, and
473
+ # the arming bar reads bar-1. The peak accumulates from the
474
+ # entry bar regardless of when the offset becomes readable;
475
+ # restarting it there would trail from the wrong extreme.
476
+ position["peak"] = favorable if arming else (
477
+ max(position["peak"], favorable)
478
+ if d == 1
479
+ else min(position["peak"], favorable)
480
+ )
481
+ return
482
+ offset = (
483
+ value * ref
484
+ if rule.get("tslStopMode") == "fraction"
485
+ else value
486
+ )
487
+
488
+ def level(peak):
489
+ return peak - d * offset
490
+
491
+ def adverse_crosses(lvl):
492
+ return adverse <= lvl if d == 1 else adverse >= lvl
493
+
494
+ def close_beyond(lvl):
495
+ return c < lvl if d == 1 else c > lvl
496
+
497
+ def fill_at(lvl):
498
+ nonlocal exit_price, exit_slippage, exit_reason
499
+ exit_price = _slipped(lvl, -d, slip)
500
+ exit_slippage = abs(exit_price - lvl) * abs(position["size"])
501
+ exit_reason = "trail"
502
+
503
+ if arming:
504
+ new_peak = favorable
505
+ if exit_price is None:
506
+ lvl = level(new_peak)
507
+ fires = (
508
+ adverse_crosses(lvl)
509
+ if favorable_first
510
+ else close_beyond(lvl)
511
+ )
512
+ if fires:
513
+ fill_at(lvl)
514
+ position["peak"] = new_peak
515
+ return
516
+ peak = position["peak"]
517
+ if favorable_first:
518
+ new_peak = max(peak, favorable) if d == 1 else min(peak, favorable)
519
+ if exit_price is None and adverse_crosses(level(new_peak)):
520
+ fill_at(level(new_peak))
521
+ position["peak"] = new_peak
522
+ return
523
+ if exit_price is None and adverse_crosses(level(peak)):
524
+ fill_at(level(peak))
525
+ return
526
+ new_peak = max(peak, favorable) if d == 1 else min(peak, favorable)
527
+ if exit_price is None and close_beyond(level(new_peak)):
528
+ fill_at(level(new_peak))
529
+ position["peak"] = new_peak
530
+
531
+ if favorable_first:
532
+ check_limit()
533
+ check_stop()
534
+ check_trail()
535
+ else:
536
+ check_stop()
537
+ check_trail()
538
+ check_limit()
539
+
540
+ if exit_price is not None:
541
+ close_position(bar, exit_price, exit_slippage, exit_reason)
542
+ position = None
543
+
544
+ # 3) evaluate signals on this bar's close (fill next bar). Scoping
545
+ # the exit signal to the rule that opened the position is what
546
+ # makes a signal with nothing to close a no-op rather than an
547
+ # order stored until some later position exists to flatten
548
+ #
549
+ # The hook runs before both guards, so a column it writes is
550
+ # readable by this same bar's exit and entry signals; after them it
551
+ # would be a one-bar lag that raises nothing and still yields
552
+ # plausible trades.
553
+ if per_bar is not None:
554
+ _refresh_state(st, position, closes[bar], bar, equity,
555
+ initial_equity, wins, losses)
556
+ view._seek(bar)
557
+ per_bar(view, st)
558
+
559
+ if position is not None and position["rule"].get("exitSignal"):
560
+ v = _raw(df, position["rule"]["exitSignal"], bar, store)
561
+ if bool(v) and v == v: # truthy and not NaN
562
+ pending_exit = True
563
+
564
+ # while in a position only an OPPOSITE-direction entry signal acts
565
+ # (as a reversal) — same-direction re-entry is ignored (one entry per
566
+ # direction, no adds)
567
+ if pending is None:
568
+ for rule in rules:
569
+ v = _raw(df, rule["entries"], bar, store)
570
+ if bool(v) and v == v: # truthy and not NaN
571
+ if position is None or rule["dir"] != position["rule"]["dir"]:
572
+ pending = rule
573
+ break
574
+
575
+ # The store is the frame's copy of these columns for the whole run; write
576
+ # it back so callers read those values off `result.df`.
577
+ for name, values in store.items():
578
+ df[name] = values
579
+
580
+ if position is not None:
581
+ trades.append({
582
+ "entryId": position["rule"]["id"],
583
+ "entryBar": position["entryBar"],
584
+ "entryPrice": position["entryPrice"],
585
+ "exitBar": None,
586
+ "exitPrice": None,
587
+ "size": position["size"],
588
+ "entryCommission": position["entryCommission"],
589
+ "exitCommission": None,
590
+ "slippageCost": position["slippageCost"],
591
+ "reason": "open",
592
+ })
593
+ return trades
594
+
595
+
596
+ class _Position:
597
+ def __init__(self, strat, entry_id):
598
+ self._s, self._id = strat, entry_id
599
+
600
+ def exit_stop(self, distance, mode="price"):
601
+ self._set("slStop", "slStopMode", distance, mode)
602
+
603
+ def exit_profit(self, distance, mode="price"):
604
+ self._set("tpStop", "tpStopMode", distance, mode)
605
+
606
+ def exit_trail(self, distance, mode="price"):
607
+ self._set("tslStop", "tslStopMode", distance, mode)
608
+
609
+ def exit_signal(self, when):
610
+ """Close this position at market on the bar after `when` is true.
611
+ No mode argument: unlike the distance-driven exits there is no level
612
+ to interpret, and the exit is to flat, never a reversal."""
613
+ self._s._rules[self._id]["exitSignal"] = when.name
614
+
615
+ def _set(self, col_key, mode_key, distance, mode):
616
+ r = self._s._rules[self._id]
617
+ r[col_key] = distance.name
618
+ r[mode_key] = "fraction" if mode == "percent" else "price"
619
+
620
+
621
+ _ANSI = {
622
+ "reset": "\033[0m",
623
+ "bold": "\033[1m",
624
+ "dim": "\033[2m",
625
+ "red": "\033[31m",
626
+ "green": "\033[32m",
627
+ }
628
+
629
+ # The six values a trade's "reason" can hold, and how each is shown. Only "open"
630
+ # is renamed: on a line beside stop/target/trail, which are all level fills, the
631
+ # bare word reads as "filled at the open" — the most overloaded word in this
632
+ # file. The stored value keeps its own spelling for anything reading the trades.
633
+ _REASON_LABELS = {
634
+ "signal": "signal",
635
+ "reversal": "reversal",
636
+ "stop": "stop",
637
+ "target": "target",
638
+ "trail": "trail",
639
+ "open": "still open",
640
+ }
641
+
642
+
643
+ def _color_enabled():
644
+ """Whether the report may carry ANSI codes. Terminal-only and opt-out: a
645
+ report piped to a file or read by another program has to be plain bytes, and
646
+ NO_COLOR is the convention a user already has for turning colour off."""
647
+ if os.environ.get("NO_COLOR"): # set and non-empty, as no-color.org defines it
648
+ return False
649
+ if os.environ.get("TERM") == "dumb":
650
+ return False
651
+ try:
652
+ return bool(sys.stdout.isatty())
653
+ except (AttributeError, ValueError): # detached or closed stdout
654
+ return False
655
+
656
+
657
+ def _paint(text, key, on):
658
+ """Wrap ALREADY-PADDED text. Painting before padding would count the escape
659
+ bytes as width and skew every column in the table."""
660
+ return f"{_ANSI[key]}{text}{_ANSI['reset']}" if on else text
661
+
662
+
663
+ def _quantity(size):
664
+ """Every digit a size carries, up to six decimals: a quantity sized by risk
665
+ is rarely whole, and significant-digit formats switch to exponent form at a
666
+ million."""
667
+ return f"{abs(size):.6f}".rstrip("0").rstrip(".")
668
+
669
+
670
+ def _is_intraday(index):
671
+ """Whether any bar sits off midnight. Decided once for the whole run, so a
672
+ midnight bar inside an intraday run still prints its time."""
673
+ return any(
674
+ getattr(value, "hour", 0)
675
+ or getattr(value, "minute", 0)
676
+ or getattr(value, "second", 0)
677
+ for value in index
678
+ )
679
+
680
+
681
+ def _stamp(value, intraday):
682
+ """A bar's label as the reader's own data has it: the date alone for a run
683
+ whose every bar sits at midnight, the minute too otherwise, so an intraday
684
+ run is not a column of identical dates. A frame not indexed by time falls
685
+ back to str()."""
686
+ strftime = getattr(value, "strftime", None)
687
+ if strftime is None:
688
+ return str(value)
689
+ return strftime("%Y-%m-%d %H:%M" if intraday else "%Y-%m-%d")
690
+
691
+
692
+ class BacktestResult:
693
+ def __init__(self, df, trades, warmup_bars=0):
694
+ self.df, self.trades = df, trades
695
+ self.warmup_bars = warmup_bars
696
+
697
+ def summary(self):
698
+ closed = [t for t in self.trades if t["exitPrice"] is not None]
699
+
700
+ def net_of(t):
701
+ return ((t["exitPrice"] - t["entryPrice"]) * t["size"]
702
+ - t["entryCommission"] - t["exitCommission"])
703
+
704
+ net = sum(net_of(t) for t in closed)
705
+ wins = sum(1 for t in closed if net_of(t) > 0)
706
+ # Spans EVERY trade, including one still open: its entry commission
707
+ # was paid. net_profit is closed-only, so the two sit on different
708
+ # populations by design.
709
+ costs = sum(
710
+ t["entryCommission"]
711
+ + (t["exitCommission"] if t["exitCommission"] is not None else 0.0)
712
+ + t["slippageCost"]
713
+ for t in self.trades
714
+ )
715
+ return {"trades": len(self.trades), "closed": len(closed),
716
+ "net_profit": round(net, 2),
717
+ "win_rate": round(wins / len(closed), 2) if closed else None,
718
+ "costs": round(costs, 2)}
719
+
720
+ def report(self, color=None):
721
+ """The whole run as text: the span it covered, the headline numbers, and
722
+ every trade with the reason it closed.
723
+
724
+ `color` defaults to auto-detection; pass False for plain bytes whatever
725
+ stdout is. The five headline numbers are taken from summary() rather than
726
+ recomputed, so the two can never disagree — but each trade's Net is
727
+ rounded for display, so that column will not always add up to Net Profit,
728
+ which summary() rounds once at the end. This is a report, not a ledger.
729
+ """
730
+ on = _color_enabled() if color is None else bool(color)
731
+ s = self.summary()
732
+ index = self.df.index
733
+ bars = len(index)
734
+ intraday = _is_intraday(index)
735
+ win_rate = s["win_rate"]
736
+ rows = [
737
+ ("Start", _stamp(index[0], intraday) if bars else "-"),
738
+ ("End", _stamp(index[-1], intraday) if bars else "-"),
739
+ ("Bars", str(bars)),
740
+ ("Warm-up bars", str(self.warmup_bars)),
741
+ ("Trades", str(s["trades"])),
742
+ ("Closed trades", str(s["closed"])),
743
+ # The unit brackets carry a qualifier rather than a currency: the
744
+ # engine is never told one, and every closed trade's costs are
745
+ # already inside Net Profit, so adjacent bare labels would invite
746
+ # subtracting one from the other. They are not even on the same
747
+ # population — costs spans the open trade, net profit is closed-only.
748
+ ("Net Profit [after costs]", f"{s['net_profit']:.2f}"),
749
+ # summary() rounds the rate to two places, so a whole percent is all
750
+ # the precision there is.
751
+ ("Win Rate [%]", "-" if win_rate is None else f"{win_rate * 100:.0f}"),
752
+ ("Costs [all trades]", f"{s['costs']:.2f}"),
753
+ ]
754
+ label_width = max(len(label) for label, _ in rows)
755
+ value_width = max(len(value) for _, value in rows)
756
+ lines = [
757
+ f"{label:<{label_width}} {value:>{value_width}}" for label, value in rows
758
+ ]
759
+ if bars and self.warmup_bars >= bars:
760
+ # Bars and Warm-up bars would otherwise print the same number beside a
761
+ # non-zero trade count, three rows contradicting each other on sight.
762
+ lines.append(
763
+ "At least one column never held a value, so warm-up spans the"
764
+ " whole run."
765
+ )
766
+ return "\n".join(lines + [""] + self._trade_lines(on, intraday))
767
+
768
+ def _trade_lines(self, on, intraday):
769
+ heading = _paint("Trades", "bold", on)
770
+ if not self.trades:
771
+ return [heading, " none"]
772
+ index = self.df.index
773
+ header = ("#", "Side", "Size", "Entry Date", "Entry Price",
774
+ "Exit Date", "Exit Price", "Reason", "Net")
775
+ # True where the column reads as a quantity and should sit right-aligned.
776
+ numeric = (True, False, True, False, True, False, True, False, True)
777
+ body = [
778
+ self._trade_cells(i, t, index, intraday)
779
+ for i, t in enumerate(self.trades, 1)
780
+ ]
781
+ widths = [
782
+ max(len(row[c]) for row in [header, *body]) for c in range(len(header))
783
+ ]
784
+
785
+ def lay(cells):
786
+ return " " + " ".join(
787
+ cell.rjust(widths[c]) if numeric[c] else cell.ljust(widths[c])
788
+ for c, cell in enumerate(cells)
789
+ ).rstrip()
790
+
791
+ lines = [heading, _paint(lay(header), "bold", on)]
792
+ for row, trade in zip(body, self.trades):
793
+ line = lay(row)
794
+ if trade["exitPrice"] is None:
795
+ lines.append(_paint(line, "dim", on))
796
+ continue
797
+ net = self._net(trade)
798
+ if net > 0:
799
+ lines.append(_paint(line, "green", on))
800
+ elif net < 0:
801
+ lines.append(_paint(line, "red", on))
802
+ else: # neither a win nor a loss, as summary() counts it
803
+ lines.append(line)
804
+ return lines
805
+
806
+ @staticmethod
807
+ def _net(trade):
808
+ return ((trade["exitPrice"] - trade["entryPrice"]) * trade["size"]
809
+ - trade["entryCommission"] - trade["exitCommission"])
810
+
811
+ def _trade_cells(self, number, trade, index, intraday):
812
+ closed = trade["exitPrice"] is not None
813
+ size = trade["size"]
814
+ # A NaN or zero size has no side; both comparisons are false for NaN.
815
+ side = "long" if size > 0 else "short" if size < 0 else "?"
816
+ return (
817
+ str(number),
818
+ side,
819
+ _quantity(size),
820
+ _stamp(index[trade["entryBar"]], intraday),
821
+ f"{trade['entryPrice']:.2f}",
822
+ _stamp(index[trade["exitBar"]], intraday) if closed else "-",
823
+ f"{trade['exitPrice']:.2f}" if closed else "-",
824
+ _REASON_LABELS.get(trade["reason"], trade["reason"]),
825
+ f"{self._net(trade):.2f}" if closed else "-",
826
+ )
827
+
828
+ def __str__(self):
829
+ return self.report()
830
+
831
+ # A REPL or a notebook cell echoing `result` calls __repr__, not __str__, and
832
+ # an object address is the exact thing this report exists to replace.
833
+ __repr__ = __str__
834
+
835
+
836
+ class Strategy:
837
+ def __init__(self, df):
838
+ self.df, self._rules, self._per_bar = df, {}, None
839
+
840
+ def per_bar(self, fn, names):
841
+ """Register the per-bar hook and pre-create the columns it writes. The
842
+ columns have to exist before any rule is registered: a rule binds its
843
+ parameter by being handed the Series and keeping only its name."""
844
+ self._per_bar = (fn, list(names))
845
+ for name in names:
846
+ self.df[name] = float("nan")
847
+ return self
848
+
849
+ def entry(self, entry_id, direction, when, qty):
850
+ self._rules[entry_id] = {"id": entry_id, "dir": direction,
851
+ "entries": when.name, "qty": qty}
852
+ return _Position(self, entry_id)
853
+
854
+ def size_risk(self, risk, distance):
855
+ return {"__kind__": "risk", "risk": risk, "distance": distance.name}
856
+
857
+ def backtest(self, initial_equity=INITIAL_EQUITY, commission_pct=0.0,
858
+ slippage_ticks=0):
859
+ fn, names = self._per_bar if self._per_bar is not None else (None, ())
860
+ trades = simulate(self.df, list(self._rules.values()),
861
+ initial_equity=initial_equity,
862
+ commission_pct=commission_pct,
863
+ slippage_ticks=slippage_ticks,
864
+ per_bar=fn, runtime_columns=names)
865
+ return BacktestResult(self.df, trades, _warmup_bars(self.df, names))
@@ -0,0 +1,51 @@
1
+ # Packaging for the `gstrat` PyPI distribution. The engine deliberately stays a
2
+ # single flat module at `engine.py`: the wheel remaps it to `gstrat/__init__.py`
3
+ # rather than the file moving, so the bake target, the version lock and the
4
+ # unittest modules all keep the path they already use. See ../README.md.
5
+ [build-system]
6
+ requires = ["hatchling>=1.27"]
7
+ build-backend = "hatchling.build"
8
+
9
+ [project]
10
+ name = "gstrat"
11
+ description = "Reference backtest engine for GStrat-generated Python strategies."
12
+ readme = "README.md"
13
+ requires-python = ">=3.11"
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+ authors = [{ name = "GStrat" }]
17
+ keywords = ["backtest", "trading", "strategy", "quant"]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Financial and Insurance Industry",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Office/Business :: Financial :: Investment",
23
+ ]
24
+ # numpy is imported by engine.py. pandas is not, but it is the public API's
25
+ # argument type — Strategy(df) and every column read go through a DataFrame —
26
+ # so an install without it cannot be called. The pandas floor is 2.2.2, not
27
+ # 2.0: earlier pandas is built against the NumPy 1 C ABI, so `pandas>=2.0` with
28
+ # an unbounded numpy is a pip-resolvable pair that raises on `import pandas`.
29
+ dependencies = ["numpy>=1.26", "pandas>=2.2.2"]
30
+ dynamic = ["version"]
31
+
32
+ [project.urls]
33
+ # Homepage only: the GitHub repository is private, so a Source link would 404.
34
+ Homepage = "https://gstrat.net"
35
+
36
+ # The distribution version IS the engine version — there is no second place to
37
+ # write one, so the two cannot drift.
38
+ [tool.hatch.version]
39
+ path = "engine.py"
40
+ pattern = 'ENGINE_VERSION = "(?P<version>[^"]+)"'
41
+
42
+ [tool.hatch.build.targets.wheel.force-include]
43
+ "engine.py" = "gstrat/__init__.py"
44
+
45
+ # force-include is scoped to the wheel target, so without this stanza the sdist
46
+ # falls back to hatchling's sweep of the whole directory and publishes
47
+ # engine.lock.json and the unittest modules. The tests are left out
48
+ # deliberately: they import `engine`, which does not exist in an installed
49
+ # gstrat.
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["engine.py", "pyproject.toml", "README.md", "LICENSE"]