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/run/cli.py ADDED
@@ -0,0 +1,192 @@
1
+ from dotenv import load_dotenv
2
+
3
+ load_dotenv(override=True)
4
+
5
+ import datetime
6
+ from mbt import Runner, Account, Fee
7
+ from mbt.run import Report, build_report, build_json_report, RunRequest
8
+ from mbt.run.msd import load_data
9
+ import logging
10
+ from typing import Tuple
11
+ import argparse
12
+ import pandas as pd
13
+ import orjson
14
+ import os
15
+ import numpy as np
16
+
17
+ logging.basicConfig(
18
+ format="%(asctime)s %(name)s %(levelname)s %(message)s", level=logging.INFO
19
+ )
20
+
21
+ logger = logging.getLogger("backtest")
22
+
23
+
24
+ def expand_symbols(symbols: list[str]) -> list[str]:
25
+ """read as file when input is a file path, and expand it. otherwise, return the input as a list of symbols"""
26
+ expanded = []
27
+ for s in symbols:
28
+ if os.path.isfile(s):
29
+ with open(s, "r") as f:
30
+ lines = filter(
31
+ lambda x: len(x) > 0 and not x.startswith("#"),
32
+ map(lambda x: x.strip(), f.readlines()),
33
+ )
34
+ expanded.extend(lines)
35
+ else:
36
+ expanded.append(s)
37
+
38
+ return expanded
39
+
40
+
41
+ def parse_args() -> Tuple[RunRequest, str, str, Fee]:
42
+ parser = argparse.ArgumentParser(description="Run a strategy")
43
+ parser.add_argument(
44
+ "strategy",
45
+ help="Strategy to run, can be a JSON file or a Python file",
46
+ )
47
+ parser.add_argument("-s", "--symbols", nargs="+", default=[], help="Symbols to run")
48
+ parser.add_argument("-B", "--benchmark", type=str, default=os.environ.get("MSD_BT_BENCHMARK", "SH000300"), help="Benchmark symbol, env $MSD_BT_BENCHMARK")
49
+ parser.add_argument(
50
+ "-b", "--begin", type=str, default=os.environ.get("MSD_BT_BEGIN", "2020-01-01"), help="Start date, env $MSD_BT_BEGIN"
51
+ )
52
+ parser.add_argument("-e", "--end", type=str, default=os.environ.get("MSD_BT_END", "2020-12-31"), help="End date, env $MSD_BT_END")
53
+ parser.add_argument("-a", "--args", nargs="*", help="Strategy arguments")
54
+ parser.add_argument(
55
+ "-m", "--msd", type=str, default=os.environ.get("MSD_HOST", "http://localhost:50510"), help="MSD host, env $MSD_HOST"
56
+ )
57
+ parser.add_argument(
58
+ "-o",
59
+ "--output",
60
+ default="",
61
+ help="output to, empty to stdout, or any supported file path, csv, json, excel",
62
+ )
63
+ parser.add_argument(
64
+ "-f",
65
+ "--fee",
66
+ default=os.environ.get("MSD_BT_FEE", ""),
67
+ help="fee config path, env $MSD_BT_FEE",
68
+ )
69
+ parser.add_argument("-V", "--verbose", action="store_true", help="verbose output")
70
+ args = parser.parse_args()
71
+
72
+ if args.verbose:
73
+ logging.getLogger().setLevel(logging.DEBUG)
74
+
75
+
76
+ args.symbols = expand_symbols(args.symbols)
77
+
78
+ if args.strategy.endswith(".json"):
79
+ with open(args.strategy, "r") as f:
80
+ req = RunRequest.from_json(
81
+ f,
82
+ symbols=args.symbols,
83
+ start=args.begin,
84
+ end=args.end,
85
+ args=args.args,
86
+ )
87
+ elif args.strategy.endswith(".py"):
88
+ with open(args.strategy, "r") as f:
89
+ req = RunRequest(
90
+ f,
91
+ args.symbols,
92
+ start=args.begin,
93
+ end=args.end,
94
+ args=args.args,
95
+ )
96
+ else:
97
+ raise ValueError("Invalid strategy type")
98
+
99
+ if len(args.symbols) == 0:
100
+ raise ValueError("No symbols provided")
101
+
102
+ try:
103
+ args.symbols.remove(args.benchmark)
104
+ except ValueError:
105
+ pass
106
+
107
+ args.symbols.insert(0, args.benchmark)
108
+
109
+ fee = Fee(args.fee_json) if len(args.fee_json) > 0 else Fee()
110
+
111
+ return req, args.msd, args.output, fee
112
+
113
+
114
+ def float_format(f: float) -> str:
115
+ s = f"{f:.6f}"
116
+ if "." in s:
117
+ s = s.rstrip("0").rstrip(".")
118
+ return s
119
+
120
+ def json_default(self, o):
121
+ if isinstance(o, (np.ndarray, np.generic)):
122
+ return o.tolist()
123
+ elif isinstance(o, np.datetime64):
124
+ return np.datetime_as_string(o, 's').rstrip("T00:00:00")
125
+ elif isinstance(o, datetime.datetime):
126
+ return o.strftime("%Y-%m-%dT%X").rstrip("T00:00:00")
127
+ elif isinstance(o, float):
128
+ return round(o, 6)
129
+ return o
130
+
131
+
132
+ def build_output(reports: list[Report], output: str):
133
+ ext = output.split(".")[-1].lower()
134
+ logger.info(f"output kind: {ext}")
135
+ if ext == "csv":
136
+ for report in reports:
137
+ df = pd.DataFrame(report.detailed(with_symbol=True))
138
+ df.to_csv(output, index=False, float_format=float_format) # type: ignore
139
+ elif ext == "xlsx":
140
+ with pd.ExcelWriter(output) as writer:
141
+ df_metrics = pd.DataFrame([r.metrics(True) for r in reports])
142
+ df_metrics.to_excel(writer, sheet_name="metrics", index=False)
143
+ for report in reports:
144
+ data = report.detailed()
145
+ data['price'] = report.price
146
+ data['fw_price'] = report.fw_price
147
+ data['bw_price'] = report.bw_price
148
+ df = pd.DataFrame(data)
149
+ df.to_excel(
150
+ writer,
151
+ sheet_name=report.symbol,
152
+ index=False,
153
+ float_format="%.6f",
154
+ )
155
+ elif ext == "json":
156
+ data = build_json_report(reports)
157
+ logger.info("build json")
158
+ with open(output, "wb") as f:
159
+ f.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_OMIT_MICROSECONDS))
160
+ else:
161
+ raise ValueError(f"unsupported output format: {ext}")
162
+ logger.info(f"output saved to: {output}")
163
+
164
+
165
+ def echo_reports(reports: list[Report]):
166
+ df_metrics = pd.DataFrame([r.metrics(True) for r in reports])
167
+ print(df_metrics.to_json(orient='records', indent=2))
168
+
169
+
170
+ def main():
171
+ req, msd_host, output, fee = parse_args()
172
+ logger.info(f"load data from {msd_host}")
173
+ dp, symbols = load_data(msd_host, req.symbols, req.start, req.end)
174
+ logger.info(
175
+ f"data loaded, symbols: {len(symbols)}, bars per symbol: {dp.bars}, total_bars: {dp.groups * dp.bars}"
176
+ )
177
+ account = Account(10000.0, dp, fee=fee)
178
+ logger.info("account created")
179
+ runner = Runner(account)
180
+ logger.info("runner created")
181
+ runner.run(req.strategy)
182
+ logger.info("strategy finished")
183
+ reports = build_report(dp, runner.ctx.accounts)[1:]
184
+ logger.info("report built")
185
+ if len(output) > 0:
186
+ build_output(reports, output)
187
+ else:
188
+ echo_reports(reports)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
mbt/run/msd.py ADDED
@@ -0,0 +1,76 @@
1
+ import pymsd
2
+ from mbt import DataProvider
3
+ import alpha as al
4
+ import numpy as np
5
+ import logging
6
+
7
+ logger = logging.getLogger("msd_loader")
8
+
9
+
10
+
11
+ def next_hook_al(i: int, groups: int, bars: int):
12
+ al.set_ctx(end=i + 1)
13
+
14
+
15
+ def load_data(
16
+ msd_host: str, symbols: list[str], start: str, end: str
17
+ ) -> tuple[DataProvider, list[str]]:
18
+ """
19
+ create a DataProvider for backtesting from msd database.
20
+
21
+ Args:
22
+ msd_host: msd server host
23
+ symbols: symbols to backtest
24
+ start: start date
25
+ end: end date
26
+
27
+ Returns:
28
+ DataProvider and symbols
29
+ """
30
+
31
+ client = pymsd.create_msd_pandas(msd_host)
32
+
33
+ dfs = client.load(
34
+ objs=symbols,
35
+ tables=["stock_kline_1d", "stock_dividend", "stock_financial"],
36
+ join={"stock_dividend": "zero", "*": "backward"},
37
+ start=start,
38
+ end=end,
39
+ )
40
+ logger.info(f"data loaded from msd, {len(dfs)} symbols")
41
+
42
+ data, symbols = client.adaptor.concat(dfs, base=symbols[0], join="nan")
43
+ logger.info("data concatenated")
44
+ if data is None or "ts" not in data:
45
+ return DataProvider({"ts": np.array([])}, symbols=[]), []
46
+
47
+ data = client.adaptor.to_numpy(data)
48
+
49
+ dp = DataProvider(data, symbols=symbols, next_hook=next_hook_al)
50
+
51
+ # keep original as price
52
+ data["price"] = data["close"].copy()
53
+
54
+ al.set_ctx(groups=dp.groups, flags=al.FLAG_SKIP_NAN)
55
+ data["bw_price"] = al.BW_SPLIT(data["close"], data["dividend"], data["transfer_shares"], data["right_shares"], data["right_price"])
56
+ data["fw_price"] = al.FW_SPLIT(data["close"], data["dividend"], data["transfer_shares"], data["right_shares"], data["right_price"])
57
+
58
+ logger.info("adjustment factors generated")
59
+ # apply forward factor to all price related columns used to do technical analysis
60
+ data["open"] = al.FW_SPLIT(data["open"], data["dividend"], data["transfer_shares"], data["right_shares"], data["right_price"])
61
+ data["high"] = al.FW_SPLIT(data["high"], data["dividend"], data["transfer_shares"], data["right_shares"], data["right_price"])
62
+ data["low"] = al.FW_SPLIT(data["low"], data["dividend"], data["transfer_shares"], data["right_shares"], data["right_price"])
63
+ data["close"] = data["fw_price"]
64
+ data["vwap"] = data["amount"] / data["volume"]
65
+ # check if it is limited:
66
+ is_limit_up = (data["close"] == data["high"]) & (data["open"] == data["high"]) & (data["close"] > al.REF(data["close"], 1))
67
+ is_limit_down = (data["close"] == data["low"]) & (data["open"] == data["low"]) & (data["close"] < al.REF(data["close"], 1))
68
+ data["limited"] = np.where(is_limit_up, 1, np.where(is_limit_down, -1, 0))
69
+ logger.info("adjustment factors applied")
70
+
71
+ if dp.bars == 0:
72
+ raise ValueError("No data found for the given symbols and date range.")
73
+
74
+ # reset al context
75
+ al.set_ctx(groups=dp.groups, flags=al.FLAG_SKIP_NAN, start=0, end=1)
76
+ return dp, symbols
mbt/run/report.py ADDED
@@ -0,0 +1,313 @@
1
+ from mbt import Account
2
+ from mbt import DataProvider
3
+ import pandas as pd
4
+ import numpy as np
5
+ import dataclasses
6
+
7
+
8
+ @dataclasses.dataclass
9
+ class Report:
10
+ symbol: str
11
+ date: np.ndarray
12
+ ror: np.ndarray
13
+ ror_hold: np.ndarray
14
+ ror_original: np.ndarray
15
+ actions: np.ndarray
16
+ price: np.ndarray
17
+ close: np.ndarray
18
+ fw_price: np.ndarray
19
+ bw_price: np.ndarray
20
+ alpha: float
21
+ beta: float
22
+ sharpe: float
23
+ information_ratio: float
24
+ ir: float
25
+ sortino: float
26
+ treynor: float
27
+ max_drawdown: float
28
+ calmar: float
29
+ win_rate: float
30
+ avg_win: float
31
+ avg_loss: float
32
+ profit_factor: float
33
+ profit: float
34
+ profit_original: float
35
+ fee_rate: float
36
+ trade_count: int
37
+
38
+
39
+ def __init__(self, symbol: str, dp: DataProvider , account: Account ):
40
+ if len(symbol) == 0:
41
+ return
42
+ self.symbol = symbol
43
+ s, e = dp.symbol_indices(symbol)
44
+ self.date = dp.dates[s:e]
45
+
46
+
47
+ self.ror = np.round(dp.slice("ror", s), 4)
48
+ self.ror_hold = np.round(dp.slice("ror_hold", s), 4)
49
+ self.ror_original = np.round(uniform_ror(dp.slice("bw_price", s)), 4)
50
+
51
+ # Clean NaNs by forward filling and filling leading NaNs with 0
52
+ self.ror = pd.Series(self.ror).ffill().fillna(0.0).values
53
+ self.ror_hold = pd.Series(self.ror_hold).ffill().fillna(0.0).values
54
+ self.ror_original = pd.Series(self.ror_original).ffill().fillna(0.0).values
55
+
56
+ self.actions = dp.slice("actions", s)
57
+ self.price = dp.slice("price", s)
58
+ self.close = self.price # For compatibility with tests
59
+ self.fw_price = np.round(dp.slice("fw_price", s), 3)
60
+ self.bw_price = np.round(dp.slice("bw_price", s), 3)
61
+
62
+ self.calculate_metrics(dp, account)
63
+
64
+
65
+ @staticmethod
66
+ def from_dict(symbol: str, d: dict) -> Report:
67
+ r = Report('', None, None) # type: ignore
68
+ r.symbol = symbol
69
+ r.date = d.get('date', np.array([], dtype='int64'))
70
+ r.ror = np.array(d.get('ror', []), dtype='float64')
71
+ r.ror_hold = np.array(d.get('ror_hold', []), dtype='float64')
72
+ r.ror_original = np.array(d.get('ror_original', []), dtype='float64')
73
+ r.actions = np.array(d.get('actions', []), dtype='int8')
74
+ r.price = np.array(d.get('price', []), dtype='float64')
75
+ r.close = r.price
76
+ r.fw_price = np.array(d.get('fw_price', []), dtype='float64')
77
+ r.bw_price = np.array(d.get('bw_price', []), dtype='float64')
78
+
79
+ r.alpha = d.get('alpha', 0.0)
80
+ r.beta = d.get('beta', 0.0)
81
+ r.sharpe = d.get('sharpe', 0.0)
82
+ r.information_ratio = d.get('information_ratio', 0.0)
83
+ r.ir = d.get('ir', 0.0)
84
+ r.sortino = d.get('sortino', 0.0)
85
+ r.treynor = d.get('treynor', 0.0)
86
+ r.max_drawdown = d.get('max_drawdown', 0.0)
87
+ r.calmar = d.get('calmar', 0.0)
88
+ r.win_rate = d.get('win_rate', 0.0)
89
+ r.avg_win = d.get('avg_win', 0.0)
90
+ r.avg_loss = d.get('avg_loss', 0.0)
91
+ r.profit_factor = d.get('profit_factor', 0.0)
92
+ r.profit = d.get('profit', 0.0)
93
+ r.profit_original = d.get('profit_original', 0.0)
94
+ r.fee_rate = d.get('fee_rate', 0.0)
95
+ r.trade_count = d.get('trade_count', 0)
96
+
97
+ return r
98
+
99
+
100
+
101
+ def calculate_metrics(self, dp: DataProvider, account: Account):
102
+ benchmark = uniform_ror(dp.slice("bw_price", 0))
103
+
104
+ # 1. Convert cumulative returns to daily returns
105
+ strategy_ratio = 1.0 + self.ror
106
+ benchmark_ratio = 1.0 + benchmark
107
+
108
+ if len(strategy_ratio) > 1:
109
+ strategy_daily = np.diff(strategy_ratio) / strategy_ratio[:-1]
110
+ benchmark_daily = np.diff(benchmark_ratio) / benchmark_ratio[:-1]
111
+ else:
112
+ strategy_daily = np.array([], dtype=np.float64)
113
+ benchmark_daily = np.array([], dtype=np.float64)
114
+
115
+ # 2. Portfolio/Strategy metrics calculations
116
+ if len(strategy_daily) >= 2:
117
+ # Sharpe
118
+ mean_strat = np.mean(strategy_daily)
119
+ std_strat = np.std(strategy_daily, ddof=1)
120
+ sharpe = (mean_strat / std_strat * np.sqrt(252)) if std_strat != 0.0 else 0.0
121
+
122
+ # Beta
123
+ cov = np.cov(strategy_daily, benchmark_daily)
124
+ var_bench = cov[1, 1]
125
+ beta = (cov[0, 1] / var_bench) if var_bench != 0.0 else 0.0
126
+
127
+ # Alpha
128
+ mean_bench = np.mean(benchmark_daily)
129
+ alpha = (mean_strat - beta * mean_bench) * 252
130
+
131
+ # Information Ratio (IR)
132
+ active_daily = strategy_daily - benchmark_daily
133
+ mean_active = np.mean(active_daily)
134
+ std_active = np.std(active_daily, ddof=1)
135
+ ir = (mean_active / std_active * np.sqrt(252)) if std_active != 0.0 else 0.0
136
+
137
+ # Sortino
138
+ downside_daily = np.minimum(strategy_daily, 0.0)
139
+ std_downside = np.std(downside_daily, ddof=1)
140
+ sortino = (mean_strat / std_downside * np.sqrt(252)) if std_downside != 0.0 else 0.0
141
+
142
+ # Treynor
143
+ treynor = (mean_strat * 252 / beta) if beta != 0.0 else 0.0
144
+ else:
145
+ sharpe = 0.0
146
+ beta = 0.0
147
+ alpha = 0.0
148
+ ir = 0.0
149
+ sortino = 0.0
150
+ treynor = 0.0
151
+
152
+ # Max Drawdown
153
+ if len(self.ror) > 0:
154
+ equity = 1.0 + self.ror
155
+ peaks = np.maximum.accumulate(equity)
156
+ drawdowns = (equity - peaks) / peaks
157
+ max_drawdown = np.min(drawdowns)
158
+ else:
159
+ max_drawdown = 0.0
160
+
161
+ # Calmar
162
+ if max_drawdown != 0.0 and len(strategy_daily) >= 2:
163
+ calmar = (np.mean(strategy_daily) * 252) / abs(max_drawdown)
164
+ else:
165
+ calmar = 0.0
166
+
167
+ # 3. Trade-level metrics
168
+ action_shares = dp.slice("action_shares", self.symbol) if "action_shares" in dp.data else np.zeros_like(self.price)
169
+
170
+ # Track shares held at each bar
171
+ shares = np.zeros(len(self.actions))
172
+ curr_shares = 0.0
173
+ for t in range(len(self.actions)):
174
+ act = self.actions[t]
175
+ sh = action_shares[t]
176
+ if act == 1: # ACTION_BUY
177
+ curr_shares += sh
178
+ elif act == 2: # ACTION_SELL
179
+ curr_shares -= sh
180
+ shares[t] = max(curr_shares, 0.0)
181
+
182
+ # Identify holding periods where shares > 0
183
+ holding = shares > 0
184
+ padded = np.concatenate(([False], holding, [False]))
185
+ diff = np.diff(padded.astype(int))
186
+ starts = np.where(diff == 1)[0]
187
+ ends = np.where(diff == -1)[0] - 1
188
+
189
+ trade_profits = []
190
+ trade_returns = []
191
+
192
+ s = dp.symbols.index(self.symbol) * dp.bars
193
+ e = s + dp.bars
194
+ assets = dp.all("assets")[s:e]
195
+
196
+ for s_idx, e_idx in zip(starts, ends):
197
+ base_idx = s_idx - 1 if s_idx > 0 else 0
198
+ base_asset = assets[base_idx]
199
+
200
+ final_idx = min(e_idx + 1, len(assets) - 1)
201
+ final_asset = assets[final_idx]
202
+
203
+ profit = final_asset - base_asset
204
+ trade_profits.append(profit)
205
+
206
+ ret = (profit / base_asset) if base_asset != 0.0 else 0.0
207
+ trade_returns.append(ret)
208
+
209
+ if trade_profits:
210
+ wins = [p for p in trade_profits if p > 0]
211
+ losses = [p for p in trade_profits if p < 0]
212
+
213
+ wins_ret = [r for r in trade_returns if r > 0]
214
+ losses_ret = [r for r in trade_returns if r < 0]
215
+
216
+ win_rate = len(wins) / len(trade_profits)
217
+ avg_win = np.mean(wins_ret) if wins_ret else 0.0
218
+ avg_loss = np.mean(losses_ret) if losses_ret else 0.0
219
+
220
+ sum_loss = abs(sum(losses))
221
+ profit_factor = sum(wins) / sum_loss if sum_loss != 0.0 else 0.0
222
+ else:
223
+ win_rate = 0.0
224
+ avg_win = 0.0
225
+ avg_loss = 0.0
226
+ profit_factor = 0.0
227
+
228
+ # 4. Store all metrics as attributes
229
+ self.alpha = 0.0 if np.isnan(alpha) else np.round(alpha, 4)
230
+ self.beta = 0.0 if np.isnan(beta) else np.round(beta, 4)
231
+ self.sharpe = 0.0 if np.isnan(sharpe) else np.round(sharpe, 4)
232
+ self.information_ratio = 0.0 if np.isnan(ir) else np.round(ir, 4)
233
+ self.ir = self.information_ratio # For compatibility with tests
234
+ self.sortino = 0.0 if np.isnan(sortino) else np.round(sortino, 4)
235
+ self.treynor = 0.0 if np.isnan(treynor) else np.round(treynor, 4)
236
+ self.max_drawdown = 0.0 if np.isnan(max_drawdown) else np.round(max_drawdown, 4)
237
+ self.calmar = 0.0 if np.isnan(calmar) else np.round(calmar, 4)
238
+
239
+ self.win_rate = 0.0 if np.isnan(win_rate) else np.round(win_rate, 4)
240
+ self.avg_win = 0.0 if np.isnan(avg_win) else np.round(avg_win, 4)
241
+ self.avg_loss = 0.0 if np.isnan(avg_loss) else np.round(avg_loss, 4)
242
+ self.profit_factor = 0.0 if np.isnan(profit_factor) else np.round(profit_factor, 4)
243
+ self.profit = self.ror[-1] if self.ror.size > 0 else 0.0
244
+ self.profit_original = self.ror_original[-1] if self.ror_original.size > 0 else 0.0
245
+ self.fee_rate = np.round(account.deducted / account.initial_cash, 4)
246
+ self.trade_count = len(trade_profits)
247
+
248
+
249
+ def metrics(self, with_symbol: bool = False) -> dict:
250
+ d = {
251
+ "symbol": self.symbol,
252
+ "profit": self.profit,
253
+ "profit_original": self.profit_original,
254
+ "alpha": self.alpha,
255
+ "beta": self.beta,
256
+ "sharpe": self.sharpe,
257
+ "information_ratio": self.information_ratio,
258
+ "sortino": self.sortino,
259
+ "treynor": self.treynor,
260
+ "max_drawdown": self.max_drawdown,
261
+ "calmar": self.calmar,
262
+ "win_rate": self.win_rate,
263
+ "avg_win": self.avg_win,
264
+ "avg_loss": self.avg_loss,
265
+ "profit_factor": self.profit_factor,
266
+ "fee_rate": self.fee_rate,
267
+ "trade_count": self.trade_count,
268
+ }
269
+ if not with_symbol:
270
+ del d["symbol"]
271
+ return d
272
+
273
+ def detailed(self, with_symbol: bool = False) -> dict:
274
+ d = {
275
+ "symbol": [self.symbol] * len(self.date),
276
+ "date": self.date,
277
+ "ror": self.ror,
278
+ "ror_hold": self.ror_hold,
279
+ "ror_original": self.ror_original,
280
+ "actions": self.actions,
281
+ }
282
+ if not with_symbol:
283
+ del d["symbol"]
284
+ return d
285
+
286
+ def uniform_ror(a: np.ndarray) -> np.ndarray:
287
+ base = a[0]
288
+ if base == 0:
289
+ return a
290
+ return ((a - base) / base)
291
+
292
+
293
+ def build_report(dp: DataProvider, accounts: list[Account]) -> list[Report]:
294
+ reports = [Report(symbol, dp, accounts[i]) for i, symbol in enumerate(dp.symbols)]
295
+
296
+ return reports
297
+
298
+
299
+ def build_json_report(reports: list[Report]) -> dict:
300
+ """Helper to build a JSON report from DataProvider"""
301
+
302
+ data = {}
303
+ for report in reports:
304
+ d = {}
305
+ d.update(report.metrics())
306
+ d.update(report.detailed())
307
+ data[report.symbol] = d
308
+ return data
309
+
310
+
311
+
312
+
313
+
mbt/run/review.py ADDED
@@ -0,0 +1,67 @@
1
+ from mbt.run.report import Report
2
+ import orjson
3
+ import sys
4
+ import argparse
5
+ import pandas as pd
6
+
7
+
8
+
9
+ def parse_args():
10
+ parser = argparse.ArgumentParser(description="Review a backtest report")
11
+ parser.add_argument("report", type=str, help="Path to the report file")
12
+
13
+ sub_commands = parser.add_subparsers(dest="command", required=True)
14
+
15
+ stat_parser = sub_commands.add_parser("stat", help="Show statistics")
16
+ stat_parser.set_defaults(func=do_stat)
17
+
18
+ sort_parser = sub_commands.add_parser("sort", help="Sort by a metric")
19
+ sort_parser.add_argument("-m", "--metric", type=str, help="Metric to sort by")
20
+ sort_parser.add_argument("-o", "--order", type=str, help="Order, desc or asc", default="desc")
21
+ sort_parser.add_argument("-l", "--limit", type=int, help="Limit number of symbols")
22
+ sort_parser.set_defaults(func=do_sort)
23
+
24
+ detail_parser = sub_commands.add_parser("detail", help="Show detail for a symbol")
25
+ detail_parser.add_argument("-s", "--symbol", type=str, help="Symbol to show")
26
+ detail_parser.set_defaults(func=do_detail)
27
+
28
+ return parser.parse_args()
29
+
30
+ def do_stat(args):
31
+ reports = load_reports(args.report)
32
+ df = pd.DataFrame([r.metrics(True) for r in reports.values()])
33
+ df.set_index("symbol", inplace=True)
34
+ stat = df.describe()
35
+ print(stat.to_json())
36
+
37
+ def do_detail(args):
38
+ reports = load_reports(args.report)
39
+ symbol_report = reports[args.symbol]
40
+ d = {}
41
+ d.update(symbol_report.detailed(False))
42
+ d.update(symbol_report.metrics(True))
43
+ print(orjson.dumps(d, option=orjson.OPT_SERIALIZE_NUMPY))
44
+
45
+ def do_sort(args):
46
+ reports = load_reports(args.report)
47
+ df = pd.DataFrame([{"symbol":r.symbol, args.metric: getattr(r, args.metric, 0)} for r in reports.values()])
48
+ df.set_index("symbol", inplace=True)
49
+ df = df.sort_values(by=args.metric, ascending=args.order == "asc")
50
+ if args.limit:
51
+ df = df.head(args.limit)
52
+ print(df.to_json(orient="columns"))
53
+
54
+ def load_reports(report_path: str) -> dict[str, Report]:
55
+ with open(report_path, "r") as f:
56
+ data = orjson.loads(f.read())
57
+ return {symbol: Report.from_dict(symbol, data[symbol]) for symbol in data}
58
+
59
+
60
+ def main():
61
+ args = parse_args()
62
+ args.func(args)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
67
+