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,594 @@
1
+ """
2
+ バックテスト管理モジュール。
3
+ """
4
+
5
+ import sys
6
+ import warnings
7
+ from functools import partial
8
+ from numbers import Number
9
+ from typing import Callable, List, Optional, Tuple, Union
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ try:
14
+ import plotly.graph_objects as go
15
+ HAS_PLOTLY = True
16
+ except ImportError:
17
+ HAS_PLOTLY = False
18
+
19
+ from ._broker import _Broker
20
+ from ._stats import compute_stats
21
+
22
+
23
+ class Backtest:
24
+ """
25
+ 特定のデータに対してバックテストを実行します。
26
+
27
+ バックテストを初期化します。
28
+ 初期化後、`Backtest.run_with_strategy`メソッドを呼び出して実行します。
29
+
30
+ `data`は以下の列を持つ`pd.DataFrame`です:
31
+ `Open`, `High`, `Low`, `Close`, および(オプションで)`Volume`。
32
+ 列が不足している場合は、利用可能なものに設定してください。
33
+ 例:
34
+
35
+ df['Open'] = df['High'] = df['Low'] = df['Close']
36
+
37
+ 渡されたデータフレームには、戦略で使用できる追加の列
38
+ (例:センチメント情報)を含めることができます。
39
+ DataFrameのインデックスは、datetimeインデックス(タイムスタンプ)または
40
+ 単調増加の範囲インデックス(期間のシーケンス)のいずれかです。
41
+
42
+ `cash`は開始時の初期現金です。
43
+
44
+ `spread`は一定のビッドアスクスプレッド率(価格に対する相対値)です。
45
+ 例:平均スプレッドがアスク価格の約0.2‰である手数料なしの
46
+ 外国為替取引では`0.0002`に設定してください。
47
+
48
+ `commission`は手数料率です。例:ブローカーの手数料が
49
+ 注文価値の1%の場合、commissionを`0.01`に設定してください。
50
+ 手数料は2回適用されます:取引開始時と取引終了時です。
51
+ 単一の浮動小数点値に加えて、`commission`は浮動小数点値の
52
+ タプル`(fixed, relative)`にすることもできます。例:ブローカーが
53
+ 最低$100 + 1%を請求する場合は`(100, .01)`に設定してください。
54
+ さらに、`commission`は呼び出し可能な
55
+ `func(order_size: int, price: float) -> float`
56
+ (注:ショート注文では注文サイズは負の値)にすることもでき、
57
+ より複雑な手数料構造をモデル化するために使用できます。
58
+ 負の手数料値はマーケットメーカーのリベートとして解釈されます。
59
+
60
+ `margin`はレバレッジアカウントの必要証拠金(比率)です。
61
+ 初期証拠金と維持証拠金の区別はありません。
62
+ ブローカーが許可する50:1レバレッジなどでバックテストを実行するには、
63
+ marginを`0.02`(1 / レバレッジ)に設定してください。
64
+
65
+ `trade_on_close`が`True`の場合、成行注文は
66
+ 次のバーの始値ではなく、現在のバーの終値で約定されます。
67
+
68
+ `hedging`が`True`の場合、両方向の取引を同時に許可します。
69
+ `False`の場合、反対方向の注文は既存の取引を
70
+ [FIFO]方式で最初にクローズします。
71
+
72
+ `exclusive_orders`が`True`の場合、各新しい注文は前の
73
+ 取引/ポジションを自動クローズし、各時点で最大1つの取引
74
+ (ロングまたはショート)のみが有効になります。
75
+
76
+ `finalize_trades`が`True`の場合、バックテスト終了時に
77
+ まだ[アクティブで継続中]の取引は最後のバーでクローズされ、
78
+ 計算されたバックテスト統計に貢献します。
79
+ """
80
+
81
+ def __init__(self,
82
+ data: dict[str, pd.DataFrame] = None,
83
+ *,
84
+ cash: float = 10_000,
85
+ spread: float = .0,
86
+ commission: Union[float, Tuple[float, float]] = .0,
87
+ margin: float = 1.,
88
+ trade_on_close=False,
89
+ hedging=False,
90
+ exclusive_orders=False,
91
+ finalize_trades=False,
92
+ ):
93
+
94
+ if not isinstance(spread, Number):
95
+ raise TypeError('`spread` must be a float value, percent of '
96
+ 'entry order price')
97
+ if not isinstance(commission, (Number, tuple)) and not callable(commission):
98
+ raise TypeError('`commission` must be a float percent of order value, '
99
+ 'a tuple of `(fixed, relative)` commission, '
100
+ 'or a function that takes `(order_size, price)`'
101
+ 'and returns commission dollar value')
102
+
103
+ self.set_data(data)
104
+
105
+ # partialとは、関数の一部の引数を事前に固定して、新しい関数を作成します。
106
+ # これにより、後で残りの引数だけを渡せば関数を実行できるようになります。
107
+ # 1. _Brokerクラスのコンストラクタの引数の一部(cash, spread, commissionなど)を事前に固定
108
+ # 2. 新しい関数(実際には呼び出し可能オブジェクト)を作成
109
+ # 3. 後で残りの引数(おそらくdataなど)を渡すだけで_Brokerのインスタンスを作成できるようにする
110
+ self._broker_factory = partial[_Broker](
111
+ _Broker, cash=cash, spread=spread, commission=commission, margin=margin,
112
+ trade_on_close=trade_on_close, hedging=hedging,
113
+ exclusive_orders=exclusive_orders
114
+ )
115
+
116
+ self._results: Optional[pd.Series] = None
117
+ self._finalize_trades = bool(finalize_trades)
118
+
119
+ # ステップ実行用の状態管理
120
+ self._broker_instance: Optional[_Broker] = None
121
+ self._step_index = 0
122
+ self._is_started = False
123
+ self._is_finished = False
124
+ self._current_data: dict[str, pd.DataFrame] = {}
125
+
126
+ # パフォーマンス最適化: 各銘柄の index position マッピング
127
+ self._index_positions: dict[str, dict] = {}
128
+
129
+ # 自動的にstart()を呼び出す
130
+ if data is not None:
131
+ self.start()
132
+
133
+ def _validate_and_prepare_df(self, df: pd.DataFrame, code: str) -> pd.DataFrame:
134
+ """
135
+ 単一のDataFrameをバリデーションし、準備します。
136
+
137
+ Args:
138
+ df: バリデーションするDataFrame
139
+ code: データの識別子(エラーメッセージ用)
140
+
141
+ Returns:
142
+ バリデーション済みのDataFrame(コピー)
143
+
144
+ Raises:
145
+ TypeError: DataFrameでない場合
146
+ ValueError: 必要な列がない場合、またはNaN値が含まれる場合
147
+ """
148
+ if not isinstance(df, pd.DataFrame):
149
+ raise TypeError(f"`data[{code}]` must be a pandas.DataFrame with columns")
150
+
151
+ # データフレームのコピーを作成
152
+ df = df.copy()
153
+
154
+ # インデックスをdatetimeインデックスに変換
155
+ if (not isinstance(df.index, pd.DatetimeIndex) and
156
+ not isinstance(df.index, pd.RangeIndex) and
157
+ # 大部分が大きな数値の数値インデックス
158
+ (df.index.is_numeric() and
159
+ (df.index > pd.Timestamp('1975').timestamp()).mean() > .8)):
160
+ try:
161
+ df.index = pd.to_datetime(df.index, infer_datetime_format=True)
162
+ except ValueError:
163
+ pass
164
+
165
+ # Volume列がない場合は追加
166
+ if 'Volume' not in df:
167
+ df['Volume'] = np.nan
168
+
169
+ # 空のDataFrameチェック
170
+ if len(df) == 0:
171
+ raise ValueError(f'OHLC `data[{code}]` is empty')
172
+
173
+ # 必要な列の確認
174
+ if len(df.columns.intersection({'Open', 'High', 'Low', 'Close', 'Volume'})) != 5:
175
+ raise ValueError(f"`data[{code}]` must be a pandas.DataFrame with columns "
176
+ "'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'")
177
+
178
+ # NaN値の確認
179
+ if df[['Open', 'High', 'Low', 'Close']].isnull().values.any():
180
+ raise ValueError('Some OHLC values are missing (NaN). '
181
+ 'Please strip those lines with `df.dropna()` or '
182
+ 'fill them in with `df.interpolate()` or whatever.')
183
+
184
+ # インデックスのソート確認
185
+ if not df.index.is_monotonic_increasing:
186
+ warnings.warn(f'data[{code}] index is not sorted in ascending order. Sorting.',
187
+ stacklevel=3)
188
+ df = df.sort_index()
189
+
190
+ # インデックスの型警告
191
+ if not isinstance(df.index, pd.DatetimeIndex):
192
+ warnings.warn(f'data[{code}] index is not datetime. Assuming simple periods, '
193
+ 'but `pd.DateTimeIndex` is advised.',
194
+ stacklevel=3)
195
+
196
+ return df
197
+
198
+
199
+ def set_data(self, data):
200
+ self._data = None
201
+ if data is None:
202
+ return
203
+
204
+ data = data.copy()
205
+
206
+ # 各DataFrameをバリデーションして準備
207
+ for code, df in data.items():
208
+ data[code] = self._validate_and_prepare_df(df, code)
209
+
210
+ # 辞書dataに含まれる全てのdf.index一覧を作成
211
+ # df.indexが不一致の場合のために、どれかに固有値があれば抽出しておくため
212
+ self.index: pd.DatetimeIndex = pd.DatetimeIndex(sorted({idx for df in data.values() for idx in df.index}))
213
+
214
+ self._data: dict[str, pd.DataFrame] = data
215
+
216
+ def set_cash(self, cash):
217
+ self._broker_factory.keywords['cash'] = cash
218
+
219
+ # =========================================================================
220
+ # ステップ実行 API
221
+ # =========================================================================
222
+
223
+ def start(self) -> 'Backtest':
224
+ """バックテストを開始準備する"""
225
+ if self._data is None:
226
+ raise ValueError("data が設定されていません")
227
+
228
+ self._broker_instance = self._broker_factory(data=self._data)
229
+ self._step_index = 0
230
+ self._is_started = True
231
+ self._is_finished = False
232
+ self._current_data = {}
233
+ self._results = None
234
+
235
+ # パフォーマンス最適化: 各銘柄の index → position マッピングを事前計算
236
+ self._index_positions = {}
237
+ for code, df in self._data.items():
238
+ self._index_positions[code] = {
239
+ ts: i for i, ts in enumerate(df.index)
240
+ }
241
+
242
+ return self
243
+
244
+ def step(self) -> bool:
245
+ """
246
+ 1ステップ(1バー)進める。
247
+
248
+ 【タイミング】
249
+ - step(t) 実行時、data[:t] が見える状態になる
250
+ - 注文は broker.next(t) 内で処理される
251
+
252
+ Returns:
253
+ bool: まだ続行可能なら True、終了なら False
254
+ """
255
+ if not self._is_started:
256
+ raise RuntimeError("start() を呼び出してください")
257
+
258
+ if self._is_finished:
259
+ return False
260
+
261
+ if self._step_index >= len(self.index):
262
+ self._is_finished = True
263
+ return False
264
+
265
+ current_time = self.index[self._step_index]
266
+
267
+ with np.errstate(invalid='ignore'):
268
+ # パフォーマンス最適化: iloc ベースで slicing
269
+ for code, df in self._data.items():
270
+ if current_time in self._index_positions[code]:
271
+ pos = self._index_positions[code][current_time]
272
+ self._current_data[code] = df.iloc[:pos + 1]
273
+ # current_time がこの銘柄に存在しない場合は前の状態を維持
274
+
275
+ # ブローカー処理(注文の約定)
276
+ try:
277
+ self._broker_instance._data = self._current_data
278
+ self._broker_instance.next(current_time)
279
+ except Exception:
280
+ self._is_finished = True
281
+ return False
282
+
283
+ self._step_index += 1
284
+
285
+ if self._step_index >= len(self.index):
286
+ self._is_finished = True
287
+
288
+ return not self._is_finished
289
+
290
+ def reset(self) -> 'Backtest':
291
+ """バックテストをリセットして最初から"""
292
+ self._broker_instance = self._broker_factory(data=self._data)
293
+ self._step_index = 0
294
+ self._is_finished = False
295
+ self._current_data = {}
296
+ self._results = None
297
+ return self
298
+
299
+ def goto(self, step: int, strategy: Callable[['Backtest'], None] = None) -> 'Backtest':
300
+ """
301
+ 指定ステップまで進める(スライダー連携用)
302
+
303
+ Args:
304
+ step: 目標のステップ番号(1-indexed、0以下は1に丸められる)
305
+ strategy: 各ステップで呼び出す戦略関数(省略可)
306
+ ※ strategy は step() の **前** に呼ばれます
307
+
308
+ Note:
309
+ step < 現在位置 の場合、reset() してから再実行します。
310
+ """
311
+ step = max(1, min(step, len(self.index)))
312
+
313
+ # 現在より前に戻る場合はリセット
314
+ if step < self._step_index:
315
+ self.reset()
316
+
317
+ # 目標まで進める(戦略を適用しながら)
318
+ while self._step_index < step and not self._is_finished:
319
+ if strategy:
320
+ strategy(self)
321
+ self.step()
322
+
323
+ return self
324
+
325
+ # =========================================================================
326
+ # 売買 API
327
+ # =========================================================================
328
+
329
+ def buy(self, *,
330
+ code: str = None,
331
+ size: float = None,
332
+ limit: Optional[float] = None,
333
+ stop: Optional[float] = None,
334
+ sl: Optional[float] = None,
335
+ tp: Optional[float] = None,
336
+ tag: object = None):
337
+ """
338
+ 買い注文を発注する。
339
+
340
+ Args:
341
+ code: 銘柄コード(1銘柄のみの場合は省略可)
342
+ size: 注文数量(省略時は利用可能資金の99.99%)
343
+ limit: 指値価格
344
+ stop: 逆指値価格
345
+ sl: ストップロス価格
346
+ tp: テイクプロフィット価格
347
+ tag: 注文理由(例: "dip_buy", "breakout")→ チャートに表示可能
348
+ """
349
+ if not self._is_started:
350
+ raise RuntimeError("start() を呼び出してください")
351
+
352
+ if code is None:
353
+ if len(self._data) == 1:
354
+ code = list(self._data.keys())[0]
355
+ else:
356
+ raise ValueError("複数銘柄がある場合はcodeを指定してください")
357
+
358
+ if size is None:
359
+ size = 1 - sys.float_info.epsilon
360
+
361
+ return self._broker_instance.new_order(code, size, limit, stop, sl, tp, tag)
362
+
363
+ def sell(self, *,
364
+ code: str = None,
365
+ size: float = None,
366
+ limit: Optional[float] = None,
367
+ stop: Optional[float] = None,
368
+ sl: Optional[float] = None,
369
+ tp: Optional[float] = None,
370
+ tag: object = None):
371
+ """
372
+ 売り注文を発注する。
373
+
374
+ Args:
375
+ code: 銘柄コード(1銘柄のみの場合は省略可)
376
+ size: 注文数量(省略時は利用可能資金の99.99%)
377
+ limit: 指値価格
378
+ stop: 逆指値価格
379
+ sl: ストップロス価格
380
+ tp: テイクプロフィット価格
381
+ tag: 注文理由(例: "profit_take", "stop_loss")→ チャートに表示可能
382
+ """
383
+ if not self._is_started:
384
+ raise RuntimeError("start() を呼び出してください")
385
+
386
+ if code is None:
387
+ if len(self._data) == 1:
388
+ code = list(self._data.keys())[0]
389
+ else:
390
+ raise ValueError("複数銘柄がある場合はcodeを指定してください")
391
+
392
+ if size is None:
393
+ size = 1 - sys.float_info.epsilon
394
+
395
+ return self._broker_instance.new_order(code, -size, limit, stop, sl, tp, tag)
396
+
397
+ # =========================================================================
398
+ # 可視化
399
+ # =========================================================================
400
+
401
+ def chart(self, code: str = None, height: int = 500, show_tags: bool = True):
402
+ """
403
+ 現在時点までのローソク足チャートを生成(売買マーカー付き)
404
+
405
+ Args:
406
+ code: 銘柄コード
407
+ height: チャートの高さ
408
+ show_tags: 売買理由(tag)をチャートに表示するか
409
+
410
+ Returns:
411
+ plotly.graph_objects.Figure
412
+ """
413
+ if not HAS_PLOTLY:
414
+ raise ImportError("plotly がインストールされていません。pip install plotly を実行してください。")
415
+
416
+ if code is None:
417
+ if len(self._data) == 1:
418
+ code = list(self._data.keys())[0]
419
+ else:
420
+ raise ValueError("複数銘柄がある場合はcodeを指定してください")
421
+
422
+ if not self._is_started or self._broker_instance is None:
423
+ return go.Figure()
424
+
425
+ if code not in self._current_data or len(self._current_data[code]) == 0:
426
+ return go.Figure()
427
+
428
+ df = self._current_data[code]
429
+
430
+ # 全取引(アクティブ + 決済済み)を取得
431
+ all_trades = list(self._broker_instance.closed_trades) + list(self._broker_instance.trades)
432
+
433
+ # chart_by_df を呼び出してチャートを生成
434
+ from .api.chart import chart_by_df
435
+ return chart_by_df(
436
+ df,
437
+ trades=all_trades,
438
+ height=height,
439
+ show_tags=show_tags,
440
+ show_volume=False,
441
+ title=f"{code} - {self.current_time}",
442
+ code=code,
443
+ )
444
+
445
+ # =========================================================================
446
+ # ステップ実行用プロパティ
447
+ # =========================================================================
448
+
449
+ @property
450
+ def data(self) -> dict[str, pd.DataFrame]:
451
+ """現在時点までのデータ"""
452
+ return self._current_data
453
+
454
+ @property
455
+ def position(self) -> int:
456
+ """
457
+ 現在のポジションサイズ(全銘柄合計)
458
+
459
+ ⚠️ 注意: 複数銘柄を扱う場合は position_of(code) を使用してください。
460
+ このプロパティは後方互換性のために残されています。
461
+ """
462
+ if not self._is_started or self._broker_instance is None:
463
+ return 0
464
+ return self._broker_instance.position.size
465
+
466
+ def position_of(self, code: str) -> int:
467
+ """
468
+ 指定銘柄のポジションサイズ(推奨)
469
+
470
+ Args:
471
+ code: 銘柄コード
472
+
473
+ Returns:
474
+ int: ポジションサイズ(正: ロング、負: ショート、0: ノーポジ)
475
+ """
476
+ if not self._is_started or self._broker_instance is None:
477
+ return 0
478
+ return sum(t.size for t in self._broker_instance.trades if t.code == code)
479
+
480
+ @property
481
+ def equity(self) -> float:
482
+ """現在の資産"""
483
+ if not self._is_started or self._broker_instance is None:
484
+ return self._broker_factory.keywords.get('cash', 0)
485
+ return self._broker_instance.equity
486
+
487
+ @property
488
+ def is_finished(self) -> bool:
489
+ """完了したかどうか"""
490
+ return self._is_finished
491
+
492
+ @property
493
+ def current_time(self) -> Optional[pd.Timestamp]:
494
+ """現在の日時"""
495
+ if self._step_index == 0:
496
+ return None
497
+ return self.index[self._step_index - 1]
498
+
499
+ @property
500
+ def progress(self) -> float:
501
+ """進捗率(0.0〜1.0)"""
502
+ if len(self.index) == 0:
503
+ return 0.0
504
+ return self._step_index / len(self.index)
505
+
506
+ @property
507
+ def trades(self) -> List:
508
+ """アクティブな取引リスト"""
509
+ if not self._is_started or self._broker_instance is None:
510
+ return []
511
+ return list(self._broker_instance.trades)
512
+
513
+ @property
514
+ def closed_trades(self) -> List:
515
+ """決済済み取引リスト"""
516
+ if not self._is_started or self._broker_instance is None:
517
+ return []
518
+ return list(self._broker_instance.closed_trades)
519
+
520
+ @property
521
+ def orders(self) -> List:
522
+ """未約定の注文リスト"""
523
+ if not self._is_started or self._broker_instance is None:
524
+ return []
525
+ return list(self._broker_instance.orders)
526
+
527
+ # =========================================================================
528
+ # finalize / run
529
+ # =========================================================================
530
+
531
+ def finalize(self) -> pd.Series:
532
+ """統計を計算して結果を返す"""
533
+ if self._results is not None:
534
+ return self._results
535
+
536
+ if not self._is_started:
537
+ raise RuntimeError("バックテストが開始されていません")
538
+
539
+ broker = self._broker_instance
540
+
541
+ if self._finalize_trades:
542
+ for trade in reversed(broker.trades):
543
+ trade.close()
544
+ if self._step_index > 0:
545
+ broker.next(self.index[self._step_index - 1])
546
+ elif len(broker.trades):
547
+ warnings.warn(
548
+ 'バックテスト終了時に一部の取引がオープンのままです。'
549
+ '`Backtest(..., finalize_trades=True)`を使用してクローズし、'
550
+ '統計に含めてください。', stacklevel=2)
551
+
552
+ # インデックスが空の場合のガード
553
+ result_index = self.index[:self._step_index] if self._step_index > 0 else self.index[:1]
554
+
555
+ equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
556
+ self._results = compute_stats(
557
+ trades=broker.closed_trades,
558
+ equity=np.array(equity),
559
+ index=result_index,
560
+ strategy_instance=None,
561
+ risk_free_rate=0.0,
562
+ )
563
+
564
+ return self._results
565
+
566
+ def run_with_strategy(self, strategy_func: Callable[['Backtest'], None] = None) -> pd.Series:
567
+ """
568
+ バックテストを最後まで実行(ステップ実行API版)
569
+
570
+ Args:
571
+ strategy_func: 各ステップで呼び出す関数 (bt) -> None
572
+ """
573
+ if not self._is_started:
574
+ self.start()
575
+
576
+ while not self._is_finished:
577
+ if strategy_func:
578
+ strategy_func(self)
579
+ self.step()
580
+
581
+ return self.finalize()
582
+
583
+ @property
584
+ def cash(self):
585
+ """現在の現金残高"""
586
+ if self._is_started and self._broker_instance is not None:
587
+ return self._broker_instance.cash
588
+ # partialで初期化されている場合、初期化時のcash値を返す
589
+ return self._broker_factory.keywords.get('cash', 0)
590
+
591
+ @property
592
+ def commission(self):
593
+ # partialで初期化されている場合、初期化時のcommission値を返す
594
+ return self._broker_factory.keywords.get('commission', 0)