msd-backtest 0.1.0__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.
- mbt/__init__.py +28 -0
- mbt/account.py +446 -0
- mbt/data_provider.py +134 -0
- mbt/run/__init__.py +5 -0
- mbt/run/backend.py +95 -0
- mbt/run/cli.py +192 -0
- mbt/run/msd.py +76 -0
- mbt/run/report.py +313 -0
- mbt/run/review.py +67 -0
- mbt/run/runner.py +85 -0
- mbt/run/server.py +103 -0
- mbt/select/__init__.py +0 -0
- mbt/strategy.py +85 -0
- msd_backtest-0.1.0.dist-info/METADATA +182 -0
- msd_backtest-0.1.0.dist-info/RECORD +17 -0
- msd_backtest-0.1.0.dist-info/WHEEL +5 -0
- msd_backtest-0.1.0.dist-info/top_level.txt +1 -0
mbt/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .data_provider import DataProvider
|
|
2
|
+
from .strategy import Context, Strategy
|
|
3
|
+
from .account import Account, Operation, Position, Fee
|
|
4
|
+
from .account import (
|
|
5
|
+
ACTION_BUY,
|
|
6
|
+
ACTION_BUY_NO_CASH,
|
|
7
|
+
ACTION_SELL,
|
|
8
|
+
ACTION_SELL_NO_HOLD,
|
|
9
|
+
ACTION_KEEP,
|
|
10
|
+
)
|
|
11
|
+
from .run.runner import Runner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Context",
|
|
16
|
+
"Strategy",
|
|
17
|
+
"Runner",
|
|
18
|
+
"Account",
|
|
19
|
+
"Fee",
|
|
20
|
+
"DataProvider",
|
|
21
|
+
"Operation",
|
|
22
|
+
"Position",
|
|
23
|
+
"ACTION_BUY",
|
|
24
|
+
"ACTION_BUY_NO_CASH",
|
|
25
|
+
"ACTION_SELL",
|
|
26
|
+
"ACTION_SELL_NO_HOLD",
|
|
27
|
+
"ACTION_KEEP",
|
|
28
|
+
]
|
mbt/account.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from .data_provider import DataProvider
|
|
5
|
+
import numpy as np
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ACTION_KEEP = 0
|
|
10
|
+
|
|
11
|
+
ACTION_BUY = 1
|
|
12
|
+
ACTION_BUY_PERCENT = 3
|
|
13
|
+
ACTION_BUY_NO_CASH = 5
|
|
14
|
+
|
|
15
|
+
ACTION_SELL = 2
|
|
16
|
+
ACTION_SELL_PERCENT = 4
|
|
17
|
+
ACTION_SELL_NO_HOLD = 6
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Position:
|
|
23
|
+
"""Position is a position of a stock"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, shares: float, price: float):
|
|
26
|
+
self.shares = shares
|
|
27
|
+
self.price = price
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def value(self) -> float:
|
|
31
|
+
return self.shares * self.price
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def zero() -> "Position":
|
|
35
|
+
return Position(0, 0)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def from_cash(cash: float, price: float, unit: int = 100) -> "Position":
|
|
39
|
+
if price == 0:
|
|
40
|
+
return Position.zero()
|
|
41
|
+
shares = int(cash / price)
|
|
42
|
+
shares = shares - shares % unit # round down to unit
|
|
43
|
+
return Position(float(shares), price)
|
|
44
|
+
|
|
45
|
+
def __add__(self, other: "Position") -> "Position":
|
|
46
|
+
value = self.value + other.value
|
|
47
|
+
shares = self.shares + other.shares
|
|
48
|
+
if shares <= 0:
|
|
49
|
+
return Position.zero()
|
|
50
|
+
return Position(shares, value / shares)
|
|
51
|
+
|
|
52
|
+
def __sub__(self, other: "Position") -> "Position":
|
|
53
|
+
value = self.value - other.value
|
|
54
|
+
shares = self.shares - other.shares
|
|
55
|
+
if shares <= 0:
|
|
56
|
+
return Position.zero()
|
|
57
|
+
return Position(shares, value / shares)
|
|
58
|
+
|
|
59
|
+
def __str__(self):
|
|
60
|
+
return f"Position(shares={self.shares}, price={self.price})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Operation:
|
|
64
|
+
"""Operation is a operation in a day of a backtest account"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, action: int, position: Position | None):
|
|
67
|
+
self.action = action
|
|
68
|
+
self.position = position
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def keep(price: float) -> "Operation":
|
|
72
|
+
return Operation(ACTION_KEEP, Position(0, price))
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def buy(position: Position) -> "Operation":
|
|
76
|
+
return Operation(ACTION_BUY, position)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def buy_percent(percent: float, price: float) -> "Operation":
|
|
80
|
+
return Operation(ACTION_BUY_PERCENT, Position(percent, price))
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def sell(position: Position) -> "Operation":
|
|
84
|
+
return Operation(ACTION_SELL, position)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def sell_percent(percent: float, price: float) -> "Operation":
|
|
88
|
+
return Operation(ACTION_SELL_PERCENT, Position(percent, price))
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def buy_no_cash(position: Position) -> "Operation":
|
|
92
|
+
return Operation(ACTION_BUY_NO_CASH, position)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def sell_no_hold(position: Position) -> "Operation":
|
|
96
|
+
return Operation(ACTION_SELL_NO_HOLD, position)
|
|
97
|
+
|
|
98
|
+
def __str__(self):
|
|
99
|
+
kind = "None"
|
|
100
|
+
if self.action == ACTION_KEEP:
|
|
101
|
+
kind = "Keep"
|
|
102
|
+
elif self.action == ACTION_BUY:
|
|
103
|
+
kind = "Buy"
|
|
104
|
+
elif self.action == ACTION_BUY_PERCENT:
|
|
105
|
+
kind = "BuyPercent"
|
|
106
|
+
elif self.action == ACTION_SELL:
|
|
107
|
+
kind = "Sell"
|
|
108
|
+
elif self.action == ACTION_SELL_PERCENT:
|
|
109
|
+
kind = "SellPercent"
|
|
110
|
+
elif self.action == ACTION_BUY_NO_CASH:
|
|
111
|
+
kind = "BuyNoCash"
|
|
112
|
+
elif self.action == ACTION_SELL_NO_HOLD:
|
|
113
|
+
kind = "SellNoHold"
|
|
114
|
+
if self.position is None:
|
|
115
|
+
return f"{kind}"
|
|
116
|
+
return f"{kind}({self.position})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class FeeItem:
|
|
121
|
+
ts: int = 0
|
|
122
|
+
commission_buy: float = 0.0
|
|
123
|
+
min_commission_buy: float = 0.0
|
|
124
|
+
tax_buy: float = 0.0
|
|
125
|
+
transaction_fee_buy: float = 0.0
|
|
126
|
+
commission_sell: float = 0.0
|
|
127
|
+
min_commission_sell: float = 0.0
|
|
128
|
+
tax_sell: float = 0.0
|
|
129
|
+
transaction_fee_sell: float = 0.0
|
|
130
|
+
|
|
131
|
+
def buy(self, value: float) -> float:
|
|
132
|
+
if value == 0:
|
|
133
|
+
return 0.0
|
|
134
|
+
|
|
135
|
+
commission = value * self.commission_buy
|
|
136
|
+
min_commission = self.min_commission_buy
|
|
137
|
+
tax = value * self.tax_buy
|
|
138
|
+
transaction_fee = self.transaction_fee_buy
|
|
139
|
+
return max(commission, min_commission) + tax + transaction_fee
|
|
140
|
+
|
|
141
|
+
def sell(self, value: float) -> float:
|
|
142
|
+
if value == 0:
|
|
143
|
+
return 0.0
|
|
144
|
+
commission = value * self.commission_sell
|
|
145
|
+
min_commission = self.min_commission_sell
|
|
146
|
+
tax = value * self.tax_sell
|
|
147
|
+
transaction_fee = self.transaction_fee_sell
|
|
148
|
+
return max(commission, min_commission) + tax + transaction_fee
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Fee:
|
|
152
|
+
def __init__(self, config: list[FeeItem] | list[str] | str | bytes | None = None):
|
|
153
|
+
def build_fee_item(x):
|
|
154
|
+
return FeeItem(**x)
|
|
155
|
+
|
|
156
|
+
if isinstance(config, list):
|
|
157
|
+
if len(config) > 0 and isinstance(config[0], str):
|
|
158
|
+
self.config = [json.loads(x, object_hook=build_fee_item) for x in config] # type: ignore
|
|
159
|
+
self.config.sort(key=lambda x: x.ts)
|
|
160
|
+
else:
|
|
161
|
+
self.config = config
|
|
162
|
+
elif isinstance(config, str):
|
|
163
|
+
p = Path(config)
|
|
164
|
+
|
|
165
|
+
if p.is_file():
|
|
166
|
+
self.config = json.load(p.open("r"), object_hook=build_fee_item)
|
|
167
|
+
else:
|
|
168
|
+
self.config = json.loads(config, object_hook=build_fee_item)
|
|
169
|
+
elif isinstance(config, bytes):
|
|
170
|
+
self.config = json.loads(config.decode("utf-8"), object_hook=build_fee_item)
|
|
171
|
+
else:
|
|
172
|
+
self.config = [FeeItem()]
|
|
173
|
+
|
|
174
|
+
# add a default fee item at the beginning when no fee item is provided
|
|
175
|
+
if len(self.config) == 0:
|
|
176
|
+
self.config = [FeeItem()]
|
|
177
|
+
|
|
178
|
+
def get(self, ts: int) -> FeeItem:
|
|
179
|
+
"""
|
|
180
|
+
find the last fee item whose ts is less than or equal to the given ts
|
|
181
|
+
Args:
|
|
182
|
+
ts: timestamp
|
|
183
|
+
Returns:
|
|
184
|
+
fee item
|
|
185
|
+
"""
|
|
186
|
+
for i in range(len(self.config)):
|
|
187
|
+
if self.config[i].ts > ts:
|
|
188
|
+
if i == 0:
|
|
189
|
+
return self.config[0]
|
|
190
|
+
return self.config[i - 1]
|
|
191
|
+
return self.config[-1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Record:
|
|
195
|
+
"""Record is a record of a backtest account"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
ts: int,
|
|
200
|
+
operation: Operation,
|
|
201
|
+
cash: float,
|
|
202
|
+
hold_profit: float,
|
|
203
|
+
position: Position,
|
|
204
|
+
deducted: float,
|
|
205
|
+
):
|
|
206
|
+
"""
|
|
207
|
+
Args:
|
|
208
|
+
ts: timestamp of the record
|
|
209
|
+
operation: incoming operation of the record
|
|
210
|
+
cash: cash after operation
|
|
211
|
+
hold_profit: profit when position was bought
|
|
212
|
+
position: position after operation
|
|
213
|
+
deducted: deducted commission and other fees
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
"""the date of the record"""
|
|
217
|
+
self.ts = ts
|
|
218
|
+
"""incoming operation of the record"""
|
|
219
|
+
self.operation = operation
|
|
220
|
+
"""cash after operation"""
|
|
221
|
+
self.cash = cash
|
|
222
|
+
"""profit when position was bought"""
|
|
223
|
+
self.hold_profit = hold_profit
|
|
224
|
+
"""position after operation"""
|
|
225
|
+
self.position = position
|
|
226
|
+
"""value of position"""
|
|
227
|
+
self.value = self.position.shares * self.position.price
|
|
228
|
+
"""total assets"""
|
|
229
|
+
self.assets = self.cash + self.value
|
|
230
|
+
"""deducted commission and other fees"""
|
|
231
|
+
self.deducted = deducted
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Account:
|
|
235
|
+
"""Account is a backtest account, with initial cash and data provider
|
|
236
|
+
|
|
237
|
+
data_provider used to get the actions, price, dividend, commission, etc.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
initial_cash: float,
|
|
243
|
+
dp: DataProvider,
|
|
244
|
+
/,
|
|
245
|
+
group: int = 0,
|
|
246
|
+
fee: Fee | None = None,
|
|
247
|
+
unit: int = 100,
|
|
248
|
+
slippage: float = 0.0,
|
|
249
|
+
):
|
|
250
|
+
"""
|
|
251
|
+
Args:
|
|
252
|
+
initial_cash: initial cash of the account
|
|
253
|
+
dp: data provider of the account, the data kinds will be used are:
|
|
254
|
+
- "price": price of the stock, without dividend
|
|
255
|
+
- "transfer_shares": transfers per share in dividend
|
|
256
|
+
- "dividend": dividend per share in dividend
|
|
257
|
+
- "right_shares": right share per share in dividend
|
|
258
|
+
- "right_price": right price per share in dividend
|
|
259
|
+
unit: trading unit, default is 100
|
|
260
|
+
slippage: slippage of the buy and sell operations, for buy: deal_price = price * (1 + slippage), for sell: deal_price = price * (1 - slippage)
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
"""initial cash of the account"""
|
|
265
|
+
self.initial_cash = initial_cash
|
|
266
|
+
self.cash = initial_cash
|
|
267
|
+
"""data provider of the account"""
|
|
268
|
+
self.dp = dp
|
|
269
|
+
"""trading unit"""
|
|
270
|
+
self.unit = unit
|
|
271
|
+
"""position of the account"""
|
|
272
|
+
self.position = Position(0, 0)
|
|
273
|
+
"""assets after first buy operation"""
|
|
274
|
+
self.per_assets = 0.0
|
|
275
|
+
"""deducted commission and other fees"""
|
|
276
|
+
self.deducted = 0.0
|
|
277
|
+
"""records of the account"""
|
|
278
|
+
self.records = []
|
|
279
|
+
"""fee calculator"""
|
|
280
|
+
self.fee = fee if fee is not None else Fee()
|
|
281
|
+
"""slippage of the buy and sell operations"""
|
|
282
|
+
self.slippage = slippage
|
|
283
|
+
"""group index"""
|
|
284
|
+
self.group = group
|
|
285
|
+
|
|
286
|
+
s = self.group * dp.bars
|
|
287
|
+
e = s + dp.bars
|
|
288
|
+
"""dates of current group"""
|
|
289
|
+
self.dates = dp.dates[s:e].astype('datetime64[s]').astype(int)
|
|
290
|
+
"""history of assets for current group"""
|
|
291
|
+
self.assets = dp.all("assets", create_if_not_exist=np.float64)[s:e]
|
|
292
|
+
"""history of rate of return for current group"""
|
|
293
|
+
self.ror = dp.all("ror", create_if_not_exist=np.float64)[s:e]
|
|
294
|
+
"""history of rate of return since last clear"""
|
|
295
|
+
self.ror_hold = dp.all("ror_hold", create_if_not_exist=np.float64)[s:e]
|
|
296
|
+
|
|
297
|
+
self.actions = dp.all("actions", create_if_not_exist=np.int32)[s:e]
|
|
298
|
+
self.action_shares = dp.all("action_shares", create_if_not_exist=np.float64)[s:e]
|
|
299
|
+
|
|
300
|
+
"""corporate actions"""
|
|
301
|
+
self.dividends = dp.all("dividend")[s:e]
|
|
302
|
+
self.transfer_shares = dp.all("transfer_shares")[s:e]
|
|
303
|
+
self.right_shares = dp.all("right_shares")[s:e]
|
|
304
|
+
self.right_prices = dp.all("right_price")[s:e]
|
|
305
|
+
|
|
306
|
+
self.assets.fill(initial_cash)
|
|
307
|
+
self.ror.fill(0.0)
|
|
308
|
+
self.actions.fill(ACTION_KEEP)
|
|
309
|
+
self.action_shares.fill(0.0)
|
|
310
|
+
|
|
311
|
+
def clone(self, group: int = 0) -> "Account":
|
|
312
|
+
return Account(
|
|
313
|
+
self.cash,
|
|
314
|
+
self.dp,
|
|
315
|
+
group=group,
|
|
316
|
+
fee=self.fee,
|
|
317
|
+
unit=self.unit,
|
|
318
|
+
slippage=self.slippage,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def asset(self):
|
|
323
|
+
"""current asset"""
|
|
324
|
+
return self.position.value + self.cash
|
|
325
|
+
|
|
326
|
+
def do_operation(self, ts: int, operation: Operation):
|
|
327
|
+
"""
|
|
328
|
+
perform operation, add record to records
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
ts: timestamp of the record
|
|
332
|
+
operation: incoming operation of the record
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
if operation.position is None:
|
|
336
|
+
raise ValueError(f"invalid operation: {operation}")
|
|
337
|
+
|
|
338
|
+
fee = 0.0
|
|
339
|
+
if self.position.shares > 0:
|
|
340
|
+
dividend = self.dividends[ts]
|
|
341
|
+
transfer_shares = self.transfer_shares[ts]
|
|
342
|
+
right_share = self.right_shares[ts]
|
|
343
|
+
right_price = self.right_prices[ts]
|
|
344
|
+
|
|
345
|
+
if dividend > 0 or transfer_shares > 0 or right_share > 0:
|
|
346
|
+
self.cash += self.position.shares * dividend
|
|
347
|
+
self.cash -= self.position.shares * right_share * right_price
|
|
348
|
+
self.position.shares += self.position.shares * (transfer_shares + right_share)
|
|
349
|
+
|
|
350
|
+
if operation.action == ACTION_BUY:
|
|
351
|
+
operation, fee = self.do_buy(ts, operation.position)
|
|
352
|
+
elif operation.action == ACTION_BUY_PERCENT:
|
|
353
|
+
operation, fee = self.do_buy(
|
|
354
|
+
ts,
|
|
355
|
+
Position.from_cash(
|
|
356
|
+
self.cash * operation.position.shares, operation.position.price, self.unit
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
elif operation.action == ACTION_SELL:
|
|
360
|
+
operation, fee = self.do_sell(ts, operation.position)
|
|
361
|
+
elif operation.action == ACTION_SELL_PERCENT:
|
|
362
|
+
operation, fee = self.do_sell(
|
|
363
|
+
ts,
|
|
364
|
+
Position(
|
|
365
|
+
self.position.shares * operation.position.shares,
|
|
366
|
+
operation.position.price,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
elif operation.action == ACTION_KEEP:
|
|
370
|
+
self.position.price = operation.position.price
|
|
371
|
+
else:
|
|
372
|
+
raise ValueError(f"invalid operation: {operation}")
|
|
373
|
+
|
|
374
|
+
hold_profit = (
|
|
375
|
+
(self.asset - self.per_assets) / self.per_assets if self.per_assets > 0 else 0.0
|
|
376
|
+
)
|
|
377
|
+
self.records.append(
|
|
378
|
+
Record(ts, operation, self.cash, hold_profit, self.position, fee)
|
|
379
|
+
)
|
|
380
|
+
self.assets[ts] = self.asset
|
|
381
|
+
self.ror[ts] = self.asset / self.assets[0] - 1
|
|
382
|
+
self.ror_hold[ts] = hold_profit
|
|
383
|
+
self.actions[ts] = operation.action
|
|
384
|
+
self.action_shares[ts] = (
|
|
385
|
+
operation.position.shares if operation.position is not None else 0.0
|
|
386
|
+
)
|
|
387
|
+
logger.debug(
|
|
388
|
+
f"group: {self.group}, ts: {ts}, operation: {operation}, cash: {self.cash}, position: {self.position}, asset: {self.asset}, ror: {self.ror[ts]}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def do_buy(self, ts: int, position: Position) -> tuple[Operation, float]:
|
|
392
|
+
"""
|
|
393
|
+
perform buy operation
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
ts: timestamp of the record
|
|
397
|
+
position: position to buy
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
operation: because buy operation may be rejected or partially filled, return the actual operation
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
if self.cash == 0:
|
|
404
|
+
return Operation.buy_no_cash(Position(0, 0))
|
|
405
|
+
|
|
406
|
+
fee_item = self.fee.get(self.dates[ts])
|
|
407
|
+
fee = fee_item.buy(position.value)
|
|
408
|
+
if position.value + fee > self.cash:
|
|
409
|
+
position = Position.from_cash(self.cash - fee, position.price, self.unit)
|
|
410
|
+
|
|
411
|
+
if position.shares == 0:
|
|
412
|
+
return Operation.buy_no_cash(Position(0, 0)), 0.0
|
|
413
|
+
|
|
414
|
+
self.cash -= position.value + fee
|
|
415
|
+
self.position += position
|
|
416
|
+
fee = fee_item.buy(position.value)
|
|
417
|
+
self.deducted += fee
|
|
418
|
+
if self.per_assets == 0:
|
|
419
|
+
self.per_assets = self.asset
|
|
420
|
+
return Operation.buy(position), fee
|
|
421
|
+
|
|
422
|
+
def do_sell(self, ts: int, position: Position) -> tuple[Operation, float]:
|
|
423
|
+
"""
|
|
424
|
+
perform sell operation
|
|
425
|
+
Args:
|
|
426
|
+
ts: timestamp of the record
|
|
427
|
+
position: position to sell
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
operation: because sell operation may be rejected or partially filled, return the actual operation
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
if position.shares > self.position.shares:
|
|
434
|
+
position.shares = self.position.shares
|
|
435
|
+
|
|
436
|
+
if position.shares == 0:
|
|
437
|
+
return Operation.sell_no_hold(Position(0, 0)), 0.0
|
|
438
|
+
|
|
439
|
+
fee_item = self.fee.get(self.dates[ts])
|
|
440
|
+
fee = fee_item.sell(position.value)
|
|
441
|
+
self.cash += position.value - fee
|
|
442
|
+
self.position -= position
|
|
443
|
+
self.deducted += fee
|
|
444
|
+
if self.position.shares == 0:
|
|
445
|
+
self.per_assets = 0.0
|
|
446
|
+
return Operation.sell(position), fee
|
mbt/data_provider.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
import numpy as np
|
|
3
|
+
from numpy.typing import DTypeLike
|
|
4
|
+
|
|
5
|
+
MAX_N = 1_000_000_000
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NextHook(Protocol):
|
|
9
|
+
def __call__(self, i: int, groups: int, bars: int):
|
|
10
|
+
"""
|
|
11
|
+
Args:
|
|
12
|
+
i: current index
|
|
13
|
+
groups: number of groups, eg. number of symbols
|
|
14
|
+
bars: number of bars per group, eg. number of days
|
|
15
|
+
"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DataProvider:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
data: dict[str, np.ndarray],
|
|
23
|
+
/,
|
|
24
|
+
symbols: list[str] = ["ANY"],
|
|
25
|
+
ts_name: str = "ts",
|
|
26
|
+
next_hook: NextHook | None = None,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Args:
|
|
30
|
+
data: data to be used in the strategy, keys are data names, values are numpy arrays, each array must have the same length as dates and can be partitioned into groups of length bars
|
|
31
|
+
symbols: symbols of the account, each symbol has bars bars of data
|
|
32
|
+
ts_name: name of the time series data, default is 'ts', must be in data
|
|
33
|
+
next_hook: hook to be called for each bar, used to do some configuration before the next bar
|
|
34
|
+
"""
|
|
35
|
+
self.i = 0
|
|
36
|
+
self.symbols = symbols
|
|
37
|
+
self.data = data
|
|
38
|
+
self.ts_name = ts_name
|
|
39
|
+
self.dates = data[ts_name]
|
|
40
|
+
self.next_hook = next_hook
|
|
41
|
+
self.groups = len(self.symbols)
|
|
42
|
+
self.bars = len(self.dates) // self.groups
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def verify_data(symbols: list[str], data: dict[str, np.ndarray], ts_name: str):
|
|
46
|
+
"""verify the data
|
|
47
|
+
1. ts_name must be in data
|
|
48
|
+
2. all data must have the same length as dates
|
|
49
|
+
3. data length must be integer divisible by len(symbols)
|
|
50
|
+
"""
|
|
51
|
+
if ts_name not in data:
|
|
52
|
+
raise ValueError(f"data '{ts_name}' not found")
|
|
53
|
+
dates = data[ts_name]
|
|
54
|
+
if len(dates) % len(symbols) != 0:
|
|
55
|
+
raise ValueError("data length must be integer divisible by len(symbols)")
|
|
56
|
+
for kind in data:
|
|
57
|
+
if len(data[kind]) != len(dates):
|
|
58
|
+
raise ValueError(f"data '{kind}' has different length from dates")
|
|
59
|
+
|
|
60
|
+
def all(
|
|
61
|
+
self,
|
|
62
|
+
kind: str,
|
|
63
|
+
/,
|
|
64
|
+
create_if_not_exist: DTypeLike | None = None,
|
|
65
|
+
symbol: str | None = None,
|
|
66
|
+
) -> np.ndarray:
|
|
67
|
+
"""
|
|
68
|
+
return the all data of the kind from the first to the last
|
|
69
|
+
Args:
|
|
70
|
+
kind: kind of data
|
|
71
|
+
create_if_not_exist: if not None, create the data if it does not exist with the given dtype
|
|
72
|
+
symbol: symbol of the data, when provided, return the data of the symbol and tile it `groups` times
|
|
73
|
+
"""
|
|
74
|
+
if kind not in self.data:
|
|
75
|
+
if create_if_not_exist is not None:
|
|
76
|
+
self.data[kind] = np.zeros(len(self.dates), dtype=create_if_not_exist)
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"data '{kind}' not found")
|
|
79
|
+
if symbol is not None and self.groups > 1:
|
|
80
|
+
i = self.symbols.index(symbol)
|
|
81
|
+
return np.tile(self.data[kind][i * self.bars : (i + 1) * self.bars], self.groups)
|
|
82
|
+
else:
|
|
83
|
+
return self.data[kind]
|
|
84
|
+
|
|
85
|
+
def slice(self, kind: str, index: str | int) -> np.ndarray:
|
|
86
|
+
"""
|
|
87
|
+
return the slice of the data of the kind
|
|
88
|
+
Args:
|
|
89
|
+
kind: kind of data
|
|
90
|
+
index: index of the slice, can be symbol name or index
|
|
91
|
+
Returns:
|
|
92
|
+
np.ndarray: slice of the data
|
|
93
|
+
Raise:
|
|
94
|
+
ValueError: if the kind is not in the data or index is out of range
|
|
95
|
+
"""
|
|
96
|
+
if kind not in self.data:
|
|
97
|
+
raise ValueError(f"data '{kind}' not found")
|
|
98
|
+
if isinstance(index, str):
|
|
99
|
+
s, e = self.symbol_indices(index)
|
|
100
|
+
return self.data[kind][s : e]
|
|
101
|
+
else:
|
|
102
|
+
return self.data[kind][index: index + self.bars]
|
|
103
|
+
|
|
104
|
+
def symbol_indices(self, symbol: str) -> tuple[int, int]:
|
|
105
|
+
"""
|
|
106
|
+
return the indices of the symbol
|
|
107
|
+
Args:
|
|
108
|
+
symbol: symbol of the data
|
|
109
|
+
Returns:
|
|
110
|
+
tuple[int, int]: indices of the symbol
|
|
111
|
+
Raise:
|
|
112
|
+
ValueError: if the symbol is not in the data
|
|
113
|
+
"""
|
|
114
|
+
if symbol not in self.symbols:
|
|
115
|
+
raise ValueError(f"symbol '{symbol}' not found")
|
|
116
|
+
i = self.symbols.index(symbol)
|
|
117
|
+
return i * self.bars, (i + 1) * self.bars
|
|
118
|
+
|
|
119
|
+
def __len__(self):
|
|
120
|
+
return self.bars
|
|
121
|
+
|
|
122
|
+
def __iter__(self):
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __next__(self):
|
|
126
|
+
self.i += 1
|
|
127
|
+
if self.i >= self.bars:
|
|
128
|
+
raise StopIteration
|
|
129
|
+
if self.next_hook is not None:
|
|
130
|
+
self.next_hook(self.i, self.groups, self.bars)
|
|
131
|
+
return self.i
|
|
132
|
+
|
|
133
|
+
def reset(self):
|
|
134
|
+
self.i = 0
|
mbt/run/__init__.py
ADDED
mbt/run/backend.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from mbt import Strategy
|
|
2
|
+
from typing import IO
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RunRequest:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
strategy: str | IO | Strategy,
|
|
9
|
+
symbols: list[str],
|
|
10
|
+
/,
|
|
11
|
+
start: str,
|
|
12
|
+
end: str,
|
|
13
|
+
args: list | None = None,
|
|
14
|
+
):
|
|
15
|
+
self.strategy = RunRequest.load_strategy(strategy, args=args)
|
|
16
|
+
self.symbols = symbols
|
|
17
|
+
self.start = start
|
|
18
|
+
self.end = end
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def load_strategy(
|
|
22
|
+
strategy: str | IO | Strategy, args: list | None = None
|
|
23
|
+
) -> Strategy:
|
|
24
|
+
import sys
|
|
25
|
+
import os
|
|
26
|
+
import importlib.util
|
|
27
|
+
|
|
28
|
+
if isinstance(strategy, Strategy):
|
|
29
|
+
return strategy
|
|
30
|
+
|
|
31
|
+
if hasattr(strategy, "read"):
|
|
32
|
+
# input is file-like object, read it and import it and return Strategy class instance in it
|
|
33
|
+
if hasattr(strategy, "name") and strategy.name.endswith(".py"):
|
|
34
|
+
path = os.path.abspath(strategy.name)
|
|
35
|
+
dir_path = os.path.dirname(path)
|
|
36
|
+
if dir_path not in sys.path:
|
|
37
|
+
sys.path.insert(0, dir_path)
|
|
38
|
+
|
|
39
|
+
module_name = os.path.splitext(os.path.basename(path))[0]
|
|
40
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
41
|
+
if spec and spec.loader:
|
|
42
|
+
module = importlib.util.module_from_spec(spec)
|
|
43
|
+
spec.loader.exec_module(module)
|
|
44
|
+
for obj in vars(module).values():
|
|
45
|
+
if isinstance(obj, type) and issubclass(obj, Strategy) and obj is not Strategy:
|
|
46
|
+
return obj(*args) if args is not None else obj()
|
|
47
|
+
|
|
48
|
+
content = strategy.read()
|
|
49
|
+
if isinstance(content, bytes):
|
|
50
|
+
content = content.decode("utf-8")
|
|
51
|
+
return RunRequest.load_strategy(content, args=args)
|
|
52
|
+
|
|
53
|
+
if isinstance(strategy, str):
|
|
54
|
+
# input is source code, import it and return Strategy class instance in it
|
|
55
|
+
loc = {}
|
|
56
|
+
# Use the same dictionary for globals and locals to avoid NameError in class methods
|
|
57
|
+
# also include Strategy class in the loc to avoid NameError
|
|
58
|
+
loc["Strategy"] = Strategy
|
|
59
|
+
exec(strategy, loc)
|
|
60
|
+
for obj in loc.values():
|
|
61
|
+
if isinstance(obj, type) and issubclass(obj, Strategy) and obj is not Strategy:
|
|
62
|
+
if args is None:
|
|
63
|
+
return obj()
|
|
64
|
+
else:
|
|
65
|
+
return obj(*args)
|
|
66
|
+
raise ValueError("No Strategy subclass found in source code")
|
|
67
|
+
|
|
68
|
+
raise ValueError("Invalid strategy type")
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def from_json(
|
|
72
|
+
data: str | IO[str] | IO[bytes] | bytes,
|
|
73
|
+
/,
|
|
74
|
+
symbols: list[str] | None = None,
|
|
75
|
+
start: str | None = None,
|
|
76
|
+
end: str | None = None,
|
|
77
|
+
args: list | None = None,
|
|
78
|
+
) -> "RunRequest":
|
|
79
|
+
import json
|
|
80
|
+
|
|
81
|
+
if hasattr(data, "read"):
|
|
82
|
+
content = data.read()
|
|
83
|
+
else:
|
|
84
|
+
content = data
|
|
85
|
+
|
|
86
|
+
config = json.loads(content) #type: ignore
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
return RunRequest(
|
|
90
|
+
config.get("strategy"),
|
|
91
|
+
symbols if symbols is not None else config.get("symbols"),
|
|
92
|
+
start=start if start is not None else config.get("start"),
|
|
93
|
+
end=end if end is not None else config.get("end"),
|
|
94
|
+
args=args if args is not None else config.get("args"),
|
|
95
|
+
)
|