BackcastPro 0.3.4__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.
@@ -0,0 +1,28 @@
1
+ """
2
+ BackcastPro をご利用いただきありがとうございます。
3
+
4
+ インストール後のご案内(インストール済みユーザー向け)
5
+
6
+ - ドキュメント総合トップ: [index.md](https://github.com/botterYosuke/BackcastPro/blob/main/docs/index.md)
7
+ - クイックスタート/チュートリアル: [tutorial.md](https://github.com/botterYosuke/BackcastPro/blob/main/docs/tutorial.md)
8
+ - APIリファレンス: [BackcastPro - APIリファレンス](https://botteryosuke.github.io/BackcastPro/namespacesrc_1_1BackcastPro.html)
9
+ - トラブルシューティング: [troubleshooting.md](https://github.com/botterYosuke/BackcastPro/blob/main/docs/troubleshooting.md)
10
+
11
+ ※ 使い始めはチュートリアル → 詳細はAPIリファレンスをご参照ください。
12
+ """
13
+ from .backtest import Backtest
14
+
15
+ from .api.stocks_price import get_stock_daily
16
+ from .api.stocks_board import get_stock_board
17
+ from .api.stocks_info import get_stock_info
18
+ from .api.chart import chart
19
+ from .api.board import board
20
+
21
+ __all__ = [
22
+ 'Backtest',
23
+ 'get_stock_daily',
24
+ 'get_stock_board',
25
+ 'get_stock_info',
26
+ 'chart',
27
+ 'board'
28
+ ]
BackcastPro/_broker.py ADDED
@@ -0,0 +1,430 @@
1
+ """
2
+ ブローカー管理モジュール。
3
+ """
4
+
5
+ import warnings
6
+ from math import copysign
7
+ from typing import List, Optional, TYPE_CHECKING
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .order import Order
13
+ from .position import Position
14
+ from .trade import Trade
15
+
16
+ if TYPE_CHECKING:
17
+ pass
18
+
19
+
20
+ class _Broker:
21
+ """
22
+ バックテストにおける証券取引の実行、注文管理、ポジション管理、損益計算を担当します。
23
+ 実際の証券会社のブローカー機能をシミュレートし、リアルな取引環境を提供します。
24
+
25
+ Parameters
26
+ ----------
27
+ data : pd.DataFrame
28
+ 取引対象の価格データ。Open, High, Low, Closeの列を持つ必要があります。
29
+ cash : float
30
+ 初期現金残高。正の値である必要があります。
31
+ spread : float
32
+ ビッドアスクスプレッド(買値と売値の差)。取引コストとして使用されます。
33
+ commission : float or tuple or callable
34
+ 手数料の設定方法:
35
+ - float: 相対手数料(例: 0.001 = 0.1%)
36
+ - tuple: (固定手数料, 相対手数料) の組み合わせ
37
+ - callable: カスタム手数料計算関数 (size, price) -> 手数料
38
+ margin : float
39
+ 必要証拠金率(0 < margin <= 1)。レバレッジ = 1/margin として計算されます。
40
+ trade_on_close : bool
41
+ 取引を終値で実行するかどうか。Trueの場合、次の始値ではなく現在の終値で取引します。
42
+ hedging : bool
43
+ ヘッジングモードの有効化。Trueの場合、反対方向のポジションを同時に保有できます。
44
+ exclusive_orders : bool
45
+ 排他的注文モード。Trueの場合、新しい注文が前のポジションを自動的にクローズします。
46
+ """
47
+
48
+ # ヒント:
49
+ # 関数定義における`*`の意味
50
+ # - `*`以降の引数は、必ずキーワード引数として渡す必要がある
51
+ # - 位置引数として渡すことはできない
52
+ # なぜキーワード専用引数を使うのか?
53
+ # 1. APIの明確性: 引数の意味が明確になる
54
+ # 2. 保守性: 引数の順序を変更しても既存のコードが壊れない
55
+ # 3. 可読性: 関数呼び出し時に何を渡しているかが分かりやすい
56
+ def __init__(self, *, data, cash, spread, commission, margin,
57
+ trade_on_close, hedging, exclusive_orders):
58
+ assert cash > 0, f"cash should be > 0, is {cash}"
59
+ assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}"
60
+ self._data: dict[str, pd.DataFrame] = data
61
+ self._cash = cash
62
+
63
+ # 手数料の登録
64
+ if callable(commission):
65
+ # 関数`commission`が呼び出し可能な場合
66
+ self._commission = commission
67
+ else:
68
+ try:
69
+ self._commission_fixed, self._commission_relative = commission
70
+ except TypeError:
71
+ self._commission_fixed, self._commission_relative = 0, commission
72
+ assert self._commission_fixed >= 0, 'Need fixed cash commission in $ >= 0'
73
+ assert -.1 <= self._commission_relative < .1, \
74
+ ("commission should be between -10% "
75
+ f"(e.g. market-maker's rebates) and 10% (fees), is {self._commission_relative}")
76
+ self._commission = self._commission_func
77
+
78
+
79
+ self._spread = spread
80
+ self._leverage = 1 / margin
81
+ self._trade_on_close = trade_on_close
82
+ self._hedging = hedging
83
+ self._exclusive_orders = exclusive_orders
84
+
85
+ self._equity = []
86
+ self._current_time = None
87
+ self.orders: List[Order] = []
88
+ self.trades: List[Trade] = []
89
+ self.position = Position(self)
90
+ self.closed_trades: List[Trade] = []
91
+
92
+ def _commission_func(self, order_size, price):
93
+ return self._commission_fixed + abs(order_size) * price * self._commission_relative
94
+
95
+ def new_order(self,
96
+ code: str,
97
+ size: float,
98
+ limit: Optional[float] = None,
99
+ stop: Optional[float] = None,
100
+ sl: Optional[float] = None,
101
+ tp: Optional[float] = None,
102
+ tag: object = None,
103
+ *,
104
+ trade: Optional[Trade] = None) -> Order:
105
+ """
106
+ Argument size indicates whether the order is long or short
107
+ """
108
+ size = float(size)
109
+ stop = stop and float(stop)
110
+ limit = limit and float(limit)
111
+ sl = sl and float(sl)
112
+ tp = tp and float(tp)
113
+
114
+ is_long = size > 0
115
+ assert size != 0, size
116
+ adjusted_price = self._adjusted_price(code, size)
117
+
118
+ if is_long:
119
+ if not (sl or -np.inf) < (limit or stop or adjusted_price) < (tp or np.inf):
120
+ raise ValueError(
121
+ "Long orders require: "
122
+ f"SL ({sl}) < LIMIT ({limit or stop or adjusted_price}) < TP ({tp})")
123
+ else:
124
+ if not (tp or -np.inf) < (limit or stop or adjusted_price) < (sl or np.inf):
125
+ raise ValueError(
126
+ "Short orders require: "
127
+ f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")
128
+
129
+ order = Order(self, code, size, limit, stop, sl, tp, trade, tag)
130
+
131
+ if not trade:
132
+ # 排他的注文(各新しい注文が前の注文/ポジションを自動クローズ)の場合、
133
+ # 事前にすべての非条件付き注文をキャンセルし、すべてのオープン取引をクローズ
134
+ if self._exclusive_orders:
135
+ for o in self.orders:
136
+ if not o.is_contingent:
137
+ o.cancel()
138
+ for t in self.trades:
139
+ t.close()
140
+
141
+ # 新しい注文を注文キューに配置、SL注文が最初に処理されるようにする
142
+ self.orders.insert(0 if trade and stop else len(self.orders), order)
143
+
144
+ return order
145
+
146
+ def last_price(self, code: str) -> float:
147
+ """ Price at the last (current) close. """
148
+ return self._data[code].Close.iloc[-1]
149
+
150
+ def _adjusted_price(self, code: str, size=None, price=None) -> float:
151
+ """
152
+ Long/short `price`, adjusted for spread.
153
+ In long positions, the adjusted price is a fraction higher, and vice versa.
154
+ """
155
+ return (price or self.last_price(code)) * (1 + copysign(self._spread, size))
156
+
157
+ @property
158
+ def equity(self) -> float:
159
+ return self._cash + sum(trade.pl for trade in self.trades)
160
+
161
+ @property
162
+ def margin_available(self) -> float:
163
+ # https://github.com/QuantConnect/Lean/pull/3768 から
164
+ margin_used = sum(trade.value / self._leverage for trade in self.trades)
165
+ return max(0, self.equity - margin_used)
166
+
167
+ @property
168
+ def cash(self):
169
+ return self._cash
170
+
171
+ @property
172
+ def commission(self):
173
+ return self._commission
174
+
175
+ def next(self, current_time: pd.Timestamp):
176
+ self._current_time = current_time
177
+ self._process_orders()
178
+
179
+ # エクイティカーブ用にアカウントエクイティを記録
180
+ equity = self.equity
181
+ self._equity.append(equity)
182
+
183
+ # エクイティが負の場合、すべてを0に設定してシミュレーションを停止
184
+ if equity <= 0:
185
+ assert self.margin_available <= 0
186
+ for trade in self.trades:
187
+ price = self._data[trade.code].Close.iloc[-1]
188
+ self._close_trade(trade, price, self._current_time)
189
+ self._cash = 0
190
+ raise Exception
191
+
192
+ def _process_orders(self):
193
+ data = self._data
194
+ reprocess_orders = False
195
+
196
+ # 注文を処理
197
+ for order in list(self.orders): # 型: Order
198
+
199
+ # 関連するSL/TP注文は既に削除されている
200
+ if order not in self.orders:
201
+ continue
202
+
203
+ # 注文の銘柄データを取得
204
+ if order.code not in data:
205
+ continue
206
+ df = data[order.code]
207
+
208
+ # データの存在確認
209
+ if len(df) == 0 or df.index[-1] != self._current_time:
210
+ continue
211
+
212
+ open, high, low = df.Open.iloc[-1], df.High.iloc[-1], df.Low.iloc[-1]
213
+
214
+ # ストップ条件が満たされたかチェック
215
+ stop_price = order.stop
216
+ if stop_price:
217
+ is_stop_hit = ((high >= stop_price) if order.is_long else (low <= stop_price))
218
+ if not is_stop_hit:
219
+ continue
220
+
221
+ # ストップ価格に達すると、ストップ注文は成行/指値注文になる
222
+ # https://www.sec.gov/fast-answers/answersstopordhtm.html
223
+ order._replace(stop_price=None)
224
+
225
+ # 購入価格を決定
226
+ # 指値注文が約定可能かチェック
227
+ if order.limit:
228
+ is_limit_hit = low <= order.limit if order.is_long else high >= order.limit
229
+ # ストップとリミットが同じバー内で満たされた場合、悲観的に
230
+ # リミットがストップより先に満たされたと仮定する(つまり「カウントされる前に」)
231
+ is_limit_hit_before_stop = (is_limit_hit and
232
+ (order.limit <= (stop_price or -np.inf)
233
+ if order.is_long
234
+ else order.limit >= (stop_price or np.inf)))
235
+ if not is_limit_hit or is_limit_hit_before_stop:
236
+ continue
237
+
238
+ # stop_priceが設定されている場合、このバー内で満たされた
239
+ price = (min(stop_price or open, order.limit)
240
+ if order.is_long else
241
+ max(stop_price or open, order.limit))
242
+ else:
243
+ # 成行注文(Market-if-touched / market order)
244
+ # 条件付き注文は常に次の始値で
245
+ prev_close = df.Close.iloc[-2]
246
+ price = prev_close if self._trade_on_close and not order.is_contingent else open
247
+ if stop_price:
248
+ price = max(price, stop_price) if order.is_long else min(price, stop_price)
249
+
250
+ # エントリー/エグジットバーのインデックスを決定
251
+ is_market_order = not order.limit and not stop_price
252
+
253
+ # 注文がSL/TP注文の場合、それが依存していた既存の取引をクローズする必要がある
254
+ if order.parent_trade:
255
+ trade = order.parent_trade
256
+ _prev_size = trade.size
257
+ # order.sizeがtrade.sizeより「大きい」場合、この注文はtrade.close()注文で
258
+ # 取引の一部は事前にクローズされている
259
+ size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
260
+ # この取引がまだクローズされていない場合(例:複数の`trade.close(.5)`呼び出し)
261
+ if trade in self.trades:
262
+ self._reduce_trade(trade, price, size, self._current_time)
263
+ assert order.size != -_prev_size or trade not in self.trades
264
+ if price == stop_price:
265
+ # 統計用にSLを注文に戻す
266
+ trade._sl_order._replace(stop_price=stop_price)
267
+ if order in (trade._sl_order,
268
+ trade._tp_order):
269
+ assert order.size == -trade.size
270
+ assert order not in self.orders # 取引がクローズされたときに削除される
271
+ else:
272
+ # trade.close()注文で、完了
273
+ assert abs(_prev_size) >= abs(size) >= 1
274
+ self.orders.remove(order)
275
+ continue
276
+
277
+ # そうでなければ、これは独立した取引
278
+
279
+ # 手数料(またはビッドアスクスプレッド)を含むように価格を調整
280
+ # ロングポジションでは調整価格が少し高くなり、その逆も同様
281
+ adjusted_price = self._adjusted_price(code=order.code, size=order.size, price=price)
282
+
283
+ # 調整済み価格がゼロまたは負の場合、注文をスキップ
284
+ if adjusted_price <= 0:
285
+ warnings.warn(
286
+ f'{self._current_time}: {order.code} の調整済み価格が無効です({adjusted_price})。注文をキャンセルしました。'
287
+ f'価格データが正常か確認してください。',
288
+ category=UserWarning)
289
+ self.orders.remove(order)
290
+ continue
291
+
292
+ adjusted_price_plus_commission = \
293
+ adjusted_price + self._commission(order.size, price) / abs(order.size)
294
+
295
+
296
+ # 注文サイズが比例的に指定された場合、
297
+ # マージンとスプレッド/手数料を考慮して、単位での真のサイズを事前計算
298
+ size = order.size
299
+ if -1 < size < 1:
300
+ size = copysign(int((self.margin_available * self._leverage * abs(size))
301
+ // adjusted_price_plus_commission), size)
302
+ # 単一ユニットでも十分な現金/マージンがない
303
+ if not size:
304
+ warnings.warn(
305
+ f'{self._current_time}: ブローカーは相対サイズの注文を'
306
+ f'不十分なマージンのためキャンセルしました。', category=UserWarning)
307
+ # XXX: 注文はブローカーによってキャンセルされる?
308
+ self.orders.remove(order)
309
+ continue
310
+ assert size == round(size)
311
+ need_size = int(size)
312
+
313
+ if not self._hedging:
314
+ # 既存の反対方向の取引をFIFOでクローズ/削減してポジションを埋める
315
+ # 既存の取引は調整価格でクローズされる(調整は購入時に既に行われているため)
316
+ for trade in list(self.trades):
317
+ if trade.is_long == order.is_long:
318
+ continue
319
+ assert trade.size * order.size < 0
320
+
321
+ # 注文サイズがこの反対方向の既存取引より大きい場合、
322
+ # 完全にクローズされる
323
+ if abs(need_size) >= abs(trade.size):
324
+ self._close_trade(trade, price, self._current_time)
325
+ need_size += trade.size
326
+ else:
327
+ # 既存の取引が新しい注文より大きい場合、
328
+ # 部分的にのみクローズされる
329
+ self._reduce_trade(trade, price, need_size, self._current_time)
330
+ need_size = 0
331
+
332
+ if not need_size:
333
+ break
334
+
335
+ # 注文をカバーするのに十分な流動性がない場合、ブローカーはそれをキャンセルする
336
+ if abs(need_size) * adjusted_price_plus_commission > \
337
+ self.margin_available * self._leverage:
338
+ self.orders.remove(order)
339
+ continue
340
+
341
+ # 新しい取引を開始
342
+ if need_size:
343
+ self._open_trade(order.code,
344
+ adjusted_price,
345
+ need_size,
346
+ order.sl,
347
+ order.tp,
348
+ self._current_time,
349
+ order.tag)
350
+
351
+ # 新しくキューに追加されたSL/TP注文を再処理する必要がある
352
+ # これにより、注文が開かれた同じバーでSLがヒットすることを可能にする
353
+ # https://github.com/kernc/backtesting.py/issues/119 を参照
354
+ if order.sl or order.tp:
355
+ if is_market_order:
356
+ reprocess_orders = True
357
+ # Order.stopとTPが同じバー内でヒットしたが、SLはヒットしなかった。この場合
358
+ # ストップとTPが同じ価格方向に進むため、曖昧ではない
359
+ elif stop_price and not order.limit and order.tp and (
360
+ (order.is_long and order.tp <= high and (order.sl or -np.inf) < low) or
361
+ (order.is_short and order.tp >= low and (order.sl or np.inf) > high)):
362
+ reprocess_orders = True
363
+ elif (low <= (order.sl or -np.inf) <= high or
364
+ low <= (order.tp or -np.inf) <= high):
365
+ warnings.warn(
366
+ f"({df.index[-1]}) 条件付きSL/TP注文が、その親ストップ/リミット注文が取引に"
367
+ "変換された同じバーで実行されることになります。"
368
+ "正確なローソク足内価格変動を断言できないため、"
369
+ "影響を受けるSL/TP注文は代わりに次の(マッチングする)価格/バーで"
370
+ "実行され、結果(この取引の)が幾分疑わしいものになります。"
371
+ "https://github.com/kernc/backtesting.py/issues/119 を参照",
372
+ UserWarning)
373
+
374
+ # 注文処理完了
375
+ self.orders.remove(order)
376
+
377
+ if reprocess_orders:
378
+ self._process_orders()
379
+
380
+ def _reduce_trade(self, trade: Trade, price: float, size: float, current_time: pd.Timestamp):
381
+ assert trade.size * size < 0
382
+ assert abs(trade.size) >= abs(size)
383
+
384
+ size_left = trade.size + size
385
+ assert size_left * trade.size >= 0
386
+ if not size_left:
387
+ close_trade = trade
388
+ else:
389
+ # 既存の取引を削減...
390
+ trade._replace(size=size_left)
391
+ if trade._sl_order:
392
+ trade._sl_order._replace(size=-trade.size)
393
+ if trade._tp_order:
394
+ trade._tp_order._replace(size=-trade.size)
395
+
396
+ # ... その削減コピーをクローズすることによって
397
+ close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
398
+ self.trades.append(close_trade)
399
+
400
+ self._close_trade(close_trade, price, current_time)
401
+
402
+ def _close_trade(self, trade: Trade, price: float, current_time: pd.Timestamp):
403
+ self.trades.remove(trade)
404
+ if trade._sl_order:
405
+ self.orders.remove(trade._sl_order)
406
+ if trade._tp_order:
407
+ self.orders.remove(trade._tp_order)
408
+
409
+ closed_trade = trade._replace(exit_price=price, exit_time=current_time)
410
+ self.closed_trades.append(closed_trade)
411
+ # 取引終了時に手数料を再度適用
412
+ commission = self._commission(trade.size, price)
413
+ self._cash += trade.pl - commission
414
+ # 統計用にTradeインスタンスに手数料を保存
415
+ trade_open_commission = self._commission(closed_trade.size, closed_trade.entry_price)
416
+ # サイズが_reduce_trade()によって変更された可能性があるため、
417
+ # Trade開始時ではなくここで適用
418
+ closed_trade._commissions = commission + trade_open_commission
419
+
420
+ def _open_trade(self, code: str, price: float, size: int,
421
+ sl: Optional[float], tp: Optional[float], current_time: pd.Timestamp, tag):
422
+ trade = Trade(self, code, size, price, current_time, tag)
423
+ self.trades.append(trade)
424
+ # 取引開始時にブローカー手数料を適用
425
+ self._cash -= self._commission(size, price)
426
+ # SL/TP(ブラケット)注文を作成。
427
+ if tp:
428
+ trade.tp = tp
429
+ if sl:
430
+ trade.sl = sl
BackcastPro/_stats.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from numbers import Number
5
+ from typing import TYPE_CHECKING, List, Union, cast
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ if TYPE_CHECKING:
11
+ from .strategy import Strategy
12
+ from .trade import Trade
13
+
14
+ def compute_drawdown_duration_peaks(dd: pd.Series):
15
+ iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1])
16
+ iloc = pd.Series(iloc, index=dd.index[iloc])
17
+ df = iloc.to_frame('iloc').assign(prev=iloc.shift())
18
+ df = df[df['iloc'] > df['prev'] + 1].astype(np.int64)
19
+
20
+ # 取引がないためドローダウンがない場合、pandasの都合上以下を避けてnanシリーズを返す
21
+ if not len(df):
22
+ return (dd.replace(0, np.nan),) * 2
23
+
24
+ df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__)
25
+ df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1)
26
+ df = df.reindex(dd.index)
27
+ return df['duration'], df['peak_dd']
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
+ """データインデックスの期間を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
+ index: pd.DatetimeIndex,
44
+ strategy_instance: Strategy | None,
45
+ risk_free_rate: float = 0,
46
+ ) -> pd.Series:
47
+ assert -1 < risk_free_rate < 1
48
+
49
+
50
+ # エクイティカーブとインデックスの長さを一致させる
51
+ if len(equity) > len(index):
52
+ equity = equity[:len(index)]
53
+ elif len(equity) < len(index):
54
+ # エクイティカーブが短い場合は、0で埋める
55
+ equity = np.concatenate([equity, np.full(len(index) - len(equity), 0)])
56
+
57
+ dd = 1 - equity / np.maximum.accumulate(equity)
58
+ dd_dur, dd_peaks = compute_drawdown_duration_peaks(pd.Series(dd, index=index))
59
+
60
+ equity_df = pd.DataFrame({
61
+ 'Equity': equity,
62
+ 'DrawdownPct': dd,
63
+ 'DrawdownDuration': dd_dur},
64
+ index=index)
65
+
66
+ if isinstance(trades, pd.DataFrame):
67
+ trades_df: pd.DataFrame = trades
68
+ commissions = None # Not shown
69
+ else:
70
+ # Backtest.run()から直接来たデータ
71
+ trades_df = pd.DataFrame({
72
+ 'Code': [t.code for t in trades],
73
+ 'Size': [t.size for t in trades],
74
+ 'EntryBar': [t.entry_bar for t in trades],
75
+ 'ExitBar': [t.exit_bar for t in trades],
76
+ 'EntryPrice': [t.entry_price for t in trades],
77
+ 'ExitPrice': [t.exit_price for t in trades],
78
+ 'SL': [t.sl for t in trades],
79
+ 'TP': [t.tp for t in trades],
80
+ 'PnL': [t.pl for t in trades],
81
+ 'Commission': [t._commissions for t in trades],
82
+ 'ReturnPct': [t.pl_pct for t in trades],
83
+ 'EntryTime': [t.entry_time for t in trades],
84
+ 'ExitTime': [t.exit_time for t in trades],
85
+ })
86
+ trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
87
+ trades_df['Tag'] = [t.tag for t in trades]
88
+
89
+ commissions = sum(t._commissions for t in trades)
90
+ del trades
91
+
92
+ pl = trades_df['PnL']
93
+ returns = trades_df['ReturnPct']
94
+ durations = trades_df['Duration']
95
+
96
+ def _round_timedelta(value, _period=_data_period(index)):
97
+ if not isinstance(value, pd.Timedelta):
98
+ return value
99
+ resolution = getattr(_period, 'resolution_string', None) or _period.resolution
100
+ return value.ceil(resolution)
101
+
102
+ s = pd.Series(dtype=object)
103
+ s.loc['Start'] = index[0]
104
+ s.loc['End'] = index[-1]
105
+ s.loc['Duration'] = s.End - s.Start
106
+
107
+ have_position = np.repeat(0, len(index))
108
+ for t in trades_df.itertuples(index=False):
109
+ have_position[t.EntryBar:t.ExitBar + 1] = 1
110
+
111
+ s.loc['Exposure Time [%]'] = have_position.mean() * 100 # "n bars"時間単位、インデックス時間ではない
112
+ s.loc['Equity Final [$]'] = equity[-1]
113
+ s.loc['Equity Peak [$]'] = equity.max()
114
+ if commissions:
115
+ s.loc['Commissions [$]'] = commissions
116
+ s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
117
+
118
+ gmean_day_return: float = 0
119
+ day_returns = np.array(np.nan)
120
+ annual_trading_days = np.nan
121
+ is_datetime_index = isinstance(index, pd.DatetimeIndex)
122
+ if is_datetime_index:
123
+ freq_days = cast(pd.Timedelta, _data_period(index)).days
124
+ have_weekends = index.dayofweek.to_series().between(5, 6).mean() > 2 / 7 * .6
125
+ annual_trading_days = (
126
+ 52 if freq_days == 7 else
127
+ 12 if freq_days == 31 else
128
+ 1 if freq_days == 365 else
129
+ (365 if have_weekends else 252))
130
+ freq = {7: 'W', 31: 'ME', 365: 'YE'}.get(freq_days, 'D')
131
+ day_returns = equity_df['Equity'].resample(freq).last().dropna().pct_change()
132
+ gmean_day_return = geometric_mean(day_returns)
133
+
134
+ # 年率化リターンとリスク指標は、リターンが複利計算されるという(ほぼ正確な)
135
+ # 仮定に基づいて計算される。参照: https://dx.doi.org/10.2139/ssrn.3054517
136
+ # 我々の年率化リターンは`empyrical.annual_return(day_returns)`と一致するが、
137
+ # リスクは一致しない。彼らは以下のより単純なアプローチを使用している。
138
+ annualized_return = (1 + gmean_day_return)**annual_trading_days - 1
139
+ s.loc['Return (Ann.) [%]'] = annualized_return * 100
140
+ 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
141
+ # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
142
+ # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
143
+ if is_datetime_index:
144
+ time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days
145
+ s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
146
+
147
+ # 我々のSharpeは`empyrical.sharpe_ratio()`と一致しない。彼らは算術平均リターン
148
+ # と単純な標準偏差を使用するため
149
+ s.loc['Sharpe Ratio'] = (s.loc['Return (Ann.) [%]'] - risk_free_rate * 100) / (s.loc['Volatility (Ann.) [%]'] or np.nan) # noqa: E501
150
+ # 我々のSortinoは`empyrical.sortino_ratio()`と一致しない。彼らは算術平均リターンを使用するため
151
+ with np.errstate(divide='ignore'):
152
+ 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
153
+ max_dd = -np.nan_to_num(dd.max())
154
+ s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
155
+ s.loc['Max. Drawdown [%]'] = max_dd * 100
156
+ s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
157
+ s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
158
+ s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
159
+ s.loc['# Trades'] = n_trades = len(trades_df)
160
+ win_rate = np.nan if not n_trades else (pl > 0).mean()
161
+ s.loc['Win Rate [%]'] = win_rate * 100
162
+ s.loc['Best Trade [%]'] = returns.max() * 100
163
+ s.loc['Worst Trade [%]'] = returns.min() * 100
164
+ mean_return = geometric_mean(returns)
165
+ s.loc['Avg. Trade [%]'] = mean_return * 100
166
+ s.loc['Max. Trade Duration'] = _round_timedelta(durations.max())
167
+ s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean())
168
+ s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan)
169
+ s.loc['Expectancy [%]'] = returns.mean() * 100
170
+ s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
171
+ s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
172
+
173
+ s.loc['_strategy'] = strategy_instance
174
+ s.loc['_equity_curve'] = equity_df
175
+ s.loc['_trades'] = trades_df
176
+
177
+ return s
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ BackcastPro Startup Module
4
+ """