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/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
|
+
|