kissbt 0.1.1__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.
- kissbt/__init__.py +0 -0
- kissbt/analyzer.py +323 -0
- kissbt/broker.py +590 -0
- kissbt/engine.py +29 -0
- kissbt/entities.py +70 -0
- kissbt/strategy.py +98 -0
- kissbt-0.1.1.dist-info/LICENSE +201 -0
- kissbt-0.1.1.dist-info/METADATA +346 -0
- kissbt-0.1.1.dist-info/RECORD +11 -0
- kissbt-0.1.1.dist-info/WHEEL +5 -0
- kissbt-0.1.1.dist-info/top_level.txt +1 -0
kissbt/broker.py
ADDED
@@ -0,0 +1,590 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from typing import Dict, List, Optional
|
3
|
+
|
4
|
+
import pandas as pd
|
5
|
+
|
6
|
+
from kissbt.entities import ClosedPosition, OpenPosition, Order, OrderType
|
7
|
+
|
8
|
+
|
9
|
+
class Broker:
|
10
|
+
"""
|
11
|
+
Manages the portfolio's cash, open and closed positions, and pending orders.
|
12
|
+
|
13
|
+
This broker class is responsible for tracking capital, applying trading fees, and
|
14
|
+
handling order execution for both opening and closing positions. It maintains
|
15
|
+
consistency between current and previous market data bars, ensuring correct updates
|
16
|
+
for position values, cash, and any accrued expenses such as short fees.
|
17
|
+
|
18
|
+
The broker interfaces with several other components:
|
19
|
+
- Manages OpenPosition and ClosedPosition objects to track trade lifecycles
|
20
|
+
- Processes Order objects from Strategy class based on market data
|
21
|
+
- Records transaction history for analysis by Analyzer
|
22
|
+
- Receives updates from Engine for each market data bar
|
23
|
+
|
24
|
+
Typical usage:
|
25
|
+
broker = Broker(start_capital=100000, fees=0.001)
|
26
|
+
broker.place_order(Order("AAPL", 100, OrderType.OPEN))
|
27
|
+
broker.update(next_bar, next_datetime)
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(
|
31
|
+
self,
|
32
|
+
start_capital: float = 100000,
|
33
|
+
fees: float = 0.001,
|
34
|
+
long_only: bool = True,
|
35
|
+
short_fee_rate: float = 0.0050,
|
36
|
+
benchmark: Optional[str] = None,
|
37
|
+
):
|
38
|
+
"""
|
39
|
+
Initialize the Broker.
|
40
|
+
|
41
|
+
Sets up the initial capital, commission fees, and optional benchmark tracking.
|
42
|
+
Configures whether broker operates in long-only mode or allows short selling.
|
43
|
+
Initializes all internal data structures for managing positions, orders, and
|
44
|
+
history.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
start_capital: Initial amount of cash available for trading, defaults to
|
48
|
+
100000.
|
49
|
+
fees: Commission or transaction fee applied to each trade, defaults to
|
50
|
+
0.0050.
|
51
|
+
long_only: If True, restricts trading to long positions only.
|
52
|
+
Defaults to True.
|
53
|
+
short_fee_rate: Annual fee rate for maintaining short positions.
|
54
|
+
Defaults to 0.0050. Daily rate derived automatically.
|
55
|
+
benchmark: Symbol used for performance comparison.
|
56
|
+
If provided, broker tracks performance against this symbol.
|
57
|
+
"""
|
58
|
+
self._cash = start_capital
|
59
|
+
self._start_capital = start_capital
|
60
|
+
self._fees = fees
|
61
|
+
|
62
|
+
self._open_positions: Dict[str, OpenPosition] = dict()
|
63
|
+
self._closed_positions: List[ClosedPosition] = []
|
64
|
+
self._open_orders: List[Order] = []
|
65
|
+
|
66
|
+
self._current_bar: pd.DataFrame = pd.DataFrame()
|
67
|
+
self._current_datetime = None
|
68
|
+
self._previous_bar: pd.DataFrame = pd.DataFrame()
|
69
|
+
self._previous_datetime = None
|
70
|
+
|
71
|
+
self._long_only = long_only
|
72
|
+
self._short_fee_rate = short_fee_rate
|
73
|
+
self._daily_short_fee_rate = -1.0 + (1.0 + short_fee_rate) ** (1.0 / 252.0)
|
74
|
+
|
75
|
+
self._benchmark = benchmark
|
76
|
+
self._benchmark_size = 0.0
|
77
|
+
|
78
|
+
self._history: Dict[str, List[float]] = {
|
79
|
+
"date": [],
|
80
|
+
"cash": [],
|
81
|
+
"long_position_value": [],
|
82
|
+
"short_position_value": [],
|
83
|
+
"total_value": [],
|
84
|
+
"positions": [],
|
85
|
+
}
|
86
|
+
if benchmark is not None:
|
87
|
+
self._history["benchmark"] = []
|
88
|
+
|
89
|
+
def _update_history(self):
|
90
|
+
"""
|
91
|
+
Updates the history dictionary with the current portfolio state.
|
92
|
+
"""
|
93
|
+
self._history["date"].append(self._current_datetime)
|
94
|
+
self._history["cash"].append(self._cash)
|
95
|
+
self._history["long_position_value"].append(self.long_position_value)
|
96
|
+
self._history["short_position_value"].append(self.short_position_value)
|
97
|
+
self._history["total_value"].append(
|
98
|
+
self.long_position_value + self.short_position_value + self._cash
|
99
|
+
)
|
100
|
+
self._history["positions"].append(len(self._open_positions))
|
101
|
+
if self._benchmark is not None:
|
102
|
+
if len(self._history["benchmark"]) == 0:
|
103
|
+
self.benchmark_size = (
|
104
|
+
self._start_capital
|
105
|
+
/ self._current_bar.loc[self._benchmark, "close"]
|
106
|
+
* (1.0 + self._fees)
|
107
|
+
)
|
108
|
+
self._history["benchmark"].append(
|
109
|
+
self._current_bar.loc[self._benchmark, "close"]
|
110
|
+
* self.benchmark_size
|
111
|
+
* (1.0 - self._fees)
|
112
|
+
)
|
113
|
+
|
114
|
+
def _get_price_for_order(self, order: Order, bar: pd.DataFrame) -> float | None:
|
115
|
+
"""
|
116
|
+
Determines the execution price for a given order based on the current market
|
117
|
+
data.
|
118
|
+
|
119
|
+
Args:
|
120
|
+
order (Order): The order to be executed, containing information about
|
121
|
+
ticker, order type, size, and limit price if applicable.
|
122
|
+
bar (pd.DataFrame): Current market data frame containing OHLC prices for the
|
123
|
+
ticker.
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
float | None: The execution price for the order. None is returned if:
|
127
|
+
- For limit orders: the limit price condition is not met
|
128
|
+
- For open/close orders with limits: the limit price condition is not
|
129
|
+
met
|
130
|
+
|
131
|
+
Raises:
|
132
|
+
ValueError: If the order type is not recognized (not OPEN, CLOSE, or LIMIT)
|
133
|
+
|
134
|
+
Notes:
|
135
|
+
Price determination rules:
|
136
|
+
- For OPEN orders: Uses the opening price
|
137
|
+
- For CLOSE orders: Uses the closing price
|
138
|
+
- For LIMIT orders:
|
139
|
+
- Buy orders: Uses min(open price, limit price) if
|
140
|
+
low price <= limit
|
141
|
+
- Sell orders: Uses max(open price, limit price) if
|
142
|
+
high price >= limit
|
143
|
+
"""
|
144
|
+
ticker = order.ticker
|
145
|
+
if order.order_type == OrderType.OPEN or order.order_type == OrderType.CLOSE:
|
146
|
+
col = "open" if order.order_type == OrderType.OPEN else "close"
|
147
|
+
if order.limit is None:
|
148
|
+
return bar.loc[ticker, col]
|
149
|
+
else:
|
150
|
+
if order.size > 0.0 and bar.loc[ticker, col] <= order.limit:
|
151
|
+
return bar.loc[ticker, col]
|
152
|
+
elif order.size < 0.0 and bar.loc[ticker, col] >= order.limit:
|
153
|
+
return bar.loc[ticker, col]
|
154
|
+
else:
|
155
|
+
return None
|
156
|
+
elif order.order_type == OrderType.LIMIT:
|
157
|
+
if order.size > 0.0 and bar.loc[ticker, "low"] <= order.limit:
|
158
|
+
return min(bar.loc[ticker, "open"], order.limit)
|
159
|
+
elif order.size < 0.0 and bar.loc[ticker, "high"] >= order.limit:
|
160
|
+
return max(bar.loc[ticker, "open"], order.limit)
|
161
|
+
else:
|
162
|
+
return None
|
163
|
+
else:
|
164
|
+
raise ValueError(f"Unknown order type {order.order_type}")
|
165
|
+
|
166
|
+
def _update_closed_positions(
|
167
|
+
self, ticker: str, size: float, price: float, datetime: datetime
|
168
|
+
):
|
169
|
+
"""
|
170
|
+
Updates the list of closed positions for a given trade.
|
171
|
+
|
172
|
+
Updates closed positions tracking when a position is fully or partially closed.
|
173
|
+
For long positions being closed, records the entry price from open position and
|
174
|
+
exit at current price. For short positions being closed, records entry at
|
175
|
+
current price and exit at open position price.
|
176
|
+
|
177
|
+
Args:
|
178
|
+
ticker (str): The ticker symbol of the position
|
179
|
+
size (float): Position size (positive for long, negative for short)
|
180
|
+
price (float): The current closing/reduction price
|
181
|
+
datetime (datetime): Timestamp of the closing/reduction
|
182
|
+
"""
|
183
|
+
if (
|
184
|
+
ticker in self._open_positions
|
185
|
+
and size * self._open_positions[ticker].size < 0.0
|
186
|
+
):
|
187
|
+
# if long position is closed/reduced
|
188
|
+
if self._open_positions[ticker].size > 0.0:
|
189
|
+
self._closed_positions.append(
|
190
|
+
ClosedPosition(
|
191
|
+
self._open_positions[ticker].ticker,
|
192
|
+
min(self._open_positions[ticker].size, abs(size)),
|
193
|
+
self._open_positions[ticker].price,
|
194
|
+
self._open_positions[ticker].datetime,
|
195
|
+
price,
|
196
|
+
datetime,
|
197
|
+
),
|
198
|
+
)
|
199
|
+
# if short position is closed/reduced
|
200
|
+
else:
|
201
|
+
self._closed_positions.append(
|
202
|
+
ClosedPosition(
|
203
|
+
self._open_positions[ticker].ticker,
|
204
|
+
max(self._open_positions[ticker].size, -size),
|
205
|
+
price,
|
206
|
+
datetime,
|
207
|
+
self._open_positions[ticker].price,
|
208
|
+
self._open_positions[ticker].datetime,
|
209
|
+
),
|
210
|
+
)
|
211
|
+
|
212
|
+
def _update_open_positions(
|
213
|
+
self, ticker: str, size: float, price: float, datetime: datetime
|
214
|
+
):
|
215
|
+
"""
|
216
|
+
Updates the open positions for a given ticker.
|
217
|
+
|
218
|
+
If the ticker already exists in the open positions, it updates the size, price,
|
219
|
+
and datetime based on the new transaction. If the size of the position becomes
|
220
|
+
zero, the position is removed. If the ticker does not exist, a new open position
|
221
|
+
is created.
|
222
|
+
|
223
|
+
Args:
|
224
|
+
ticker (str): The ticker symbol of the asset.
|
225
|
+
size (float): The size of the position.
|
226
|
+
price (float): The price at which the position was opened or updated.
|
227
|
+
datetime (datetime): The datetime when the position was opened or updated.
|
228
|
+
"""
|
229
|
+
if ticker in self._open_positions:
|
230
|
+
if size + self._open_positions[ticker].size == 0.0:
|
231
|
+
self._open_positions.pop(ticker)
|
232
|
+
else:
|
233
|
+
open_position_size = self._open_positions[ticker].size + size
|
234
|
+
open_position_price = price
|
235
|
+
open_position_datetime = datetime
|
236
|
+
|
237
|
+
if size * self._open_positions[ticker].size > 0.0:
|
238
|
+
open_position_price = (
|
239
|
+
self._open_positions[ticker].size
|
240
|
+
* self._open_positions[ticker].price
|
241
|
+
+ size * price
|
242
|
+
) / (self._open_positions[ticker].size + size)
|
243
|
+
open_position_datetime = self._open_positions[ticker].datetime
|
244
|
+
elif abs(self._open_positions[ticker].size) > abs(size):
|
245
|
+
open_position_datetime = self._open_positions[ticker].datetime
|
246
|
+
open_position_price = self._open_positions[ticker].price
|
247
|
+
self._open_positions[ticker] = OpenPosition(
|
248
|
+
ticker,
|
249
|
+
open_position_size,
|
250
|
+
open_position_price,
|
251
|
+
open_position_datetime,
|
252
|
+
)
|
253
|
+
else:
|
254
|
+
self._open_positions[ticker] = OpenPosition(
|
255
|
+
ticker,
|
256
|
+
size,
|
257
|
+
price,
|
258
|
+
datetime,
|
259
|
+
)
|
260
|
+
|
261
|
+
def _update_cash(self, order: Order, price: float):
|
262
|
+
"""
|
263
|
+
Updates the cash balance based on the given order and price, accounting for the
|
264
|
+
order size, price, and fees.
|
265
|
+
|
266
|
+
Args:
|
267
|
+
order (Order): The order object containing the size of the order.
|
268
|
+
price (float): The price at which the order is executed.
|
269
|
+
"""
|
270
|
+
if order.size > 0.0:
|
271
|
+
self._cash -= order.size * price * (1.0 + self._fees)
|
272
|
+
else:
|
273
|
+
self._cash -= order.size * price * (1.0 - self._fees)
|
274
|
+
|
275
|
+
def _check_long_only_condition(self, order: Order, datetime: datetime):
|
276
|
+
size = order.size
|
277
|
+
if order.ticker in self._open_positions:
|
278
|
+
size += self._open_positions[order.ticker].size
|
279
|
+
|
280
|
+
if size < 0.0:
|
281
|
+
raise ValueError(
|
282
|
+
f"Short selling is not allowed for {order.ticker} on {datetime}."
|
283
|
+
)
|
284
|
+
|
285
|
+
def _execute_order(
|
286
|
+
self,
|
287
|
+
order: Order,
|
288
|
+
bar: pd.DataFrame,
|
289
|
+
datetime: datetime,
|
290
|
+
) -> bool:
|
291
|
+
"""
|
292
|
+
Executes an order based on the provided bar data and datetime.
|
293
|
+
|
294
|
+
Args:
|
295
|
+
order (Order): The order to be executed.
|
296
|
+
bar (pd.DataFrame): The bar data containing price information.
|
297
|
+
datetime (datetime): The datetime at which the order is executed.
|
298
|
+
|
299
|
+
Returns:
|
300
|
+
bool: True if the order was successfully executed, False otherwise.
|
301
|
+
|
302
|
+
Raises:
|
303
|
+
ValueError: If the long-only condition is violated.
|
304
|
+
"""
|
305
|
+
ticker = order.ticker
|
306
|
+
|
307
|
+
if order.size == 0.0:
|
308
|
+
return False
|
309
|
+
|
310
|
+
if self._long_only:
|
311
|
+
self._check_long_only_condition(order, datetime)
|
312
|
+
|
313
|
+
price = self._get_price_for_order(order, bar)
|
314
|
+
|
315
|
+
# if the order is a limit order and cannot be filled, return
|
316
|
+
if price is None:
|
317
|
+
return False
|
318
|
+
|
319
|
+
# update cash for long and short positions
|
320
|
+
self._update_cash(order, price)
|
321
|
+
|
322
|
+
self._update_closed_positions(ticker, order.size, price, datetime)
|
323
|
+
|
324
|
+
self._update_open_positions(ticker, order.size, price, datetime)
|
325
|
+
|
326
|
+
return True
|
327
|
+
|
328
|
+
def update(
|
329
|
+
self,
|
330
|
+
next_bar: pd.DataFrame,
|
331
|
+
next_datetime: pd.Timestamp,
|
332
|
+
):
|
333
|
+
"""
|
334
|
+
Updates the broker's state with the next trading bar and executes pending
|
335
|
+
orders.
|
336
|
+
|
337
|
+
This method performs several key operations:
|
338
|
+
1. Updates current and previous bar/date references
|
339
|
+
2. Applies short fees for short positions if not in long-only mode
|
340
|
+
3. Sells assets that are no longer in the universe
|
341
|
+
4. Processes pending buy/sell orders
|
342
|
+
5. Updates trading history statistics
|
343
|
+
|
344
|
+
Args:
|
345
|
+
next_bar (pd.DataFrame): The next trading bar data containing at minimum
|
346
|
+
'close' prices for assets
|
347
|
+
next_datetime (pd.Timestamp): The timestamp for the next trading bar
|
348
|
+
|
349
|
+
Notes:
|
350
|
+
- Short fees are calculated using the current bar's closing price
|
351
|
+
- Assets outside the universe are sold at the previous bar's closing price
|
352
|
+
- Orders that cannot be executed due to missing data are skipped with a
|
353
|
+
warning
|
354
|
+
- Good-till-cancel orders that aren't filled are retained for the next bar
|
355
|
+
"""
|
356
|
+
self._previous_bar = self._current_bar
|
357
|
+
self._previous_datetime = self._current_datetime
|
358
|
+
self._current_bar = next_bar
|
359
|
+
self._current_datetime = next_datetime
|
360
|
+
|
361
|
+
# consider short fees
|
362
|
+
if not self._long_only:
|
363
|
+
for ticker in self._open_positions.keys():
|
364
|
+
if self._open_positions[ticker].size < 0.0:
|
365
|
+
price = (
|
366
|
+
self._current_bar.loc[ticker, "close"]
|
367
|
+
if ticker in self._current_bar.index
|
368
|
+
else self._previous_bar.loc[ticker, "close"]
|
369
|
+
)
|
370
|
+
self._cash += (
|
371
|
+
self._open_positions[ticker].size
|
372
|
+
* price
|
373
|
+
* self._daily_short_fee_rate
|
374
|
+
)
|
375
|
+
|
376
|
+
# sell assets out of universe, we use close price of previous bar, since this is
|
377
|
+
# the last price we know
|
378
|
+
ticker_out_of_universe = set()
|
379
|
+
if self._previous_bar.empty:
|
380
|
+
ticker_out_of_universe = set(self._open_positions.keys()) - set(
|
381
|
+
self._current_bar.index
|
382
|
+
)
|
383
|
+
for ticker in ticker_out_of_universe:
|
384
|
+
self._execute_order(
|
385
|
+
Order(ticker, -self._open_positions[ticker].size, OrderType.CLOSE),
|
386
|
+
self._previous_bar,
|
387
|
+
self._previous_datetime,
|
388
|
+
)
|
389
|
+
|
390
|
+
# buy and sell assets
|
391
|
+
remaining_open_orders = []
|
392
|
+
ticker_not_available = set(
|
393
|
+
[open_order.ticker for open_order in self._open_orders]
|
394
|
+
) - set(self._current_bar.index)
|
395
|
+
for open_order in self._open_orders:
|
396
|
+
if open_order.ticker in ticker_not_available:
|
397
|
+
if open_order.size > 0:
|
398
|
+
print(
|
399
|
+
f"{open_order.ticker} could not be bought on {self._current_datetime}." # noqa: E501
|
400
|
+
)
|
401
|
+
else:
|
402
|
+
print(
|
403
|
+
f"{open_order.ticker} could not be sold on {self._current_datetime}." # noqa: E501
|
404
|
+
)
|
405
|
+
continue
|
406
|
+
if (
|
407
|
+
not self._execute_order(
|
408
|
+
open_order, self._current_bar, self._current_datetime
|
409
|
+
)
|
410
|
+
and open_order.good_till_cancel
|
411
|
+
):
|
412
|
+
remaining_open_orders.append(open_order)
|
413
|
+
|
414
|
+
# Retain orders that are good till cancel and were not filled for the next bar
|
415
|
+
self._open_orders = remaining_open_orders
|
416
|
+
|
417
|
+
# update stats
|
418
|
+
self._update_history()
|
419
|
+
|
420
|
+
def liquidate_positions(self):
|
421
|
+
"""
|
422
|
+
Close all open positions in the broker by executing CLOSE orders.
|
423
|
+
|
424
|
+
The method iterates through all open positions and creates close orders with
|
425
|
+
opposite size to the current position size, effectively liquidating all
|
426
|
+
holdings.
|
427
|
+
All orders are executed at the current bar's timestamp.
|
428
|
+
|
429
|
+
No parameters are needed as it operates on the broker's internal state.
|
430
|
+
|
431
|
+
Returns:
|
432
|
+
None
|
433
|
+
"""
|
434
|
+
for ticker in [
|
435
|
+
ticker for ticker in self._open_positions.keys()
|
436
|
+
]: # open_positions is modified during iteration
|
437
|
+
self._execute_order(
|
438
|
+
Order(ticker, -self._open_positions[ticker].size, OrderType.CLOSE),
|
439
|
+
self._current_bar,
|
440
|
+
self._current_datetime,
|
441
|
+
)
|
442
|
+
|
443
|
+
def place_order(self, order: Order):
|
444
|
+
"""
|
445
|
+
Places a new order in the broker's open orders list.
|
446
|
+
|
447
|
+
Args:
|
448
|
+
order (Order): The order object to be placed in the open orders.
|
449
|
+
|
450
|
+
Note:
|
451
|
+
The order is appended to the internal _open_orders list and remains there
|
452
|
+
until it is either executed or cancelled.
|
453
|
+
"""
|
454
|
+
self._open_orders.append(order)
|
455
|
+
|
456
|
+
@property
|
457
|
+
def short_position_value(self) -> float:
|
458
|
+
"""
|
459
|
+
Gets the total market value of all short positions including fees.
|
460
|
+
|
461
|
+
Calculates the sum of (price * quantity) for all positions with negative size,
|
462
|
+
using the most recent closing price available. Transaction fees are included
|
463
|
+
in the final value.
|
464
|
+
|
465
|
+
Returns:
|
466
|
+
float: Total value of short positions (negative number), including fees.
|
467
|
+
"""
|
468
|
+
value = 0.0
|
469
|
+
for ticker in self._open_positions.keys():
|
470
|
+
if self._open_positions[ticker].size < 0.0:
|
471
|
+
price = (
|
472
|
+
self._current_bar.loc[ticker, "close"]
|
473
|
+
if ticker in self._current_bar.index
|
474
|
+
else self._previous_bar.loc[ticker, "close"]
|
475
|
+
)
|
476
|
+
value += price * self._open_positions[ticker].size * (1.0 + self._fees)
|
477
|
+
return value
|
478
|
+
|
479
|
+
@property
|
480
|
+
def long_position_value(self) -> float:
|
481
|
+
"""
|
482
|
+
Gets the total market value of all long positions after fees.
|
483
|
+
|
484
|
+
Calculates the sum of (price * quantity) for all positions with positive size,
|
485
|
+
using the most recent closing price available. Transaction fees are deducted
|
486
|
+
from the final value to reflect net liquidation value.
|
487
|
+
|
488
|
+
Returns:
|
489
|
+
float: Total value of long positions (positive number), net of fees.
|
490
|
+
"""
|
491
|
+
value = 0.0
|
492
|
+
for ticker in self._open_positions.keys():
|
493
|
+
if self._open_positions[ticker].size > 0.0:
|
494
|
+
price = (
|
495
|
+
self._current_bar.loc[ticker, "close"]
|
496
|
+
if ticker in self._current_bar.index
|
497
|
+
else self._previous_bar.loc[ticker, "close"]
|
498
|
+
)
|
499
|
+
value += price * self._open_positions[ticker].size * (1.0 - self._fees)
|
500
|
+
return value
|
501
|
+
|
502
|
+
@property
|
503
|
+
def portfolio_value(self) -> float:
|
504
|
+
"""
|
505
|
+
Gets the total portfolio value including cash and all positions.
|
506
|
+
|
507
|
+
Calculates net portfolio value by summing:
|
508
|
+
- Available cash balance
|
509
|
+
- Long positions market value (less fees)
|
510
|
+
- Short positions market value (including fees)
|
511
|
+
|
512
|
+
Returns:
|
513
|
+
float: Total portfolio value in base currency.
|
514
|
+
"""
|
515
|
+
return self._cash + self.long_position_value + self.short_position_value
|
516
|
+
|
517
|
+
@property
|
518
|
+
def open_positions(self) -> Dict[str, OpenPosition]:
|
519
|
+
"""Gets a dictionary of currently active trading positions.
|
520
|
+
|
521
|
+
Maps ticker symbols to OpenPosition objects containing:
|
522
|
+
- ticker: Financial instrument identifier
|
523
|
+
- size: Position size (positive=long, negative=short)
|
524
|
+
- price: Average entry price
|
525
|
+
- datetime: Position opening timestamp
|
526
|
+
|
527
|
+
Returns:
|
528
|
+
Dict[str, OpenPosition]: Dictionary mapping ticker symbols to positions.
|
529
|
+
Returns a defensive copy to prevent external modifications.
|
530
|
+
"""
|
531
|
+
return self._open_positions.copy()
|
532
|
+
|
533
|
+
@property
|
534
|
+
def closed_positions(self) -> List[ClosedPosition]:
|
535
|
+
"""
|
536
|
+
Gets a list of all completed trades.
|
537
|
+
|
538
|
+
Each ClosedPosition contains the complete trade lifecycle:
|
539
|
+
- Entry price and timestamp
|
540
|
+
- Exit price and timestamp
|
541
|
+
- Position size and direction
|
542
|
+
- Ticker symbol
|
543
|
+
|
544
|
+
Returns a defensive copy to prevent external modifications.
|
545
|
+
|
546
|
+
Returns:
|
547
|
+
List[ClosedPosition]: Chronological list of completed trades.
|
548
|
+
"""
|
549
|
+
return self._closed_positions.copy()
|
550
|
+
|
551
|
+
@property
|
552
|
+
def history(self) -> Dict[str, List[float]]:
|
553
|
+
"""Gets the historical performance metrics dictionary.
|
554
|
+
|
555
|
+
Contains time series data tracking portfolio metrics:
|
556
|
+
- 'cash': Available cash balance
|
557
|
+
- 'portfolio': Total portfolio value
|
558
|
+
- 'short_value': Value of short positions
|
559
|
+
- 'long_value': Value of long positions
|
560
|
+
- 'benchmark': Benchmark performance if specified
|
561
|
+
|
562
|
+
Returns:
|
563
|
+
Dict[str, List[float]]: Dictionary mapping metric names to value histories.
|
564
|
+
Returns a defensive copy to prevent external modifications.
|
565
|
+
"""
|
566
|
+
return self._history.copy()
|
567
|
+
|
568
|
+
@property
|
569
|
+
def cash(self) -> float:
|
570
|
+
"""Gets the current available cash balance.
|
571
|
+
|
572
|
+
Represents uninvested capital that can be used for new positions. Updated after
|
573
|
+
each trade to reflect fees and position changes.
|
574
|
+
|
575
|
+
Returns:
|
576
|
+
float: Available cash balance in base currency.
|
577
|
+
"""
|
578
|
+
return self._cash
|
579
|
+
|
580
|
+
@property
|
581
|
+
def benchmark(self) -> str:
|
582
|
+
"""Gets the benchmark symbol used for performance comparison.
|
583
|
+
|
584
|
+
The benchmark tracks a reference asset (e.g., market index) to evaluate relative
|
585
|
+
strategy performance. Returns None if no benchmark was specified.
|
586
|
+
|
587
|
+
Returns:
|
588
|
+
str: Ticker symbol of the benchmark instrument.
|
589
|
+
"""
|
590
|
+
return self._benchmark
|
kissbt/engine.py
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
import pandas as pd
|
2
|
+
|
3
|
+
from kissbt.broker import Broker
|
4
|
+
from kissbt.strategy import Strategy
|
5
|
+
|
6
|
+
|
7
|
+
class Engine:
|
8
|
+
"""Coordinates execution of a trading strategy using broker actions.
|
9
|
+
|
10
|
+
This class drives the main loop that processes market data, updates the
|
11
|
+
broker's state, and calls the strategy logic for each segment of data.
|
12
|
+
|
13
|
+
Args:
|
14
|
+
broker (Broker): The Broker instance for managing trades and positions.
|
15
|
+
strategy (Strategy): The trading strategy to be applied to the data.
|
16
|
+
"""
|
17
|
+
|
18
|
+
def __init__(self, broker: Broker, strategy: Strategy) -> None:
|
19
|
+
self.broker = broker
|
20
|
+
self.strategy = strategy
|
21
|
+
|
22
|
+
def run(self, data: pd.DataFrame) -> None:
|
23
|
+
for current_date, current_data in data.groupby("date"):
|
24
|
+
current_data.index = current_data.index.droplevel("date")
|
25
|
+
|
26
|
+
self.broker.update(current_data, current_date)
|
27
|
+
self.strategy(current_data, current_date)
|
28
|
+
|
29
|
+
self.broker.liquidate_positions()
|
kissbt/entities.py
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from enum import Enum
|
3
|
+
|
4
|
+
import pandas as pd
|
5
|
+
|
6
|
+
|
7
|
+
class OrderType(Enum):
|
8
|
+
OPEN = "open"
|
9
|
+
CLOSE = "close"
|
10
|
+
LIMIT = "limit"
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass(frozen=True)
|
14
|
+
class Order:
|
15
|
+
"""
|
16
|
+
Trading order representation. Immutable to ensure data integrity and thread safety.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
ticker (str): Ticker symbol of the traded asset
|
20
|
+
size (float): Order size (positive for buy, negative for sell)
|
21
|
+
order_type (OrderType, optional): Order type, defaults to OPEN
|
22
|
+
limit (float | None, optional): Limit price if applicable, defaults to None
|
23
|
+
good_till_cancel (bool, optional): Order validity flag, defaults to False
|
24
|
+
"""
|
25
|
+
|
26
|
+
ticker: str
|
27
|
+
size: float
|
28
|
+
order_type: OrderType = OrderType.OPEN
|
29
|
+
limit: float | None = None
|
30
|
+
good_till_cancel: bool = False
|
31
|
+
|
32
|
+
|
33
|
+
@dataclass(frozen=True)
|
34
|
+
class OpenPosition:
|
35
|
+
"""
|
36
|
+
Immutable representation of an open trading position.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
ticker (str): Financial instrument identifier
|
40
|
+
size (float): Position size (positive for long, negative for short)
|
41
|
+
price (float): Opening price of the position
|
42
|
+
datetime (datetime): Position opening timestamp
|
43
|
+
"""
|
44
|
+
|
45
|
+
ticker: str
|
46
|
+
size: float
|
47
|
+
price: float
|
48
|
+
datetime: pd.Timestamp
|
49
|
+
|
50
|
+
|
51
|
+
@dataclass(frozen=True)
|
52
|
+
class ClosedPosition:
|
53
|
+
"""
|
54
|
+
Immutable representation of a completed trading transaction.
|
55
|
+
|
56
|
+
Args:
|
57
|
+
ticker (str): Financial instrument identifier
|
58
|
+
size (float): Position size (positive for long, negative for short)
|
59
|
+
purchase_price (float): Entry price of the position
|
60
|
+
purchase_datetime (pd.Timestamp): Position entry timestamp
|
61
|
+
selling_price (float): Exit price of the position
|
62
|
+
selling_datetime (pd.Timestamp): Position exit timestamp
|
63
|
+
"""
|
64
|
+
|
65
|
+
ticker: str
|
66
|
+
size: float
|
67
|
+
purchase_price: float
|
68
|
+
purchase_datetime: pd.Timestamp
|
69
|
+
selling_price: float
|
70
|
+
selling_datetime: pd.Timestamp
|