BackcastPro 0.0.2__py3-none-any.whl → 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of BackcastPro might be problematic. Click here for more details.
- BackcastPro/__init__.py +4 -86
- BackcastPro/_broker.py +390 -0
- BackcastPro/_stats.py +169 -212
- BackcastPro/backtest.py +269 -0
- BackcastPro/data/__init__.py +7 -0
- BackcastPro/data/datareader.py +168 -0
- BackcastPro/order.py +154 -0
- BackcastPro/position.py +61 -0
- BackcastPro/strategy.py +174 -0
- BackcastPro/trade.py +195 -0
- backcastpro-0.0.3.dist-info/METADATA +59 -0
- backcastpro-0.0.3.dist-info/RECORD +14 -0
- BackcastPro/_plotting.py +0 -785
- BackcastPro/_util.py +0 -337
- BackcastPro/backtesting.py +0 -1763
- BackcastPro/lib.py +0 -646
- BackcastPro/test/__init__.py +0 -29
- BackcastPro/test/__main__.py +0 -7
- BackcastPro/test/_test.py +0 -1174
- backcastpro-0.0.2.dist-info/METADATA +0 -53
- backcastpro-0.0.2.dist-info/RECORD +0 -13
- {backcastpro-0.0.2.dist-info → backcastpro-0.0.3.dist-info}/WHEEL +0 -0
- {backcastpro-0.0.2.dist-info → backcastpro-0.0.3.dist-info}/top_level.txt +0 -0
BackcastPro/_stats.py
CHANGED
|
@@ -1,212 +1,169 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
iloc =
|
|
16
|
-
|
|
17
|
-
df =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
df['
|
|
25
|
-
df
|
|
26
|
-
df
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
returns
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
s.loc['
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
s.loc['
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
s.loc['
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
s.loc['
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
s.loc['
|
|
166
|
-
s.loc['
|
|
167
|
-
s.loc['
|
|
168
|
-
|
|
169
|
-
s
|
|
170
|
-
s.loc['# Trades'] = n_trades = len(trades_df)
|
|
171
|
-
win_rate = np.nan if not n_trades else (pl > 0).mean()
|
|
172
|
-
s.loc['Win Rate [%]'] = win_rate * 100
|
|
173
|
-
s.loc['Best Trade [%]'] = returns.max() * 100
|
|
174
|
-
s.loc['Worst Trade [%]'] = returns.min() * 100
|
|
175
|
-
mean_return = geometric_mean(returns)
|
|
176
|
-
s.loc['Avg. Trade [%]'] = mean_return * 100
|
|
177
|
-
s.loc['Max. Trade Duration'] = _round_timedelta(durations.max())
|
|
178
|
-
s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean())
|
|
179
|
-
s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
|
|
180
|
-
s.loc['Expectancy [%]'] = returns.mean() * 100
|
|
181
|
-
s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
|
|
182
|
-
s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
|
|
183
|
-
|
|
184
|
-
s.loc['_strategy'] = strategy_instance
|
|
185
|
-
s.loc['_equity_curve'] = equity_df
|
|
186
|
-
s.loc['_trades'] = trades_df
|
|
187
|
-
|
|
188
|
-
s = _Stats(s)
|
|
189
|
-
return s
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
class _Stats(pd.Series):
|
|
193
|
-
def __repr__(self):
|
|
194
|
-
with pd.option_context(
|
|
195
|
-
'display.max_colwidth', 20, # Prevent expansion due to _equity and _trades dfs
|
|
196
|
-
'display.max_rows', len(self), # Reveal self whole
|
|
197
|
-
'display.precision', 5, # Enough for my eyes at least
|
|
198
|
-
# 'format.na_rep', '--', # TODO: Enable once it works
|
|
199
|
-
):
|
|
200
|
-
return super().__repr__()
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def dummy_stats():
|
|
204
|
-
from .backtesting import Trade, _Broker
|
|
205
|
-
index = pd.DatetimeIndex(['2025'])
|
|
206
|
-
data = pd.DataFrame({col: [np.nan] for col in ('Close',)}, index=index)
|
|
207
|
-
trade = Trade(_Broker(data=data, cash=10000, spread=.01, commission=.01, margin=.1,
|
|
208
|
-
trade_on_close=True, hedging=True, exclusive_orders=False, index=index),
|
|
209
|
-
1, 1, 0, None)
|
|
210
|
-
trade._replace(exit_price=1, exit_bar=0)
|
|
211
|
-
trade._commissions = np.nan
|
|
212
|
-
return compute_stats([trade], np.r_[[np.nan]], data, None, 0)
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from numbers import Number
|
|
4
|
+
from typing import TYPE_CHECKING, List, Union, cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .strategy import Strategy
|
|
11
|
+
from .trade import Trade
|
|
12
|
+
|
|
13
|
+
def compute_drawdown_duration_peaks(dd: pd.Series):
|
|
14
|
+
iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1])
|
|
15
|
+
iloc = pd.Series(iloc, index=dd.index[iloc])
|
|
16
|
+
df = iloc.to_frame('iloc').assign(prev=iloc.shift())
|
|
17
|
+
df = df[df['iloc'] > df['prev'] + 1].astype(np.int64)
|
|
18
|
+
|
|
19
|
+
# 取引がないためドローダウンがない場合、pandasのために以下を回避し、nanシリーズを返す
|
|
20
|
+
if not len(df):
|
|
21
|
+
return (dd.replace(0, np.nan),) * 2
|
|
22
|
+
|
|
23
|
+
df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__)
|
|
24
|
+
df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1)
|
|
25
|
+
df = df.reindex(dd.index)
|
|
26
|
+
return df['duration'], df['peak_dd']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def geometric_mean(returns: pd.Series) -> float:
|
|
30
|
+
returns = returns.fillna(0) + 1
|
|
31
|
+
if np.any(returns <= 0):
|
|
32
|
+
return 0
|
|
33
|
+
return np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1
|
|
34
|
+
|
|
35
|
+
def _data_period(index) -> Union[pd.Timedelta, Number]:
|
|
36
|
+
"""Return data index period as pd.Timedelta"""
|
|
37
|
+
values = pd.Series(index[-100:])
|
|
38
|
+
return values.diff().dropna().median()
|
|
39
|
+
|
|
40
|
+
def compute_stats(
|
|
41
|
+
trades: Union[List['Trade'], pd.DataFrame],
|
|
42
|
+
equity: np.ndarray,
|
|
43
|
+
ohlc_data: pd.DataFrame,
|
|
44
|
+
strategy_instance: Strategy | None,
|
|
45
|
+
risk_free_rate: float = 0,
|
|
46
|
+
) -> pd.Series:
|
|
47
|
+
assert -1 < risk_free_rate < 1
|
|
48
|
+
|
|
49
|
+
index = ohlc_data.index
|
|
50
|
+
dd = 1 - equity / np.maximum.accumulate(equity)
|
|
51
|
+
dd_dur, dd_peaks = compute_drawdown_duration_peaks(pd.Series(dd, index=index))
|
|
52
|
+
|
|
53
|
+
equity_df = pd.DataFrame({
|
|
54
|
+
'Equity': equity,
|
|
55
|
+
'DrawdownPct': dd,
|
|
56
|
+
'DrawdownDuration': dd_dur},
|
|
57
|
+
index=index)
|
|
58
|
+
|
|
59
|
+
if isinstance(trades, pd.DataFrame):
|
|
60
|
+
trades_df: pd.DataFrame = trades
|
|
61
|
+
commissions = None # Not shown
|
|
62
|
+
else:
|
|
63
|
+
# Came straight from Backtest.run()
|
|
64
|
+
trades_df = pd.DataFrame({
|
|
65
|
+
'Size': [t.size for t in trades],
|
|
66
|
+
'EntryBar': [t.entry_bar for t in trades],
|
|
67
|
+
'ExitBar': [t.exit_bar for t in trades],
|
|
68
|
+
'EntryPrice': [t.entry_price for t in trades],
|
|
69
|
+
'ExitPrice': [t.exit_price for t in trades],
|
|
70
|
+
'SL': [t.sl for t in trades],
|
|
71
|
+
'TP': [t.tp for t in trades],
|
|
72
|
+
'PnL': [t.pl for t in trades],
|
|
73
|
+
'Commission': [t._commissions for t in trades],
|
|
74
|
+
'ReturnPct': [t.pl_pct for t in trades],
|
|
75
|
+
'EntryTime': [t.entry_time for t in trades],
|
|
76
|
+
'ExitTime': [t.exit_time for t in trades],
|
|
77
|
+
})
|
|
78
|
+
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
|
|
79
|
+
trades_df['Tag'] = [t.tag for t in trades]
|
|
80
|
+
|
|
81
|
+
commissions = sum(t._commissions for t in trades)
|
|
82
|
+
del trades
|
|
83
|
+
|
|
84
|
+
pl = trades_df['PnL']
|
|
85
|
+
returns = trades_df['ReturnPct']
|
|
86
|
+
durations = trades_df['Duration']
|
|
87
|
+
|
|
88
|
+
def _round_timedelta(value, _period=_data_period(index)):
|
|
89
|
+
if not isinstance(value, pd.Timedelta):
|
|
90
|
+
return value
|
|
91
|
+
resolution = getattr(_period, 'resolution_string', None) or _period.resolution
|
|
92
|
+
return value.ceil(resolution)
|
|
93
|
+
|
|
94
|
+
s = pd.Series(dtype=object)
|
|
95
|
+
s.loc['Start'] = index[0]
|
|
96
|
+
s.loc['End'] = index[-1]
|
|
97
|
+
s.loc['Duration'] = s.End - s.Start
|
|
98
|
+
|
|
99
|
+
have_position = np.repeat(0, len(index))
|
|
100
|
+
for t in trades_df.itertuples(index=False):
|
|
101
|
+
have_position[t.EntryBar:t.ExitBar + 1] = 1
|
|
102
|
+
|
|
103
|
+
s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time
|
|
104
|
+
s.loc['Equity Final [$]'] = equity[-1]
|
|
105
|
+
s.loc['Equity Peak [$]'] = equity.max()
|
|
106
|
+
if commissions:
|
|
107
|
+
s.loc['Commissions [$]'] = commissions
|
|
108
|
+
s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
|
|
109
|
+
|
|
110
|
+
gmean_day_return: float = 0
|
|
111
|
+
day_returns = np.array(np.nan)
|
|
112
|
+
annual_trading_days = np.nan
|
|
113
|
+
is_datetime_index = isinstance(index, pd.DatetimeIndex)
|
|
114
|
+
if is_datetime_index:
|
|
115
|
+
freq_days = cast(pd.Timedelta, _data_period(index)).days
|
|
116
|
+
have_weekends = index.dayofweek.to_series().between(5, 6).mean() > 2 / 7 * .6
|
|
117
|
+
annual_trading_days = (
|
|
118
|
+
52 if freq_days == 7 else
|
|
119
|
+
12 if freq_days == 31 else
|
|
120
|
+
1 if freq_days == 365 else
|
|
121
|
+
(365 if have_weekends else 252))
|
|
122
|
+
freq = {7: 'W', 31: 'ME', 365: 'YE'}.get(freq_days, 'D')
|
|
123
|
+
day_returns = equity_df['Equity'].resample(freq).last().dropna().pct_change()
|
|
124
|
+
gmean_day_return = geometric_mean(day_returns)
|
|
125
|
+
|
|
126
|
+
# Annualized return and risk metrics are computed based on the (mostly correct)
|
|
127
|
+
# assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517
|
|
128
|
+
# Our annualized return matches `empyrical.annual_return(day_returns)` whereas
|
|
129
|
+
# our risk doesn't; they use the simpler approach below.
|
|
130
|
+
annualized_return = (1 + gmean_day_return)**annual_trading_days - 1
|
|
131
|
+
s.loc['Return (Ann.) [%]'] = annualized_return * 100
|
|
132
|
+
s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2 * annual_trading_days)) * 100 # noqa: E501
|
|
133
|
+
# s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
|
|
134
|
+
# s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
|
|
135
|
+
if is_datetime_index:
|
|
136
|
+
time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days
|
|
137
|
+
s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
|
|
138
|
+
|
|
139
|
+
# Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
|
|
140
|
+
# and simple standard deviation
|
|
141
|
+
s.loc['Sharpe Ratio'] = (s.loc['Return (Ann.) [%]'] - risk_free_rate * 100) / (s.loc['Volatility (Ann.) [%]'] or np.nan) # noqa: E501
|
|
142
|
+
# Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
|
|
143
|
+
with np.errstate(divide='ignore'):
|
|
144
|
+
s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501
|
|
145
|
+
max_dd = -np.nan_to_num(dd.max())
|
|
146
|
+
s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
|
|
147
|
+
s.loc['Max. Drawdown [%]'] = max_dd * 100
|
|
148
|
+
s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
|
|
149
|
+
s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
|
|
150
|
+
s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
|
|
151
|
+
s.loc['# Trades'] = n_trades = len(trades_df)
|
|
152
|
+
win_rate = np.nan if not n_trades else (pl > 0).mean()
|
|
153
|
+
s.loc['Win Rate [%]'] = win_rate * 100
|
|
154
|
+
s.loc['Best Trade [%]'] = returns.max() * 100
|
|
155
|
+
s.loc['Worst Trade [%]'] = returns.min() * 100
|
|
156
|
+
mean_return = geometric_mean(returns)
|
|
157
|
+
s.loc['Avg. Trade [%]'] = mean_return * 100
|
|
158
|
+
s.loc['Max. Trade Duration'] = _round_timedelta(durations.max())
|
|
159
|
+
s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean())
|
|
160
|
+
s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan)
|
|
161
|
+
s.loc['Expectancy [%]'] = returns.mean() * 100
|
|
162
|
+
s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
|
|
163
|
+
s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
|
|
164
|
+
|
|
165
|
+
s.loc['_strategy'] = strategy_instance
|
|
166
|
+
s.loc['_equity_curve'] = equity_df
|
|
167
|
+
s.loc['_trades'] = trades_df
|
|
168
|
+
|
|
169
|
+
return s
|