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 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
@@ -0,0 +1,5 @@
1
+
2
+ from .report import Report, build_report, build_json_report
3
+ from .backend import RunRequest
4
+
5
+ __all__ = ["Report", "build_report", "build_json_report", "RunRequest"]
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
+ )