kissbt 0.1.5__tar.gz → 0.1.6__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.
- {kissbt-0.1.5 → kissbt-0.1.6}/PKG-INFO +7 -6
- {kissbt-0.1.5 → kissbt-0.1.6}/README.md +1 -1
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt/analyzer.py +144 -20
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt/broker.py +44 -41
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt/engine.py +4 -4
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt/entities.py +6 -6
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt/strategy.py +3 -3
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt.egg-info/PKG-INFO +7 -6
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt.egg-info/SOURCES.txt +1 -0
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt.egg-info/requires.txt +3 -3
- {kissbt-0.1.5 → kissbt-0.1.6}/pyproject.toml +37 -19
- kissbt-0.1.6/tests/test_analyzer.py +61 -0
- {kissbt-0.1.5 → kissbt-0.1.6}/tests/test_broker.py +49 -42
- {kissbt-0.1.5 → kissbt-0.1.6}/tests/test_entities.py +3 -6
- {kissbt-0.1.5 → kissbt-0.1.6}/tests/test_integration.py +7 -7
- {kissbt-0.1.5 → kissbt-0.1.6}/tests/test_strategy.py +1 -2
- {kissbt-0.1.5 → kissbt-0.1.6}/LICENSE +0 -0
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt/__init__.py +0 -0
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt.egg-info/dependency_links.txt +0 -0
- {kissbt-0.1.5 → kissbt-0.1.6}/kissbt.egg-info/top_level.txt +0 -0
- {kissbt-0.1.5 → kissbt-0.1.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: kissbt
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.6
|
4
4
|
Summary: The keep it simple backtesting framework for Python.
|
5
5
|
Author-email: Adrian Hasse <adrian.hasse@finblobs.com>
|
6
6
|
Maintainer-email: Adrian Hasse <adrian.hasse@finblobs.com>
|
@@ -216,15 +216,16 @@ Description-Content-Type: text/markdown
|
|
216
216
|
License-File: LICENSE
|
217
217
|
Requires-Dist: numpy
|
218
218
|
Requires-Dist: pandas
|
219
|
+
Requires-Dist: scipy
|
219
220
|
Requires-Dist: matplotlib
|
220
221
|
Provides-Extra: dev
|
221
222
|
Requires-Dist: pytest; extra == "dev"
|
222
223
|
Requires-Dist: pytest-mock; extra == "dev"
|
223
|
-
Requires-Dist:
|
224
|
-
Requires-Dist:
|
225
|
-
Requires-Dist: isort; extra == "dev"
|
224
|
+
Requires-Dist: ruff; extra == "dev"
|
225
|
+
Requires-Dist: mypy; extra == "dev"
|
226
226
|
Requires-Dist: yfinance; extra == "dev"
|
227
227
|
Requires-Dist: pyarrow; extra == "dev"
|
228
|
+
Dynamic: license-file
|
228
229
|
|
229
230
|
# kissbt
|
230
231
|
|
@@ -264,7 +265,7 @@ pip install kissbt
|
|
264
265
|
To install `kissbt` via `conda`, run the following command:
|
265
266
|
|
266
267
|
```sh
|
267
|
-
conda install kissbt
|
268
|
+
conda install -c conda-forge kissbt
|
268
269
|
```
|
269
270
|
|
270
271
|
## Usage
|
@@ -2,6 +2,7 @@ from typing import Any, Dict
|
|
2
2
|
|
3
3
|
import numpy as np
|
4
4
|
import pandas as pd
|
5
|
+
from scipy.stats import linregress
|
5
6
|
|
6
7
|
from kissbt.broker import Broker
|
7
8
|
|
@@ -15,7 +16,13 @@ class Analyzer:
|
|
15
16
|
key metrics used in financial analysis and portfolio management.
|
16
17
|
"""
|
17
18
|
|
18
|
-
def __init__(
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
broker: Broker,
|
22
|
+
bar_size: str = "1D",
|
23
|
+
trading_hours_per_day: float = 6.5,
|
24
|
+
trading_days_per_year: int = 252,
|
25
|
+
) -> None:
|
19
26
|
"""
|
20
27
|
Initialize the Analyzer with a Broker instance and the bar size, which is the
|
21
28
|
time interval of each bar in the data.
|
@@ -23,17 +30,29 @@ class Analyzer:
|
|
23
30
|
Parameters:
|
24
31
|
broker (Broker): The broker instance containing the trading history.
|
25
32
|
bar_size (str): The time interval of each bar in the data, supported units
|
26
|
-
are 'S' for seconds, '
|
33
|
+
are 'S' for seconds, 'T' for minutes, 'H' for hours and 'D' for days
|
27
34
|
(default is "1D").
|
35
|
+
trading_hours_per_day (float): Number of trading hours per day (default is
|
36
|
+
6.5, which assumes US equities market hours; adjust as needed for other
|
37
|
+
markets).
|
38
|
+
trading_days_per_year (int): Number of trading days per year (default is
|
39
|
+
252, which assumes US equities; adjust as needed for other markets).
|
28
40
|
"""
|
29
41
|
|
30
42
|
value = int(bar_size[:-1])
|
31
|
-
|
32
|
-
seconds_multiplier = {
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
43
|
+
bar_unit = bar_size[-1]
|
44
|
+
seconds_multiplier = {
|
45
|
+
"S": 1,
|
46
|
+
"T": 60,
|
47
|
+
"H": 3600,
|
48
|
+
"D": 3600 * trading_hours_per_day,
|
49
|
+
}
|
50
|
+
if bar_unit not in seconds_multiplier:
|
51
|
+
raise ValueError(f"Unsupported bar size unit: {bar_unit}")
|
52
|
+
self.seconds_per_bar = value * seconds_multiplier[bar_unit]
|
53
|
+
self.trading_seconds_per_year = (
|
54
|
+
trading_days_per_year * trading_hours_per_day * 3600
|
55
|
+
)
|
37
56
|
|
38
57
|
self.broker = broker
|
39
58
|
self.analysis_df = pd.DataFrame(self.broker.history)
|
@@ -50,6 +69,52 @@ class Analyzer:
|
|
50
69
|
self.analysis_df["benchmark"].cummax() - self.analysis_df["benchmark"]
|
51
70
|
) / self.analysis_df["benchmark"].cummax()
|
52
71
|
|
72
|
+
def _equity_curve_stats(
|
73
|
+
self,
|
74
|
+
value_series: pd.Series,
|
75
|
+
*,
|
76
|
+
prefix: str = "",
|
77
|
+
) -> Dict[str, float]:
|
78
|
+
"""
|
79
|
+
Calculate statistics of the equity curve based on the log-equity curve.
|
80
|
+
This method performs a linear regression on the log-equity curve to estimate
|
81
|
+
the slope, standard error, t-statistic, and R² value.
|
82
|
+
|
83
|
+
- slope: The slope of the log-equity curve, indicating the average return per
|
84
|
+
bar.
|
85
|
+
- slope_se: The standard error of the slope, indicating the variability of the
|
86
|
+
average return.
|
87
|
+
- slope_tstat: The t-statistic of the slope, indicating how strongly the data
|
88
|
+
supports the presence of a non-zero trend in the log-equity curve.
|
89
|
+
- r_squared: The R² value of the regression, indicating the proportion of
|
90
|
+
variance explained.
|
91
|
+
|
92
|
+
Parameters:
|
93
|
+
value_series (pd.Series): The series of values to analyze, typically the
|
94
|
+
total value of the portfolio or benchmark.
|
95
|
+
prefix (str): A prefix to add to the keys in the returned dictionary, useful
|
96
|
+
for distinguishing between portfolio and benchmark statistics.
|
97
|
+
"""
|
98
|
+
|
99
|
+
if (value_series <= 0).any():
|
100
|
+
raise ValueError(
|
101
|
+
"Value series contains non-positive values, cannot compute log-based statistics" # noqa: E501
|
102
|
+
)
|
103
|
+
y = np.log(value_series.to_numpy())
|
104
|
+
x = np.arange(y.size, dtype=float)
|
105
|
+
|
106
|
+
res = linregress(x, y)
|
107
|
+
slope, slope_se, r_squared = res.slope, res.stderr, res.rvalue**2
|
108
|
+
|
109
|
+
slope_tstat = slope / slope_se
|
110
|
+
|
111
|
+
return {
|
112
|
+
f"{prefix}slope": slope,
|
113
|
+
f"{prefix}slope_se": slope_se,
|
114
|
+
f"{prefix}slope_tstat": slope_tstat,
|
115
|
+
f"{prefix}r_squared": r_squared,
|
116
|
+
}
|
117
|
+
|
53
118
|
def get_performance_metrics(self) -> Dict[str, float]:
|
54
119
|
"""
|
55
120
|
Calculate and return key performance metrics of the trading strategy.
|
@@ -67,9 +132,26 @@ class Analyzer:
|
|
67
132
|
- profit_factor: The profit factor of the trading strategy, a ratio of gross
|
68
133
|
profits to gross losses.
|
69
134
|
|
70
|
-
|
71
|
-
|
72
|
-
-
|
135
|
+
Additionally we compute the equity curve statistics for the portfolio's
|
136
|
+
total value, including:
|
137
|
+
- slope: The slope of the log-equity curve, indicating the average return per
|
138
|
+
bar.
|
139
|
+
- slope_se: The standard error of the slope, indicating the variability of the
|
140
|
+
average return.
|
141
|
+
- slope_tstat: The t-statistic of the slope (slope / slope_se), indicating how
|
142
|
+
strongly the data supports the presence of a non-zero trend in the
|
143
|
+
log-equity curve. A larger absolute value (positive or negative) provides
|
144
|
+
stronger evidence against H_0 (β = 0), suggesting that the observed trend is
|
145
|
+
unlikely to be due to random fluctuations. For typical backtests the
|
146
|
+
t-statistic approximately follows a standard normal distribution. Values
|
147
|
+
above +1.96 or below -1.96 are considered statistically significant at the
|
148
|
+
95% confidence level.
|
149
|
+
- r_squared: The R² value of the regression, indicating the proportion of
|
150
|
+
variance explained by the model.
|
151
|
+
|
152
|
+
If a benchmark is available in the data, the dictionary also includes the
|
153
|
+
total_return, annual_return, slope, slope_se, slope_tstat and r_squared for the
|
154
|
+
benchmark, prefixed with "benchmark_".
|
73
155
|
|
74
156
|
Returns:
|
75
157
|
Dict[str, float]: A dictionary containing the calculated performance
|
@@ -85,14 +167,21 @@ class Analyzer:
|
|
85
167
|
"win_rate": self._calculate_win_rate(),
|
86
168
|
"profit_factor": self._calculate_profit_factor(),
|
87
169
|
}
|
170
|
+
metrics.update(self._equity_curve_stats(self.analysis_df["total_value"]))
|
88
171
|
|
89
172
|
if "benchmark" in self.analysis_df.columns:
|
90
|
-
metrics["
|
173
|
+
metrics["benchmark_total_return"] = self._calculate_total_return(
|
91
174
|
"benchmark"
|
92
175
|
)
|
93
|
-
metrics["
|
176
|
+
metrics["benchmark_annual_return"] = self._calculate_annual_return(
|
94
177
|
"benchmark"
|
95
178
|
)
|
179
|
+
metrics.update(
|
180
|
+
self._equity_curve_stats(
|
181
|
+
self.analysis_df["benchmark"],
|
182
|
+
prefix="benchmark_",
|
183
|
+
)
|
184
|
+
)
|
96
185
|
|
97
186
|
return metrics
|
98
187
|
|
@@ -280,14 +369,14 @@ class Analyzer:
|
|
280
369
|
**kwargs(Dict[str, Any]): Additional keyword arguments to pass to the plot
|
281
370
|
function of pandas.
|
282
371
|
"""
|
283
|
-
columns_to_plot = ["
|
372
|
+
columns_to_plot = ["timestamp", "drawdown"]
|
284
373
|
if "benchmark_drawdown" in self.analysis_df.columns:
|
285
374
|
columns_to_plot.append("benchmark_drawdown")
|
286
375
|
|
287
376
|
self.analysis_df.loc[:, columns_to_plot].plot(
|
288
|
-
x="
|
377
|
+
x="timestamp",
|
289
378
|
title="Portfolio Drawdown Over Time",
|
290
|
-
xlabel="
|
379
|
+
xlabel="Timestamp",
|
291
380
|
ylabel="Drawdown %",
|
292
381
|
**kwargs,
|
293
382
|
)
|
@@ -299,7 +388,7 @@ class Analyzer:
|
|
299
388
|
This method creates a line plot showing the portfolio's cash, total value, and
|
300
389
|
benchmark over time for comparison. If the benchmark is not available, it will
|
301
390
|
plot only the available columns. If logy is True, the cash column will be
|
302
|
-
excluded, to focus on the total value and benchmark on
|
391
|
+
excluded, to focus on the total value and benchmark on logarithmic scale.
|
303
392
|
|
304
393
|
Parameters:
|
305
394
|
logy (bool): If True, use a logarithmic scale for the y-axis and exclude the
|
@@ -307,17 +396,52 @@ class Analyzer:
|
|
307
396
|
**kwargs(Dict[str, Any]): Additional keyword arguments to pass to the plot
|
308
397
|
function of pandas.
|
309
398
|
"""
|
310
|
-
columns_to_plot = ["
|
399
|
+
columns_to_plot = ["timestamp", "total_value"]
|
311
400
|
if "benchmark" in self.analysis_df.columns:
|
312
401
|
columns_to_plot.append("benchmark")
|
313
402
|
if not logy:
|
314
403
|
columns_to_plot.append("cash")
|
315
404
|
|
316
405
|
self.analysis_df.loc[:, columns_to_plot].plot(
|
317
|
-
x="
|
406
|
+
x="timestamp",
|
318
407
|
title="Portfolio Equity Curve Over Time",
|
319
|
-
xlabel="
|
408
|
+
xlabel="Timestamp",
|
320
409
|
ylabel="Value",
|
321
410
|
logy=logy,
|
322
411
|
**kwargs,
|
323
412
|
)
|
413
|
+
|
414
|
+
def plot_rolling_returns_distribution(
|
415
|
+
self, window_bars: int, include_benchmark: bool = True, **kwargs: Dict[str, Any]
|
416
|
+
) -> None:
|
417
|
+
"""
|
418
|
+
Plot box plots of rolling returns for the portfolio and optionally benchmark.
|
419
|
+
|
420
|
+
Parameters:
|
421
|
+
window_bars (int): Window size as number of bars.
|
422
|
+
include_benchmark (bool): Whether to include benchmark if available
|
423
|
+
(default True).
|
424
|
+
**kwargs (dict): Additional keyword arguments to pass to the pandas
|
425
|
+
DataFrame.boxplot function for customizing the appearance and behavior
|
426
|
+
of the box plot.
|
427
|
+
"""
|
428
|
+
if window_bars >= len(self.analysis_df):
|
429
|
+
raise ValueError(
|
430
|
+
f"Window size {window_bars} is too large for the available data {len(self.analysis_df)}." # noqa: E501
|
431
|
+
)
|
432
|
+
|
433
|
+
result = {
|
434
|
+
"Portfolio": self.analysis_df["total_value"]
|
435
|
+
.pct_change(periods=window_bars)
|
436
|
+
.dropna()
|
437
|
+
.reset_index(drop=True)
|
438
|
+
}
|
439
|
+
if include_benchmark and "benchmark" in self.analysis_df.columns:
|
440
|
+
result["Benchmark"] = (
|
441
|
+
self.analysis_df["benchmark"]
|
442
|
+
.pct_change(periods=window_bars)
|
443
|
+
.dropna()
|
444
|
+
.reset_index(drop=True)
|
445
|
+
)
|
446
|
+
|
447
|
+
pd.DataFrame(result).boxplot(**kwargs)
|
@@ -1,4 +1,3 @@
|
|
1
|
-
from datetime import datetime
|
2
1
|
from typing import Dict, List, Optional
|
3
2
|
|
4
3
|
import pandas as pd
|
@@ -24,7 +23,7 @@ class Broker:
|
|
24
23
|
Typical usage:
|
25
24
|
broker = Broker(start_capital=100000, fees=0.001)
|
26
25
|
broker.place_order(Order("AAPL", 100, OrderType.OPEN))
|
27
|
-
broker.update(next_bar,
|
26
|
+
broker.update(next_bar, next_timestamp)
|
28
27
|
"""
|
29
28
|
|
30
29
|
def __init__(
|
@@ -64,9 +63,9 @@ class Broker:
|
|
64
63
|
self._open_orders: List[Order] = []
|
65
64
|
|
66
65
|
self._current_bar: pd.DataFrame = pd.DataFrame()
|
67
|
-
self.
|
66
|
+
self._current_timestamp: Optional[pd.Timestamp] = None
|
68
67
|
self._previous_bar: pd.DataFrame = pd.DataFrame()
|
69
|
-
self.
|
68
|
+
self._previous_timestamp: Optional[pd.Timestamp] = None
|
70
69
|
|
71
70
|
self._long_only = long_only
|
72
71
|
self._short_fee_rate = short_fee_rate
|
@@ -75,8 +74,8 @@ class Broker:
|
|
75
74
|
self._benchmark = benchmark
|
76
75
|
self._benchmark_size = 0.0
|
77
76
|
|
78
|
-
self._history: Dict[str, List[float]] = {
|
79
|
-
"
|
77
|
+
self._history: Dict[str, List[float | int | pd.Timestamp]] = {
|
78
|
+
"timestamp": [],
|
80
79
|
"cash": [],
|
81
80
|
"long_position_value": [],
|
82
81
|
"short_position_value": [],
|
@@ -90,7 +89,7 @@ class Broker:
|
|
90
89
|
"""
|
91
90
|
Updates the history dictionary with the current portfolio state.
|
92
91
|
"""
|
93
|
-
self._history["
|
92
|
+
self._history["timestamp"].append(self._current_timestamp)
|
94
93
|
self._history["cash"].append(self._cash)
|
95
94
|
self._history["long_position_value"].append(self.long_position_value)
|
96
95
|
self._history["short_position_value"].append(self.short_position_value)
|
@@ -164,7 +163,7 @@ class Broker:
|
|
164
163
|
raise ValueError(f"Unknown order type {order.order_type}")
|
165
164
|
|
166
165
|
def _update_closed_positions(
|
167
|
-
self, ticker: str, size: float, price: float,
|
166
|
+
self, ticker: str, size: float, price: float, timestamp: pd.Timestamp
|
168
167
|
):
|
169
168
|
"""
|
170
169
|
Updates the list of closed positions for a given trade.
|
@@ -178,7 +177,7 @@ class Broker:
|
|
178
177
|
ticker (str): The ticker symbol of the position
|
179
178
|
size (float): Position size (positive for long, negative for short)
|
180
179
|
price (float): The current closing/reduction price
|
181
|
-
|
180
|
+
timestamp (timestamp): Timestamp of the closing/reduction
|
182
181
|
"""
|
183
182
|
if (
|
184
183
|
ticker in self._open_positions
|
@@ -191,9 +190,9 @@ class Broker:
|
|
191
190
|
self._open_positions[ticker].ticker,
|
192
191
|
min(self._open_positions[ticker].size, abs(size)),
|
193
192
|
self._open_positions[ticker].price,
|
194
|
-
self._open_positions[ticker].
|
193
|
+
self._open_positions[ticker].timestamp,
|
195
194
|
price,
|
196
|
-
|
195
|
+
timestamp,
|
197
196
|
),
|
198
197
|
)
|
199
198
|
# if short position is closed/reduced
|
@@ -203,20 +202,20 @@ class Broker:
|
|
203
202
|
self._open_positions[ticker].ticker,
|
204
203
|
max(self._open_positions[ticker].size, -size),
|
205
204
|
price,
|
206
|
-
|
205
|
+
timestamp,
|
207
206
|
self._open_positions[ticker].price,
|
208
|
-
self._open_positions[ticker].
|
207
|
+
self._open_positions[ticker].timestamp,
|
209
208
|
),
|
210
209
|
)
|
211
210
|
|
212
211
|
def _update_open_positions(
|
213
|
-
self, ticker: str, size: float, price: float,
|
212
|
+
self, ticker: str, size: float, price: float, timestamp: pd.Timestamp
|
214
213
|
):
|
215
214
|
"""
|
216
215
|
Updates the open positions for a given ticker.
|
217
216
|
|
218
217
|
If the ticker already exists in the open positions, it updates the size, price,
|
219
|
-
and
|
218
|
+
and timestamp based on the new transaction. If the size of the position becomes
|
220
219
|
zero, the position is removed. If the ticker does not exist, a new open position
|
221
220
|
is created.
|
222
221
|
|
@@ -224,7 +223,8 @@ class Broker:
|
|
224
223
|
ticker (str): The ticker symbol of the asset.
|
225
224
|
size (float): The size of the position.
|
226
225
|
price (float): The price at which the position was opened or updated.
|
227
|
-
|
226
|
+
timestamp (Timestamp): The timestamp when the position was opened or
|
227
|
+
updated.
|
228
228
|
"""
|
229
229
|
if ticker in self._open_positions:
|
230
230
|
if size + self._open_positions[ticker].size == 0.0:
|
@@ -232,7 +232,7 @@ class Broker:
|
|
232
232
|
else:
|
233
233
|
open_position_size = self._open_positions[ticker].size + size
|
234
234
|
open_position_price = price
|
235
|
-
|
235
|
+
open_position_timestamp = timestamp
|
236
236
|
|
237
237
|
if size * self._open_positions[ticker].size > 0.0:
|
238
238
|
open_position_price = (
|
@@ -240,22 +240,22 @@ class Broker:
|
|
240
240
|
* self._open_positions[ticker].price
|
241
241
|
+ size * price
|
242
242
|
) / (self._open_positions[ticker].size + size)
|
243
|
-
|
243
|
+
open_position_timestamp = self._open_positions[ticker].timestamp
|
244
244
|
elif abs(self._open_positions[ticker].size) > abs(size):
|
245
|
-
|
245
|
+
open_position_timestamp = self._open_positions[ticker].timestamp
|
246
246
|
open_position_price = self._open_positions[ticker].price
|
247
247
|
self._open_positions[ticker] = OpenPosition(
|
248
248
|
ticker,
|
249
249
|
open_position_size,
|
250
250
|
open_position_price,
|
251
|
-
|
251
|
+
open_position_timestamp,
|
252
252
|
)
|
253
253
|
else:
|
254
254
|
self._open_positions[ticker] = OpenPosition(
|
255
255
|
ticker,
|
256
256
|
size,
|
257
257
|
price,
|
258
|
-
|
258
|
+
timestamp,
|
259
259
|
)
|
260
260
|
|
261
261
|
def _update_cash(self, order: Order, price: float):
|
@@ -272,29 +272,29 @@ class Broker:
|
|
272
272
|
else:
|
273
273
|
self._cash -= order.size * price * (1.0 - self._fees)
|
274
274
|
|
275
|
-
def _check_long_only_condition(self, order: Order,
|
275
|
+
def _check_long_only_condition(self, order: Order, timestamp: pd.Timestamp):
|
276
276
|
size = order.size
|
277
277
|
if order.ticker in self._open_positions:
|
278
278
|
size += self._open_positions[order.ticker].size
|
279
279
|
|
280
280
|
if size < 0.0:
|
281
281
|
raise ValueError(
|
282
|
-
f"Short selling is not allowed for {order.ticker} on {
|
282
|
+
f"Short selling is not allowed for {order.ticker} on {timestamp}."
|
283
283
|
)
|
284
284
|
|
285
285
|
def _execute_order(
|
286
286
|
self,
|
287
287
|
order: Order,
|
288
288
|
bar: pd.DataFrame,
|
289
|
-
|
289
|
+
timestamp: pd.Timestamp,
|
290
290
|
) -> bool:
|
291
291
|
"""
|
292
|
-
Executes an order based on the provided bar data and
|
292
|
+
Executes an order based on the provided bar data and timestamp.
|
293
293
|
|
294
294
|
Args:
|
295
295
|
order (Order): The order to be executed.
|
296
296
|
bar (pd.DataFrame): The bar data containing price information.
|
297
|
-
|
297
|
+
timestamp (Timestamp): The timestamp at which the order is executed.
|
298
298
|
|
299
299
|
Returns:
|
300
300
|
bool: True if the order was successfully executed, False otherwise.
|
@@ -308,7 +308,7 @@ class Broker:
|
|
308
308
|
return False
|
309
309
|
|
310
310
|
if self._long_only:
|
311
|
-
self._check_long_only_condition(order,
|
311
|
+
self._check_long_only_condition(order, timestamp)
|
312
312
|
|
313
313
|
price = self._get_price_for_order(order, bar)
|
314
314
|
|
@@ -319,16 +319,16 @@ class Broker:
|
|
319
319
|
# update cash for long and short positions
|
320
320
|
self._update_cash(order, price)
|
321
321
|
|
322
|
-
self._update_closed_positions(ticker, order.size, price,
|
322
|
+
self._update_closed_positions(ticker, order.size, price, timestamp)
|
323
323
|
|
324
|
-
self._update_open_positions(ticker, order.size, price,
|
324
|
+
self._update_open_positions(ticker, order.size, price, timestamp)
|
325
325
|
|
326
326
|
return True
|
327
327
|
|
328
328
|
def update(
|
329
329
|
self,
|
330
330
|
next_bar: pd.DataFrame,
|
331
|
-
|
331
|
+
next_timestamp: pd.Timestamp,
|
332
332
|
):
|
333
333
|
"""
|
334
334
|
Updates the broker's state with the next trading bar and executes pending
|
@@ -344,7 +344,7 @@ class Broker:
|
|
344
344
|
Args:
|
345
345
|
next_bar (pd.DataFrame): The next trading bar data containing at minimum
|
346
346
|
'close' prices for assets
|
347
|
-
|
347
|
+
next_timestamp (pd.Timestamp): The timestamp for the next trading bar
|
348
348
|
|
349
349
|
Notes:
|
350
350
|
- Short fees are calculated using the current bar's closing price
|
@@ -354,9 +354,9 @@ class Broker:
|
|
354
354
|
- Good-till-cancel orders that aren't filled are retained for the next bar
|
355
355
|
"""
|
356
356
|
self._previous_bar = self._current_bar
|
357
|
-
self.
|
357
|
+
self._previous_timestamp = self._current_timestamp
|
358
358
|
self._current_bar = next_bar
|
359
|
-
self.
|
359
|
+
self._current_timestamp = next_timestamp
|
360
360
|
|
361
361
|
# consider short fees
|
362
362
|
if not self._long_only:
|
@@ -384,7 +384,7 @@ class Broker:
|
|
384
384
|
self._execute_order(
|
385
385
|
Order(ticker, -self._open_positions[ticker].size, OrderType.CLOSE),
|
386
386
|
self._previous_bar,
|
387
|
-
self.
|
387
|
+
self._previous_timestamp,
|
388
388
|
)
|
389
389
|
|
390
390
|
# buy and sell assets
|
@@ -396,16 +396,18 @@ class Broker:
|
|
396
396
|
if open_order.ticker in ticker_not_available:
|
397
397
|
if open_order.size > 0:
|
398
398
|
print(
|
399
|
-
f"{open_order.ticker} could not be bought on {self.
|
399
|
+
f"{open_order.ticker} could not be bought on {self._current_timestamp}." # noqa: E501
|
400
400
|
)
|
401
401
|
else:
|
402
402
|
print(
|
403
|
-
f"{open_order.ticker} could not be sold on {self.
|
403
|
+
f"{open_order.ticker} could not be sold on {self._current_timestamp}." # noqa: E501
|
404
404
|
)
|
405
405
|
continue
|
406
406
|
if (
|
407
407
|
not self._execute_order(
|
408
|
-
open_order,
|
408
|
+
open_order,
|
409
|
+
self._current_bar,
|
410
|
+
self._current_timestamp,
|
409
411
|
)
|
410
412
|
and open_order.good_till_cancel
|
411
413
|
):
|
@@ -437,7 +439,7 @@ class Broker:
|
|
437
439
|
self._execute_order(
|
438
440
|
Order(ticker, -self._open_positions[ticker].size, OrderType.CLOSE),
|
439
441
|
self._current_bar,
|
440
|
-
self.
|
442
|
+
self._current_timestamp,
|
441
443
|
)
|
442
444
|
|
443
445
|
def place_order(self, order: Order):
|
@@ -522,7 +524,7 @@ class Broker:
|
|
522
524
|
- ticker: Financial instrument identifier
|
523
525
|
- size: Position size (positive=long, negative=short)
|
524
526
|
- price: Average entry price
|
525
|
-
-
|
527
|
+
- timestamp: Position opening timestamp
|
526
528
|
|
527
529
|
Returns:
|
528
530
|
Dict[str, OpenPosition]: Dictionary mapping ticker symbols to positions.
|
@@ -578,13 +580,14 @@ class Broker:
|
|
578
580
|
return self._cash
|
579
581
|
|
580
582
|
@property
|
581
|
-
def benchmark(self) -> str:
|
583
|
+
def benchmark(self) -> Optional[str]:
|
582
584
|
"""Gets the benchmark symbol used for performance comparison.
|
583
585
|
|
584
586
|
The benchmark tracks a reference asset (e.g., market index) to evaluate relative
|
585
587
|
strategy performance. Returns None if no benchmark was specified.
|
586
588
|
|
587
589
|
Returns:
|
588
|
-
str: Ticker symbol of the benchmark instrument
|
590
|
+
Optional[str]: Ticker symbol of the benchmark instrument, or None if not
|
591
|
+
set.
|
589
592
|
"""
|
590
593
|
return self._benchmark
|
@@ -20,10 +20,10 @@ class Engine:
|
|
20
20
|
self.strategy = strategy
|
21
21
|
|
22
22
|
def run(self, data: pd.DataFrame) -> None:
|
23
|
-
for
|
24
|
-
current_data.index = current_data.index.droplevel("
|
23
|
+
for current_timestamp, current_data in data.groupby("timestamp"):
|
24
|
+
current_data.index = current_data.index.droplevel("timestamp")
|
25
25
|
|
26
|
-
self.broker.update(current_data,
|
27
|
-
self.strategy(current_data,
|
26
|
+
self.broker.update(current_data, current_timestamp)
|
27
|
+
self.strategy.generate_orders(current_data, current_timestamp)
|
28
28
|
|
29
29
|
self.broker.liquidate_positions()
|
@@ -39,13 +39,13 @@ class OpenPosition:
|
|
39
39
|
ticker (str): Financial instrument identifier
|
40
40
|
size (float): Position size (positive for long, negative for short)
|
41
41
|
price (float): Opening price of the position
|
42
|
-
|
42
|
+
timestamp (pd.Timestamp): Position opening timestamp
|
43
43
|
"""
|
44
44
|
|
45
45
|
ticker: str
|
46
46
|
size: float
|
47
47
|
price: float
|
48
|
-
|
48
|
+
timestamp: pd.Timestamp
|
49
49
|
|
50
50
|
|
51
51
|
@dataclass(frozen=True)
|
@@ -57,14 +57,14 @@ class ClosedPosition:
|
|
57
57
|
ticker (str): Financial instrument identifier
|
58
58
|
size (float): Position size (positive for long, negative for short)
|
59
59
|
purchase_price (float): Entry price of the position
|
60
|
-
|
60
|
+
purchase_timestamp (pd.Timestamp): Position entry timestamp
|
61
61
|
selling_price (float): Exit price of the position
|
62
|
-
|
62
|
+
selling_timestamp (pd.Timestamp): Position exit timestamp
|
63
63
|
"""
|
64
64
|
|
65
65
|
ticker: str
|
66
66
|
size: float
|
67
67
|
purchase_price: float
|
68
|
-
|
68
|
+
purchase_timestamp: pd.Timestamp
|
69
69
|
selling_price: float
|
70
|
-
|
70
|
+
selling_timestamp: pd.Timestamp
|
@@ -49,7 +49,7 @@ class Strategy(ABC):
|
|
49
49
|
def generate_orders(
|
50
50
|
self,
|
51
51
|
current_data: pd.DataFrame,
|
52
|
-
|
52
|
+
current_timestamp: pd.Timestamp,
|
53
53
|
) -> None:
|
54
54
|
"""
|
55
55
|
Generate trading orders based on current market data and indicators.
|
@@ -71,7 +71,7 @@ class Strategy(ABC):
|
|
71
71
|
- volume: Trading volume
|
72
72
|
- [custom]: Any additional indicators added during data preparation
|
73
73
|
|
74
|
-
|
74
|
+
current_timestamp: Timestamp of the current bar, e.g. used for order timing
|
75
75
|
|
76
76
|
Returns:
|
77
77
|
None
|
@@ -80,7 +80,7 @@ class Strategy(ABC):
|
|
80
80
|
- Orders are placed through the _broker instance using methods like:
|
81
81
|
create_market_order(), create_limit_order()
|
82
82
|
- Position and portfolio information can be accessed through the _broker
|
83
|
-
- Avoid look-ahead bias by only using data available at
|
83
|
+
- Avoid look-ahead bias by only using data available at current_timestamp
|
84
84
|
- All orders are processed at the next bar's prices (which price depends on
|
85
85
|
the order type)
|
86
86
|
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: kissbt
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.6
|
4
4
|
Summary: The keep it simple backtesting framework for Python.
|
5
5
|
Author-email: Adrian Hasse <adrian.hasse@finblobs.com>
|
6
6
|
Maintainer-email: Adrian Hasse <adrian.hasse@finblobs.com>
|
@@ -216,15 +216,16 @@ Description-Content-Type: text/markdown
|
|
216
216
|
License-File: LICENSE
|
217
217
|
Requires-Dist: numpy
|
218
218
|
Requires-Dist: pandas
|
219
|
+
Requires-Dist: scipy
|
219
220
|
Requires-Dist: matplotlib
|
220
221
|
Provides-Extra: dev
|
221
222
|
Requires-Dist: pytest; extra == "dev"
|
222
223
|
Requires-Dist: pytest-mock; extra == "dev"
|
223
|
-
Requires-Dist:
|
224
|
-
Requires-Dist:
|
225
|
-
Requires-Dist: isort; extra == "dev"
|
224
|
+
Requires-Dist: ruff; extra == "dev"
|
225
|
+
Requires-Dist: mypy; extra == "dev"
|
226
226
|
Requires-Dist: yfinance; extra == "dev"
|
227
227
|
Requires-Dist: pyarrow; extra == "dev"
|
228
|
+
Dynamic: license-file
|
228
229
|
|
229
230
|
# kissbt
|
230
231
|
|
@@ -264,7 +265,7 @@ pip install kissbt
|
|
264
265
|
To install `kissbt` via `conda`, run the following command:
|
265
266
|
|
266
267
|
```sh
|
267
|
-
conda install kissbt
|
268
|
+
conda install -c conda-forge kissbt
|
268
269
|
```
|
269
270
|
|
270
271
|
## Usage
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "kissbt"
|
7
|
-
version = "0.1.
|
7
|
+
version = "0.1.6"
|
8
8
|
description = "The keep it simple backtesting framework for Python."
|
9
9
|
readme = "README.md"
|
10
10
|
authors = [
|
@@ -22,6 +22,7 @@ classifiers = [
|
|
22
22
|
dependencies = [
|
23
23
|
"numpy",
|
24
24
|
"pandas",
|
25
|
+
"scipy",
|
25
26
|
"matplotlib"
|
26
27
|
]
|
27
28
|
requires-python = ">=3.10"
|
@@ -30,9 +31,8 @@ requires-python = ">=3.10"
|
|
30
31
|
dev = [
|
31
32
|
"pytest",
|
32
33
|
"pytest-mock",
|
33
|
-
"
|
34
|
-
"
|
35
|
-
"isort",
|
34
|
+
"ruff",
|
35
|
+
"mypy",
|
36
36
|
"yfinance",
|
37
37
|
"pyarrow",
|
38
38
|
]
|
@@ -41,21 +41,39 @@ dev = [
|
|
41
41
|
homepage = "https://github.com/FinBlobs/kissbt"
|
42
42
|
bug-tracker = "https://github.com/FinBlobs/kissbt/issues"
|
43
43
|
|
44
|
-
[tool.
|
44
|
+
[tool.ruff]
|
45
45
|
line-length = 88
|
46
|
-
target-version =
|
47
|
-
|
46
|
+
target-version = "py310"
|
47
|
+
src = ["kissbt", "tests"]
|
48
|
+
exclude = [
|
49
|
+
".git",
|
50
|
+
"__pycache__",
|
51
|
+
"build",
|
52
|
+
"dist",
|
53
|
+
"examples",
|
54
|
+
]
|
55
|
+
|
56
|
+
[tool.ruff.lint]
|
57
|
+
# Start with core rules equivalent to the original flake8 setup
|
58
|
+
select = [
|
59
|
+
"E", # pycodestyle errors
|
60
|
+
"W", # pycodestyle warnings
|
61
|
+
"F", # pyflakes
|
62
|
+
"I", # isort
|
63
|
+
]
|
64
|
+
# TODO: Gradually add more rules like N, UP, C4, B
|
48
65
|
|
49
|
-
[tool.
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
use_parentheses = true
|
56
|
-
ensure_newline_before_comments = true
|
66
|
+
[tool.ruff.format]
|
67
|
+
# Enable Black-compatible formatting
|
68
|
+
quote-style = "double"
|
69
|
+
indent-style = "space"
|
70
|
+
skip-magic-trailing-comma = false
|
71
|
+
line-ending = "auto"
|
57
72
|
|
58
|
-
[tool.
|
59
|
-
|
60
|
-
|
61
|
-
|
73
|
+
[tool.mypy]
|
74
|
+
python_version = "3.10"
|
75
|
+
# Start with basic type checking, will enable strict mode later
|
76
|
+
warn_return_any = false
|
77
|
+
warn_unused_configs = true
|
78
|
+
show_error_codes = true
|
79
|
+
ignore_missing_imports = true
|
@@ -0,0 +1,61 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import pandas as pd
|
3
|
+
import pytest
|
4
|
+
from kissbt.analyzer import Analyzer
|
5
|
+
from kissbt.broker import Broker
|
6
|
+
|
7
|
+
|
8
|
+
def test_constant_growth_benchmark_stats():
|
9
|
+
daily_return = 0.0001
|
10
|
+
num_days = 252
|
11
|
+
start_value = 100000.0
|
12
|
+
values = [start_value * (1 + daily_return) ** i for i in range(num_days)]
|
13
|
+
|
14
|
+
broker = Broker(benchmark="constant_growth")
|
15
|
+
for i, val in enumerate(values):
|
16
|
+
ts = pd.Timestamp("2023-01-01") + pd.Timedelta(days=i)
|
17
|
+
broker.history["timestamp"].append(ts)
|
18
|
+
broker.history["total_value"].append(start_value)
|
19
|
+
broker.history["benchmark"].append(val)
|
20
|
+
broker.history["cash"].append(0)
|
21
|
+
broker.history["long_position_value"].append(0)
|
22
|
+
broker.history["short_position_value"].append(0)
|
23
|
+
broker.history["positions"].append({})
|
24
|
+
|
25
|
+
analyzer = Analyzer(broker)
|
26
|
+
metrics = analyzer.get_performance_metrics()
|
27
|
+
|
28
|
+
assert abs(metrics["benchmark_slope"] - np.log(1 + daily_return)) < 1e-10
|
29
|
+
assert metrics["benchmark_slope_se"] < 1e-10
|
30
|
+
assert metrics["benchmark_slope_tstat"] > 1e5
|
31
|
+
assert metrics["benchmark_r_squared"] > 0.9999
|
32
|
+
|
33
|
+
|
34
|
+
def test_portfolio_equity_curve_stats_with_volatility():
|
35
|
+
np.random.seed(42)
|
36
|
+
num_days = 256 * 3
|
37
|
+
start_value = 100000.0
|
38
|
+
|
39
|
+
# Generate portfolio values with some volatility
|
40
|
+
daily_returns = np.random.normal(0.001, 0.02, num_days)
|
41
|
+
portfolio_values = [start_value]
|
42
|
+
for ret in daily_returns:
|
43
|
+
portfolio_values.append(portfolio_values[-1] * (1 + ret))
|
44
|
+
|
45
|
+
broker = Broker()
|
46
|
+
for i, val in enumerate(portfolio_values):
|
47
|
+
ts = pd.Timestamp("2023-01-01") + pd.Timedelta(days=i)
|
48
|
+
broker.history["timestamp"].append(ts)
|
49
|
+
broker.history["total_value"].append(val)
|
50
|
+
broker.history["cash"].append(0)
|
51
|
+
broker.history["long_position_value"].append(val)
|
52
|
+
broker.history["short_position_value"].append(0)
|
53
|
+
broker.history["positions"].append({})
|
54
|
+
|
55
|
+
analyzer = Analyzer(broker)
|
56
|
+
metrics = analyzer.get_performance_metrics()
|
57
|
+
|
58
|
+
assert metrics["slope"] == pytest.approx(0.001, abs=0.0005)
|
59
|
+
assert metrics["slope_se"] > 0
|
60
|
+
assert metrics["slope_tstat"] > 1.96
|
61
|
+
assert 0.5 < metrics["r_squared"] < 0.9
|
@@ -1,8 +1,5 @@
|
|
1
|
-
from datetime import datetime
|
2
|
-
|
3
1
|
import pandas as pd
|
4
2
|
import pytest
|
5
|
-
|
6
3
|
from kissbt.broker import Broker
|
7
4
|
from kissbt.entities import OpenPosition, Order, OrderType
|
8
5
|
|
@@ -14,7 +11,7 @@ def test_initial_broker_state(broker):
|
|
14
11
|
assert broker.closed_positions == []
|
15
12
|
assert broker.history == {
|
16
13
|
"cash": [],
|
17
|
-
"
|
14
|
+
"timestamp": [],
|
18
15
|
"long_position_value": [],
|
19
16
|
"positions": [],
|
20
17
|
"short_position_value": [],
|
@@ -35,7 +32,7 @@ def test_execute_order(broker, mocker):
|
|
35
32
|
broker._execute_order(
|
36
33
|
order,
|
37
34
|
bar=pd.DataFrame({"open": [150.0], "close": [152.0]}, index=["AAPL"]),
|
38
|
-
|
35
|
+
timestamp=pd.Timestamp.now(),
|
39
36
|
)
|
40
37
|
assert broker.cash == 100000 - (10 * 150 * 1.001)
|
41
38
|
assert len(broker.open_positions) == 1
|
@@ -53,17 +50,17 @@ def test_update_open_positions(broker):
|
|
53
50
|
assert broker._execute_order(
|
54
51
|
order,
|
55
52
|
bar=pd.DataFrame({"open": [150.0], "close": [152.0]}, index=["AAPL"]),
|
56
|
-
|
53
|
+
timestamp=pd.Timestamp.now(),
|
57
54
|
)
|
58
55
|
assert len(broker.open_positions) == 1
|
59
56
|
assert "AAPL" in broker.open_positions
|
60
57
|
|
61
58
|
|
62
59
|
def test_update_closed_positions(broker):
|
63
|
-
position = OpenPosition("AAPL", 10, 150.0,
|
60
|
+
position = OpenPosition("AAPL", 10, 150.0, pd.Timestamp.now())
|
64
61
|
broker._open_positions["AAPL"] = position
|
65
62
|
broker._update_closed_positions(
|
66
|
-
position.ticker, -position.size, position.price, position.
|
63
|
+
position.ticker, -position.size, position.price, position.timestamp
|
67
64
|
)
|
68
65
|
assert len(broker.closed_positions) == 1
|
69
66
|
assert broker.closed_positions[0].ticker == "AAPL"
|
@@ -72,14 +69,14 @@ def test_update_closed_positions(broker):
|
|
72
69
|
def test_liquidate_positions(broker):
|
73
70
|
order = Order("AAPL", 10, OrderType.OPEN)
|
74
71
|
bar = pd.DataFrame({"open": [150.0], "close": [152.0]}, index=["AAPL"])
|
75
|
-
time =
|
72
|
+
time = pd.Timestamp.now()
|
76
73
|
broker._execute_order(
|
77
74
|
order,
|
78
75
|
bar=bar,
|
79
|
-
|
76
|
+
timestamp=time,
|
80
77
|
)
|
81
78
|
broker._current_bar = bar
|
82
|
-
broker.
|
79
|
+
broker._current_timestamp = time
|
83
80
|
broker.liquidate_positions()
|
84
81
|
|
85
82
|
assert broker.open_positions == {}
|
@@ -87,14 +84,16 @@ def test_liquidate_positions(broker):
|
|
87
84
|
assert broker.closed_positions[0].ticker == "AAPL"
|
88
85
|
assert broker.closed_positions[0].size == 10
|
89
86
|
assert broker.closed_positions[0].purchase_price == 150.0
|
90
|
-
assert broker.closed_positions[0].
|
87
|
+
assert broker.closed_positions[0].purchase_timestamp == time
|
91
88
|
assert broker.closed_positions[0].selling_price == 152.0
|
92
|
-
assert broker.closed_positions[0].
|
89
|
+
assert broker.closed_positions[0].selling_timestamp == time
|
93
90
|
|
94
91
|
|
95
92
|
# --- Testing Portfolio Metrics ---
|
96
93
|
def test_long_position_value(broker):
|
97
|
-
broker._open_positions = {
|
94
|
+
broker._open_positions = {
|
95
|
+
"AAPL": OpenPosition("AAPL", 10, 150.0, pd.Timestamp.now())
|
96
|
+
}
|
98
97
|
broker._current_bar = pd.DataFrame(
|
99
98
|
{"open": [150.0], "close": [152.0]}, index=["AAPL"]
|
100
99
|
)
|
@@ -104,7 +103,9 @@ def test_long_position_value(broker):
|
|
104
103
|
|
105
104
|
|
106
105
|
def test_short_position_value(broker):
|
107
|
-
broker._open_positions = {
|
106
|
+
broker._open_positions = {
|
107
|
+
"AAPL": OpenPosition("AAPL", -10, 150.0, pd.Timestamp.now())
|
108
|
+
}
|
108
109
|
broker._current_bar = pd.DataFrame(
|
109
110
|
{"open": [150.0], "close": [152.0]}, index=["AAPL"]
|
110
111
|
)
|
@@ -118,7 +119,7 @@ def test_execute_order_open(broker, mocker):
|
|
118
119
|
executed = broker._execute_order(
|
119
120
|
order,
|
120
121
|
bar=pd.DataFrame({"open": [150.0], "close": [152.0]}, index=["AAPL"]),
|
121
|
-
|
122
|
+
timestamp=pd.Timestamp.now(),
|
122
123
|
)
|
123
124
|
assert executed
|
124
125
|
assert broker.cash == 98498.5
|
@@ -129,12 +130,12 @@ def test_execute_order_open(broker, mocker):
|
|
129
130
|
def test_execute_order_close(broker):
|
130
131
|
order = Order("AAPL", -10, OrderType.CLOSE)
|
131
132
|
bar = pd.DataFrame({"open": [150.0], "close": [152.0]}, index=["AAPL"])
|
132
|
-
broker._open_positions["AAPL"] = OpenPosition("AAPL", 10, 150.0,
|
133
|
+
broker._open_positions["AAPL"] = OpenPosition("AAPL", 10, 150.0, pd.Timestamp.now())
|
133
134
|
broker._current_bar = bar
|
134
135
|
executed = broker._execute_order(
|
135
136
|
order,
|
136
137
|
bar=bar,
|
137
|
-
|
138
|
+
timestamp=pd.Timestamp.now(),
|
138
139
|
)
|
139
140
|
assert executed
|
140
141
|
assert float(broker.cash) == 100000 + 10 * 152 * 0.999
|
@@ -154,8 +155,8 @@ def test_place_multiple_orders(broker):
|
|
154
155
|
|
155
156
|
def test_portfolio_value_with_positions(broker):
|
156
157
|
broker._open_positions = {
|
157
|
-
"AAPL": OpenPosition("AAPL", 10, 150.0,
|
158
|
-
"GOOG": OpenPosition("GOOG", -5, 1000.0,
|
158
|
+
"AAPL": OpenPosition("AAPL", 10, 150.0, pd.Timestamp.now()),
|
159
|
+
"GOOG": OpenPosition("GOOG", -5, 1000.0, pd.Timestamp.now()),
|
159
160
|
}
|
160
161
|
broker._current_bar = pd.DataFrame(
|
161
162
|
{"close": [152.0, 995.0]}, index=["AAPL", "GOOG"]
|
@@ -164,18 +165,18 @@ def test_portfolio_value_with_positions(broker):
|
|
164
165
|
|
165
166
|
|
166
167
|
def test_update_history(broker):
|
167
|
-
broker.
|
168
|
+
broker._current_timestamp = pd.Timestamp.now()
|
168
169
|
broker._cash = 100000
|
169
170
|
broker._open_positions = {
|
170
|
-
"AAPL": OpenPosition("AAPL", 10, 150.0,
|
171
|
-
"GOOG": OpenPosition("GOOG", -5, 1000.0,
|
171
|
+
"AAPL": OpenPosition("AAPL", 10, 150.0, pd.Timestamp.now()),
|
172
|
+
"GOOG": OpenPosition("GOOG", -5, 1000.0, pd.Timestamp.now()),
|
172
173
|
}
|
173
174
|
broker._current_bar = pd.DataFrame(
|
174
175
|
{"close": [152.0, 995.0]}, index=["AAPL", "GOOG"]
|
175
176
|
)
|
176
177
|
broker._update_history()
|
177
178
|
history = broker.history
|
178
|
-
assert len(history["
|
179
|
+
assert len(history["timestamp"]) == 1
|
179
180
|
assert len(history["cash"]) == 1
|
180
181
|
assert len(history["long_position_value"]) == 1
|
181
182
|
assert len(history["short_position_value"]) == 1
|
@@ -227,9 +228,9 @@ def test_invalid_order_type(broker):
|
|
227
228
|
|
228
229
|
def test_position_update(broker):
|
229
230
|
broker._open_positions["AAPL"] = OpenPosition(
|
230
|
-
ticker="AAPL", size=5, price=100,
|
231
|
+
ticker="AAPL", size=5, price=100, timestamp=pd.Timestamp(2024, 1, 1)
|
231
232
|
)
|
232
|
-
broker._update_open_positions("AAPL", 10, 110,
|
233
|
+
broker._update_open_positions("AAPL", 10, 110, pd.Timestamp(2024, 1, 2))
|
233
234
|
assert broker._open_positions["AAPL"].size == 15
|
234
235
|
assert broker._open_positions["AAPL"].price == pytest.approx(
|
235
236
|
(5 * 100 + 10 * 110) / 15
|
@@ -240,11 +241,11 @@ def test_short_position_fees(broker, mocker):
|
|
240
241
|
broker._long_only = False
|
241
242
|
broker._current_bar = pd.DataFrame({"close": [100]}, index=["AAPL"])
|
242
243
|
broker._open_positions["AAPL"] = OpenPosition(
|
243
|
-
ticker="AAPL", size=-10, price=105,
|
244
|
+
ticker="AAPL", size=-10, price=105, timestamp=pd.Timestamp(2024, 1, 1)
|
244
245
|
)
|
245
246
|
next_bar = pd.DataFrame({"close": [100]}, index=["AAPL"])
|
246
|
-
|
247
|
-
broker.update(next_bar,
|
247
|
+
next_timestamp = pd.Timestamp("2024-01-02")
|
248
|
+
broker.update(next_bar, next_timestamp)
|
248
249
|
assert broker.cash < 100000 # Short fee applied
|
249
250
|
|
250
251
|
|
@@ -291,15 +292,15 @@ def test_check_long_only_blocks_short_orders(broker):
|
|
291
292
|
broker._long_only = True
|
292
293
|
order = Order("AAPL", -10, OrderType.OPEN)
|
293
294
|
with pytest.raises(ValueError):
|
294
|
-
broker._check_long_only_condition(order,
|
295
|
+
broker._check_long_only_condition(order, pd.Timestamp.now())
|
295
296
|
|
296
297
|
|
297
298
|
def test_check_long_only_condition(broker):
|
298
|
-
time =
|
299
|
+
time = pd.Timestamp.now()
|
299
300
|
broker._current_bar = pd.DataFrame(
|
300
301
|
{"open": [150.0], "close": [152.0]}, index=["AAPL"]
|
301
302
|
)
|
302
|
-
broker.
|
303
|
+
broker._current_timestamp = time
|
303
304
|
order = Order("AAPL", -10, order_type=OrderType.OPEN)
|
304
305
|
with pytest.raises(ValueError):
|
305
306
|
broker._check_long_only_condition(order, time)
|
@@ -308,11 +309,11 @@ def test_check_long_only_condition(broker):
|
|
308
309
|
def test_update_with_short_fees(broker):
|
309
310
|
broker._long_only = False
|
310
311
|
broker._open_positions["AAPL"] = OpenPosition(
|
311
|
-
"AAPL", -10, 100,
|
312
|
+
"AAPL", -10, 100, pd.Timestamp(2024, 1, 1)
|
312
313
|
)
|
313
314
|
next_bar = pd.DataFrame({"close": [100]}, index=["AAPL"])
|
314
|
-
|
315
|
-
broker.update(next_bar,
|
315
|
+
next_timestamp = pd.Timestamp("2024-01-02")
|
316
|
+
broker.update(next_bar, next_timestamp)
|
316
317
|
assert broker.cash < 100000 # Short fee applied
|
317
318
|
|
318
319
|
|
@@ -343,8 +344,10 @@ def test_update_cash(broker):
|
|
343
344
|
def test_update_open_positions_cases(
|
344
345
|
broker, size_change, expected_size, expected_price
|
345
346
|
):
|
346
|
-
broker._open_positions["AAPL"] = OpenPosition(
|
347
|
-
|
347
|
+
broker._open_positions["AAPL"] = OpenPosition(
|
348
|
+
"AAPL", 10, 100, pd.Timestamp(2024, 1, 1)
|
349
|
+
)
|
350
|
+
broker._update_open_positions("AAPL", size_change, 110, pd.Timestamp(2024, 1, 2))
|
348
351
|
|
349
352
|
if expected_size is None:
|
350
353
|
assert "AAPL" not in broker._open_positions
|
@@ -354,14 +357,18 @@ def test_update_open_positions_cases(
|
|
354
357
|
|
355
358
|
|
356
359
|
def test_update_ticker_out_of_universe(broker):
|
357
|
-
broker._open_positions["AAPL"] = OpenPosition(
|
360
|
+
broker._open_positions["AAPL"] = OpenPosition(
|
361
|
+
"AAPL", 10, 100, pd.Timestamp(2024, 1, 1)
|
362
|
+
)
|
358
363
|
|
359
364
|
broker.update(
|
360
365
|
pd.DataFrame({"close": [100, 500]}, index=["GOOG", "AAPL"]),
|
361
|
-
|
366
|
+
pd.Timestamp(2024, 1, 2),
|
362
367
|
)
|
363
368
|
|
364
|
-
broker.update(
|
369
|
+
broker.update(
|
370
|
+
pd.DataFrame({"close": [110]}, index=["GOOG"]), pd.Timestamp(2024, 1, 3)
|
371
|
+
)
|
365
372
|
|
366
373
|
assert len(broker._closed_positions) == 1
|
367
374
|
closed_pos = broker._closed_positions[0]
|
@@ -369,5 +376,5 @@ def test_update_ticker_out_of_universe(broker):
|
|
369
376
|
assert closed_pos.size == 10
|
370
377
|
assert closed_pos.purchase_price == 100
|
371
378
|
assert closed_pos.selling_price == 500
|
372
|
-
assert closed_pos.
|
373
|
-
assert closed_pos.
|
379
|
+
assert closed_pos.purchase_timestamp == pd.Timestamp(2024, 1, 1)
|
380
|
+
assert closed_pos.selling_timestamp == pd.Timestamp(2024, 1, 2)
|
@@ -1,7 +1,4 @@
|
|
1
|
-
from datetime import datetime
|
2
|
-
|
3
1
|
import pandas as pd
|
4
|
-
|
5
2
|
from kissbt.entities import OpenPosition, Order, OrderType
|
6
3
|
|
7
4
|
|
@@ -30,10 +27,10 @@ def test_order_defaults():
|
|
30
27
|
|
31
28
|
|
32
29
|
def test_open_position_creation():
|
33
|
-
entry_time = pd.Timestamp(
|
34
|
-
position = OpenPosition(ticker="MSFT", size=50, price=250.0,
|
30
|
+
entry_time = pd.Timestamp(2024, 1, 1, 10, 30, 0)
|
31
|
+
position = OpenPosition(ticker="MSFT", size=50, price=250.0, timestamp=entry_time)
|
35
32
|
|
36
33
|
assert position.ticker == "MSFT"
|
37
34
|
assert position.size == 50
|
38
35
|
assert position.price == 250.0
|
39
|
-
assert position.
|
36
|
+
assert position.timestamp == entry_time
|
@@ -1,6 +1,5 @@
|
|
1
1
|
import pandas as pd
|
2
2
|
import pytest
|
3
|
-
|
4
3
|
from kissbt.analyzer import Analyzer
|
5
4
|
from kissbt.broker import Broker
|
6
5
|
from kissbt.engine import Engine
|
@@ -15,7 +14,7 @@ class GoldenCrossStrategy(Strategy):
|
|
15
14
|
def generate_orders(
|
16
15
|
self,
|
17
16
|
current_data: pd.DataFrame,
|
18
|
-
|
17
|
+
current_timestamp: pd.Timestamp,
|
19
18
|
) -> None:
|
20
19
|
for ticker in self._broker.open_positions:
|
21
20
|
if (
|
@@ -66,9 +65,9 @@ def test_analyzer_with_golden_cross(tech_stock_data):
|
|
66
65
|
assert len(broker.open_positions) == 0, "All positions should be closed"
|
67
66
|
assert pytest.approx(broker.cash, 0.01) == 167534.46, "Final cash manually verified"
|
68
67
|
assert pytest.approx(broker.portfolio_value, 0.01) == 167534.46
|
69
|
-
assert (
|
70
|
-
|
71
|
-
)
|
68
|
+
assert len(broker.closed_positions) == 15, (
|
69
|
+
"15 trades should have been executed, manually verified"
|
70
|
+
)
|
72
71
|
|
73
72
|
# Create the Analyzer
|
74
73
|
analyzer = Analyzer(broker, bar_size="1D")
|
@@ -99,9 +98,10 @@ def test_analyzer_with_golden_cross(tech_stock_data):
|
|
99
98
|
assert pytest.approx(metrics["volatility"], abs=0.01) == 0.24
|
100
99
|
assert pytest.approx(metrics["win_rate"], abs=0.01) == 0.47
|
101
100
|
assert pytest.approx(metrics["profit_factor"], abs=0.01) == 3.17
|
102
|
-
assert pytest.approx(metrics["
|
103
|
-
assert pytest.approx(metrics["
|
101
|
+
assert pytest.approx(metrics["benchmark_total_return"], abs=0.01) == 0.29
|
102
|
+
assert pytest.approx(metrics["benchmark_annual_return"], abs=0.01) == 0.09
|
104
103
|
|
105
104
|
# Ensure running the plot functions does not raise an exception
|
106
105
|
analyzer.plot_equity_curve()
|
107
106
|
analyzer.plot_drawdowns()
|
107
|
+
analyzer.plot_rolling_returns_distribution(252)
|
@@ -1,6 +1,5 @@
|
|
1
1
|
import pandas as pd
|
2
2
|
import pytest
|
3
|
-
|
4
3
|
from kissbt.broker import Broker
|
5
4
|
from kissbt.entities import Order
|
6
5
|
from kissbt.strategy import Strategy
|
@@ -8,7 +7,7 @@ from kissbt.strategy import Strategy
|
|
8
7
|
|
9
8
|
class DummyStrategy(Strategy):
|
10
9
|
def generate_orders(
|
11
|
-
self, current_data: pd.DataFrame,
|
10
|
+
self, current_data: pd.DataFrame, current_timestamp: pd.Timestamp
|
12
11
|
) -> None:
|
13
12
|
for ticker in current_data.index:
|
14
13
|
if current_data.loc[ticker, "close"] > 100:
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|