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.
- gstrat-1.9.0/.gitignore +1 -0
- gstrat-1.9.0/LICENSE +21 -0
- gstrat-1.9.0/PKG-INFO +107 -0
- gstrat-1.9.0/README.md +89 -0
- gstrat-1.9.0/engine.py +865 -0
- gstrat-1.9.0/pyproject.toml +51 -0
gstrat-1.9.0/.gitignore
ADDED
|
@@ -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"]
|