quantvn 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.
Potentially problematic release.
This version of quantvn might be problematic. Click here for more details.
- quantvn/__init__.py +2 -0
- quantvn/crypto/__init__.py +1 -0
- quantvn/crypto/data/__init__.py +31 -0
- quantvn/crypto/data/const.py +26 -0
- quantvn/crypto/data/core.py +82 -0
- quantvn/crypto/data/derivatives.py +22 -0
- quantvn/crypto/data/utils.py +93 -0
- quantvn/metrics/__init__.py +3 -0
- quantvn/metrics/portfolio.py +0 -0
- quantvn/metrics/single_asset.py +419 -0
- quantvn/metrics/st.py +569 -0
- quantvn/paper/__init__.py +0 -0
- quantvn/paper/portfolio.py +0 -0
- quantvn/paper/single_asset.py +0 -0
- quantvn/vn/__init__.py +1 -0
- quantvn/vn/data/__init__.py +146 -0
- quantvn/vn/data/const.py +26 -0
- quantvn/vn/data/core.py +904 -0
- quantvn/vn/data/derivatives.py +62 -0
- quantvn/vn/data/stocks.py +1281 -0
- quantvn/vn/data/utils.py +56 -0
- quantvn/vn/metrics/__init__.py +4 -0
- quantvn/vn/metrics/backtest.py +323 -0
- quantvn/vn/metrics/metrics.py +185 -0
- quantvn-0.1.0.dist-info/METADATA +25 -0
- quantvn-0.1.0.dist-info/RECORD +29 -0
- quantvn-0.1.0.dist-info/WHEEL +5 -0
- quantvn-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantvn-0.1.0.dist-info/top_level.txt +1 -0
quantvn/metrics/st.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
from typing import TypedDict, List, Dict, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HistoryRecord(TypedDict):
|
|
11
|
+
time: pd.Timestamp
|
|
12
|
+
current_tick: int
|
|
13
|
+
signal: float
|
|
14
|
+
action: str
|
|
15
|
+
amount: float
|
|
16
|
+
value: float
|
|
17
|
+
price: float
|
|
18
|
+
fee: float
|
|
19
|
+
equity: float
|
|
20
|
+
bm_equity: float
|
|
21
|
+
step_ret: float
|
|
22
|
+
cum_ret: float
|
|
23
|
+
bm_step_ret: float
|
|
24
|
+
bm_cum_ret: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def round_to_lot(value: float, lot_size: int) -> int:
|
|
28
|
+
remainder = value % lot_size
|
|
29
|
+
if remainder < lot_size / 2:
|
|
30
|
+
return int(value - remainder)
|
|
31
|
+
else:
|
|
32
|
+
return int(value + (lot_size - remainder))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Algorithm:
|
|
36
|
+
_price_scale = 1
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._name = None
|
|
40
|
+
self._init_cash = 1_000_000_000
|
|
41
|
+
self._slippage = 0.0
|
|
42
|
+
self._resolution = "D"
|
|
43
|
+
self._ticker = None
|
|
44
|
+
self._from_time = None
|
|
45
|
+
self._to_time = None
|
|
46
|
+
self._df_ticker = pd.DataFrame()
|
|
47
|
+
self._init_price: float | None = None
|
|
48
|
+
|
|
49
|
+
self._bm_open_size: int | None = None
|
|
50
|
+
self._bm_equity: float | None = None
|
|
51
|
+
|
|
52
|
+
self._current_time_idx: int | None = None
|
|
53
|
+
self._current_time: pd.Timestamp | None = None
|
|
54
|
+
self._current_position: float | None = None
|
|
55
|
+
self._current_open_size: int | None = None
|
|
56
|
+
self._current_equity: float | None = None
|
|
57
|
+
|
|
58
|
+
self._ht_times: List[pd.Timestamp] = []
|
|
59
|
+
self._ht_prices: List[float] = []
|
|
60
|
+
self._bt_results: List[HistoryRecord] = []
|
|
61
|
+
self._bt_df: Union[pd.DataFrame, None] = None
|
|
62
|
+
self._bt_columns = list(HistoryRecord.__annotations__.keys())
|
|
63
|
+
self.performance: Dict[str, float] = {}
|
|
64
|
+
self._signals: pd.Series | None = None
|
|
65
|
+
|
|
66
|
+
# -------------------- Data helpers --------------------
|
|
67
|
+
@property
|
|
68
|
+
def df_ticker(self) -> pd.DataFrame:
|
|
69
|
+
return self._df_ticker
|
|
70
|
+
|
|
71
|
+
@df_ticker.setter
|
|
72
|
+
def df_ticker(self, df: pd.DataFrame):
|
|
73
|
+
self._df_ticker = df
|
|
74
|
+
|
|
75
|
+
def load_csv(self, csv_path: str, symbol: str = "TICKER"):
|
|
76
|
+
df = pd.read_csv(csv_path, parse_dates=["Date"]).rename(columns={"Date": "time"})
|
|
77
|
+
df = df.set_index("time").sort_index()
|
|
78
|
+
required = {"Open", "High", "Low", "Close"}
|
|
79
|
+
missing = required - set(df.columns)
|
|
80
|
+
if missing:
|
|
81
|
+
raise ValueError(f"CSV is missing columns: {sorted(missing)}")
|
|
82
|
+
if "Volume" not in df.columns:
|
|
83
|
+
df["Volume"] = 0
|
|
84
|
+
df["Symbol"] = symbol
|
|
85
|
+
self.df_ticker = df
|
|
86
|
+
|
|
87
|
+
# -------------------- Run lifecycle --------------------
|
|
88
|
+
def __reset__(self):
|
|
89
|
+
if self.df_ticker.empty:
|
|
90
|
+
raise ValueError("No data loaded. Set df_ticker or call load_csv().")
|
|
91
|
+
self._init_price = float(self.df_ticker['Close'].values[0] * self._price_scale)
|
|
92
|
+
|
|
93
|
+
self._current_time_idx = 0
|
|
94
|
+
self._current_position = 0.0
|
|
95
|
+
self._current_open_size = 0
|
|
96
|
+
self._ht_times.clear()
|
|
97
|
+
self._ht_prices.clear()
|
|
98
|
+
self._bt_results.clear()
|
|
99
|
+
self._signals = pd.Series(dtype=float)
|
|
100
|
+
self._ht_times = self.df_ticker.index.tolist()
|
|
101
|
+
self._ht_prices = (self.df_ticker['Close'].values * self._price_scale).tolist()
|
|
102
|
+
|
|
103
|
+
self._current_time = self.df_ticker.index[0]
|
|
104
|
+
self._bm_equity = self._init_cash
|
|
105
|
+
self._current_equity = self._init_cash
|
|
106
|
+
self._bt_df = pd.DataFrame(columns=self._bt_columns)
|
|
107
|
+
self._signals = pd.Series(0.0, index=self.df_ticker.index, dtype=float)
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def __step__(self, time_idx: int):
|
|
111
|
+
self._current_time_idx = time_idx
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def __setup__(self):
|
|
115
|
+
raise NotImplementedError
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def __algorithm__(self):
|
|
119
|
+
raise NotImplementedError
|
|
120
|
+
|
|
121
|
+
def hold(self, conditions, weight: float = 0.0):
|
|
122
|
+
self._signals[conditions] = weight
|
|
123
|
+
|
|
124
|
+
def buy(self, conditions, weight: float = 1.0):
|
|
125
|
+
self._signals[conditions] = weight
|
|
126
|
+
|
|
127
|
+
def sell(self, conditions, weight: float = 1.0):
|
|
128
|
+
self._signals[conditions] = -weight
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def Open(self):
|
|
132
|
+
return self.df_ticker['Open'].values
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def High(self):
|
|
136
|
+
return self.df_ticker['High'].values
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def Low(self):
|
|
140
|
+
return self.df_ticker['Low'].values
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def Close(self):
|
|
144
|
+
return self.df_ticker['Close'].values
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def Volume(self):
|
|
148
|
+
return self.df_ticker['Volume'].values
|
|
149
|
+
|
|
150
|
+
def run(self):
|
|
151
|
+
self.__setup__()
|
|
152
|
+
self.__load_data__()
|
|
153
|
+
self.__reset__()
|
|
154
|
+
# attach features wrapper on the full DataFrame
|
|
155
|
+
self._features = TimeseriesFeatures(self.df_ticker)
|
|
156
|
+
self.__algorithm__()
|
|
157
|
+
if self._signals is None:
|
|
158
|
+
raise ValueError("Trading signals were not generated.")
|
|
159
|
+
for time_idx in range(len(self._signals)):
|
|
160
|
+
self.__step__(time_idx)
|
|
161
|
+
self.__done__()
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def __done__(self):
|
|
165
|
+
logging.info(f"Algorithm {self._name} run completed. Total records: {len(self._bt_results)}")
|
|
166
|
+
self._bt_df = pd.DataFrame(self._bt_results, columns=self._bt_columns)
|
|
167
|
+
equity = self._bt_df['equity'].values
|
|
168
|
+
bm_equity = self._bt_df['bm_equity'].values
|
|
169
|
+
step_ret = np.zeros_like(equity, dtype=np.float64)
|
|
170
|
+
step_ret[1:] = (equity[1:] - equity[:-1]) / equity[:-1]
|
|
171
|
+
cum_ret = np.cumprod(1 + step_ret) - 1
|
|
172
|
+
bm_step_ret = np.zeros_like(bm_equity, dtype=np.float64)
|
|
173
|
+
bm_step_ret[1:] = (bm_equity[1:] - bm_equity[:-1]) / bm_equity[:-1]
|
|
174
|
+
bm_cum_ret = np.cumprod(1 + bm_step_ret) - 1
|
|
175
|
+
self._bt_df['step_ret'] = step_ret
|
|
176
|
+
self._bt_df['cum_ret'] = cum_ret
|
|
177
|
+
self._bt_df['bm_step_ret'] = bm_step_ret
|
|
178
|
+
self._bt_df['bm_cum_ret'] = bm_cum_ret
|
|
179
|
+
self._bt_df.set_index('time', inplace=True)
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
# -------------------- Indicators helpers --------------------
|
|
183
|
+
@classmethod
|
|
184
|
+
def current(cls, series):
|
|
185
|
+
if isinstance(series, pd.Series):
|
|
186
|
+
return series.values
|
|
187
|
+
elif isinstance(series, np.ndarray):
|
|
188
|
+
return series
|
|
189
|
+
else:
|
|
190
|
+
raise TypeError(f"Unsupported type {type(series)} for current()")
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def previous(cls, series, periods: int = 1):
|
|
194
|
+
if isinstance(series, pd.Series):
|
|
195
|
+
return series.shift(periods).values
|
|
196
|
+
elif isinstance(series, np.ndarray):
|
|
197
|
+
arr = series
|
|
198
|
+
if periods <= 0:
|
|
199
|
+
return arr
|
|
200
|
+
pad = np.full(periods, np.nan)
|
|
201
|
+
return np.concatenate([pad, arr[:-periods]])
|
|
202
|
+
else:
|
|
203
|
+
raise TypeError(f"Unsupported type {type(series)} for previous()")
|
|
204
|
+
|
|
205
|
+
# -------------------- Data loading --------------------
|
|
206
|
+
def __load_data__(self):
|
|
207
|
+
if not self.df_ticker.empty:
|
|
208
|
+
return
|
|
209
|
+
if self._ticker is None:
|
|
210
|
+
raise ValueError("No data loaded. Either call load_csv() or set _ticker and install yfinance.")
|
|
211
|
+
try:
|
|
212
|
+
import yfinance as yf
|
|
213
|
+
except Exception as e:
|
|
214
|
+
raise RuntimeError("yfinance is required for automatic fetching. Install via pip install yfinance or load via CSV.") from e
|
|
215
|
+
start = str(self._from_time) if self._from_time is not None else None
|
|
216
|
+
end = str(self._to_time) if self._to_time is not None else None
|
|
217
|
+
df = yf.download(self._ticker, start=start, end=end, interval="1d")
|
|
218
|
+
if df.empty:
|
|
219
|
+
raise ValueError(f"No data fetched for ticker {self._ticker}")
|
|
220
|
+
df = df.rename(columns={"Adj Close": "AdjClose"})
|
|
221
|
+
if "Volume" not in df.columns:
|
|
222
|
+
df["Volume"] = 0
|
|
223
|
+
self.df_ticker = df[["Open", "High", "Low", "Close", "Volume"]]
|
|
224
|
+
|
|
225
|
+
def visualize(self):
|
|
226
|
+
visualizer = StrategyVisualizer(self._bt_df)
|
|
227
|
+
visualizer.name = self._name
|
|
228
|
+
visualizer.visualize()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class StockAlgorithm(Algorithm):
|
|
232
|
+
_stock_lot_size = 100
|
|
233
|
+
_price_scale = 1000
|
|
234
|
+
|
|
235
|
+
def __init__(self):
|
|
236
|
+
super().__init__()
|
|
237
|
+
self._init_fee = 0.001
|
|
238
|
+
self._t0_size: float = 0.0
|
|
239
|
+
self._t1_size: float = 0.0
|
|
240
|
+
self._t2_size: float = 0.0
|
|
241
|
+
self._sell_size: float = 0.0
|
|
242
|
+
self._pending_sell_pos: float = 0.0
|
|
243
|
+
|
|
244
|
+
@abstractmethod
|
|
245
|
+
def __setup__(self):
|
|
246
|
+
raise NotImplementedError("StockAlgorithm must implement _setup_ method")
|
|
247
|
+
|
|
248
|
+
@abstractmethod
|
|
249
|
+
def __algorithm__(self):
|
|
250
|
+
raise NotImplementedError("StockAlgorithm must implement _algorithm_ method")
|
|
251
|
+
|
|
252
|
+
def __reset__(self):
|
|
253
|
+
super().__reset__()
|
|
254
|
+
self._bm_open_size = round_to_lot(self._init_cash // self._init_price, self._stock_lot_size)
|
|
255
|
+
bm_fee = self._init_price * self._bm_open_size * self._init_fee
|
|
256
|
+
self._bm_equity -= bm_fee
|
|
257
|
+
|
|
258
|
+
def __step__(self, time_idx: int):
|
|
259
|
+
super().__step__(time_idx)
|
|
260
|
+
current_action = "H"
|
|
261
|
+
current_signal = 0.0
|
|
262
|
+
current_trade_size = 0.0
|
|
263
|
+
current_fee = 0.0
|
|
264
|
+
current_price = self._ht_prices[self._current_time_idx]
|
|
265
|
+
current_time = self._ht_times[self._current_time_idx]
|
|
266
|
+
sig: float = float(self._signals.values[self._current_time_idx])
|
|
267
|
+
current_max_shares = round_to_lot(self._init_cash // current_price, self._stock_lot_size)
|
|
268
|
+
|
|
269
|
+
prev_price = self._ht_prices[self._current_time_idx - 1] if self._current_time_idx > 0 else current_price
|
|
270
|
+
current_pnl = self._current_open_size * (current_price - prev_price)
|
|
271
|
+
bm_pnl = self._bm_open_size * (current_price - prev_price)
|
|
272
|
+
|
|
273
|
+
prev_time = self._ht_times[self._current_time_idx - 1] if self._current_time_idx > 0 else current_time
|
|
274
|
+
day_diff = (current_time - prev_time).days
|
|
275
|
+
if day_diff > 0:
|
|
276
|
+
logging.debug(
|
|
277
|
+
f"Update T0, T1, T2 for {current_time}, T0: {self._t0_size}, T1: {self._t1_size}, T2: {self._t2_size}, Sell Position: {self._sell_size}"
|
|
278
|
+
)
|
|
279
|
+
self._sell_size += self._t2_size
|
|
280
|
+
self._t2_size = self._t1_size
|
|
281
|
+
self._t1_size = self._t0_size
|
|
282
|
+
self._t0_size = 0
|
|
283
|
+
|
|
284
|
+
if sig > 0:
|
|
285
|
+
updated_position = min(sig - self._current_position, 1 - self._current_position)
|
|
286
|
+
elif sig < 0:
|
|
287
|
+
if self._current_position > 0:
|
|
288
|
+
updated_position = max(sig - self._current_position, -self._current_position)
|
|
289
|
+
else:
|
|
290
|
+
updated_position = 0.0
|
|
291
|
+
else:
|
|
292
|
+
updated_position = 0.0
|
|
293
|
+
|
|
294
|
+
if updated_position == 0:
|
|
295
|
+
pass
|
|
296
|
+
elif updated_position < 0 or self._pending_sell_pos > 0:
|
|
297
|
+
logging.debug(f"Entering sell logic at {current_time} with weight {sig}")
|
|
298
|
+
if self._sell_size == 0:
|
|
299
|
+
logging.warning(
|
|
300
|
+
f"Sell position is 0, but trying to sell {sig} at {current_time}. This will be ignored, please wait for the next timestamp to sell."
|
|
301
|
+
)
|
|
302
|
+
self._pending_sell_pos += abs(sig)
|
|
303
|
+
else:
|
|
304
|
+
can_sell_position = max(self._pending_sell_pos, abs(updated_position))
|
|
305
|
+
current_trade_size = min(
|
|
306
|
+
self._sell_size,
|
|
307
|
+
round_to_lot(can_sell_position * self._current_open_size, self._stock_lot_size),
|
|
308
|
+
)
|
|
309
|
+
self._sell_size -= current_trade_size
|
|
310
|
+
self._current_open_size -= current_trade_size
|
|
311
|
+
current_signal = -can_sell_position
|
|
312
|
+
self._current_position -= can_sell_position
|
|
313
|
+
self._pending_sell_pos = max(self._pending_sell_pos - can_sell_position, 0)
|
|
314
|
+
current_action = "S"
|
|
315
|
+
current_fee = current_price * current_trade_size * self._init_fee
|
|
316
|
+
current_pnl -= current_fee
|
|
317
|
+
elif updated_position > 0:
|
|
318
|
+
logging.debug(f"Entering buy logic at {current_time} with weight {sig}")
|
|
319
|
+
self._current_position += updated_position
|
|
320
|
+
current_trade_size = round_to_lot(updated_position * current_max_shares, self._stock_lot_size)
|
|
321
|
+
self._t0_size += current_trade_size
|
|
322
|
+
self._current_open_size += current_trade_size
|
|
323
|
+
current_action = "B"
|
|
324
|
+
current_signal = updated_position
|
|
325
|
+
current_fee = current_price * current_trade_size * self._init_fee
|
|
326
|
+
current_pnl -= current_fee
|
|
327
|
+
|
|
328
|
+
self._current_equity += current_pnl
|
|
329
|
+
self._bm_equity += bm_pnl
|
|
330
|
+
self._bt_results.append(
|
|
331
|
+
HistoryRecord(
|
|
332
|
+
time=current_time,
|
|
333
|
+
current_tick=self._current_time_idx,
|
|
334
|
+
action=current_action,
|
|
335
|
+
signal=current_signal,
|
|
336
|
+
amount=current_trade_size,
|
|
337
|
+
price=current_price,
|
|
338
|
+
value=self._current_open_size * current_price,
|
|
339
|
+
fee=current_fee,
|
|
340
|
+
equity=self._current_equity,
|
|
341
|
+
bm_equity=self._bm_equity,
|
|
342
|
+
step_ret=0,
|
|
343
|
+
cum_ret=0,
|
|
344
|
+
bm_step_ret=0,
|
|
345
|
+
bm_cum_ret=0,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class TimeseriesFeatures:
|
|
351
|
+
def __init__(self, df: pd.DataFrame):
|
|
352
|
+
self.df = df
|
|
353
|
+
|
|
354
|
+
def bbands(self, timeperiod: int = 20, nbdevup: float = 2.0, nbdevdn: float = 2.0):
|
|
355
|
+
close = self.df['Close']
|
|
356
|
+
ma = close.rolling(timeperiod).mean()
|
|
357
|
+
std = close.rolling(timeperiod).std(ddof=0)
|
|
358
|
+
upper = ma + nbdevup * std
|
|
359
|
+
lower = ma - nbdevdn * std
|
|
360
|
+
return upper, ma, lower
|
|
361
|
+
|
|
362
|
+
def rsi(self, timeperiod: int = 14):
|
|
363
|
+
close = self.df['Close']
|
|
364
|
+
delta = close.diff()
|
|
365
|
+
gain = (delta.where(delta > 0, 0.0)).ewm(alpha=1/timeperiod, adjust=False).mean()
|
|
366
|
+
loss = (-delta.where(delta < 0, 0.0)).ewm(alpha=1/timeperiod, adjust=False).mean()
|
|
367
|
+
rs = gain / loss.replace(0, np.nan)
|
|
368
|
+
rsi = 100 - (100 / (1 + rs))
|
|
369
|
+
return rsi
|
|
370
|
+
|
|
371
|
+
def macd(self, fastperiod: int = 12, slowperiod: int = 26, signalperiod: int = 9):
|
|
372
|
+
close = self.df['Close']
|
|
373
|
+
ema_fast = close.ewm(span=fastperiod, adjust=False).mean()
|
|
374
|
+
ema_slow = close.ewm(span=slowperiod, adjust=False).mean()
|
|
375
|
+
macd = ema_fast - ema_slow
|
|
376
|
+
signal = macd.ewm(span=signalperiod, adjust=False).mean()
|
|
377
|
+
hist = macd - signal
|
|
378
|
+
return macd, signal, hist
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class StrategyPerformance:
|
|
382
|
+
def __init__(self, returns: pd.Series | np.ndarray):
|
|
383
|
+
import quantstats as qs
|
|
384
|
+
if isinstance(returns, np.ndarray):
|
|
385
|
+
returns = pd.Series(returns)
|
|
386
|
+
if not isinstance(returns, pd.Series):
|
|
387
|
+
raise TypeError("Returns must be a pandas Series or numpy array.")
|
|
388
|
+
self.qs = qs
|
|
389
|
+
self.returns = returns.replace([np.inf, -np.inf], np.nan).dropna()
|
|
390
|
+
self.trading_days = 252
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def summary(self):
|
|
394
|
+
qs = self.qs
|
|
395
|
+
r = self.returns
|
|
396
|
+
return {
|
|
397
|
+
"avg_return": qs.stats.avg_return(r),
|
|
398
|
+
"cumulative_return": qs.stats.comp(r),
|
|
399
|
+
"cvar": qs.stats.cvar(r),
|
|
400
|
+
"gain_to_pain_ratio": qs.stats.gain_to_pain_ratio(r),
|
|
401
|
+
"kelly_criterion": qs.stats.kelly_criterion(r),
|
|
402
|
+
"max_drawdown": qs.stats.max_drawdown(r),
|
|
403
|
+
"omega": qs.stats.omega(r),
|
|
404
|
+
"profit_factor": qs.stats.profit_factor(r),
|
|
405
|
+
"recovery_factor": qs.stats.recovery_factor(r),
|
|
406
|
+
"sharpe": qs.stats.sharpe(r),
|
|
407
|
+
"sortino": qs.stats.sortino(r),
|
|
408
|
+
"tail_ratio": qs.stats.tail_ratio(r),
|
|
409
|
+
"ulcer_index": qs.stats.ulcer_index(r),
|
|
410
|
+
"var": qs.stats.value_at_risk(r),
|
|
411
|
+
"volatility": qs.stats.volatility(r),
|
|
412
|
+
"win_loss_ratio": qs.stats.win_loss_ratio(r),
|
|
413
|
+
"win_rate": qs.stats.win_rate(r),
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class StrategyVisualizer:
|
|
418
|
+
def __init__(self, df: pd.DataFrame):
|
|
419
|
+
self._bt_df = df
|
|
420
|
+
self.name = None
|
|
421
|
+
|
|
422
|
+
def performance_summary(self) -> Dict[str, float]:
|
|
423
|
+
if not self._bt_df.empty:
|
|
424
|
+
pf = StrategyPerformance(self._bt_df['step_ret'])
|
|
425
|
+
return pf.summary
|
|
426
|
+
else:
|
|
427
|
+
logging.warning("No backtest data available for performance summary.")
|
|
428
|
+
return {}
|
|
429
|
+
|
|
430
|
+
def visualize(self):
|
|
431
|
+
if self._bt_df is None or self._bt_df.empty:
|
|
432
|
+
logging.warning("No backtest data available to visualize.")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
df = self._bt_df.sort_index()
|
|
436
|
+
performance = self.performance_summary()
|
|
437
|
+
|
|
438
|
+
metric_names = list(sorted(performance.keys()))
|
|
439
|
+
metric_values = [f"{performance[m]:.4f}" if isinstance(performance[m], float) else "-" for m in metric_names]
|
|
440
|
+
|
|
441
|
+
import plotly.graph_objects as go
|
|
442
|
+
from plotly.subplots import make_subplots
|
|
443
|
+
|
|
444
|
+
fig = make_subplots(
|
|
445
|
+
rows=2, cols=2,
|
|
446
|
+
shared_xaxes=True,
|
|
447
|
+
vertical_spacing=0.01,
|
|
448
|
+
horizontal_spacing=0.01,
|
|
449
|
+
column_widths=[0.8, 0.2],
|
|
450
|
+
row_heights=[0.4, 0.6],
|
|
451
|
+
subplot_titles=("Strategy vs Benchmark", "", "Price vs Signals", ""),
|
|
452
|
+
specs=[[{"type": "xy"}, {"type": "table"}],
|
|
453
|
+
[{"type": "xy"}, None]]
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Row 1: Strategy vs Benchmark
|
|
457
|
+
fig.add_trace(go.Scatter(
|
|
458
|
+
x=df.index, y=df['cum_ret'], mode='lines', name='Strategy',
|
|
459
|
+
line=dict(color='blue')
|
|
460
|
+
), row=1, col=1)
|
|
461
|
+
|
|
462
|
+
fig.add_trace(go.Scatter(
|
|
463
|
+
x=df.index, y=df['bm_cum_ret'], mode='lines', name='Benchmark',
|
|
464
|
+
line=dict(color='gray', dash='dot')
|
|
465
|
+
), row=1, col=1)
|
|
466
|
+
|
|
467
|
+
# Row 2: Price with Buy/Sell signals
|
|
468
|
+
fig.add_trace(go.Scatter(
|
|
469
|
+
x=df.index, y=df['price'], mode='lines', name='Price',
|
|
470
|
+
line=dict(color='black')
|
|
471
|
+
), row=2, col=1)
|
|
472
|
+
|
|
473
|
+
# Buy markers
|
|
474
|
+
buy_df = df[df['action'] == 'B'].copy()
|
|
475
|
+
buy_df['date_str'] = buy_df.index.strftime('%Y-%m-%d')
|
|
476
|
+
buy_df['time_str'] = buy_df.index.strftime('%H:%M:%S')
|
|
477
|
+
buy_df['balance_str'] = buy_df['equity'].apply(lambda x: f"{x:,.2f}")
|
|
478
|
+
buy_df['amount_str'] = buy_df['amount'].apply(lambda x: f"{x:.2f}")
|
|
479
|
+
buy_df['fee_str'] = buy_df['fee'].apply(lambda x: f"{x:.2f}")
|
|
480
|
+
buy_df['price_str'] = buy_df['price'].apply(lambda x: f"{x:.2f}")
|
|
481
|
+
fig.add_trace(go.Scatter(
|
|
482
|
+
x=buy_df.index, y=buy_df['price'], mode='markers', name='Buy',
|
|
483
|
+
marker=dict(symbol='triangle-up', color='green', size=10),
|
|
484
|
+
hovertemplate="Buy [%{customdata[0]}]<br>"
|
|
485
|
+
"Time: %{customdata[1]}<br>"
|
|
486
|
+
"Price: %{customdata[2]}<br>"
|
|
487
|
+
"Amount: %{customdata[3]}<br>"
|
|
488
|
+
"Fee: %{customdata[4]}<br>"
|
|
489
|
+
"Balance: %{customdata[5]}"
|
|
490
|
+
"<extra></extra>",
|
|
491
|
+
customdata=buy_df[['date_str', 'time_str', 'price_str', 'amount_str', 'fee_str', 'balance_str']].values
|
|
492
|
+
), row=2, col=1)
|
|
493
|
+
|
|
494
|
+
# Sell markers
|
|
495
|
+
sell_df = df[df['action'] == 'S'].copy()
|
|
496
|
+
sell_df['date_str'] = sell_df.index.strftime('%Y-%m-%d')
|
|
497
|
+
sell_df['time_str'] = sell_df.index.strftime('%H:%M:%S')
|
|
498
|
+
sell_df['balance_str'] = sell_df['equity'].apply(lambda x: f"{x:,.2f}")
|
|
499
|
+
sell_df['amount_str'] = sell_df['amount'].apply(lambda x: f"{x:.2f}")
|
|
500
|
+
sell_df['fee_str'] = sell_df['fee'].apply(lambda x: f"{x:.2f}")
|
|
501
|
+
sell_df['price_str'] = sell_df['price'].apply(lambda x: f"{x:.2f}")
|
|
502
|
+
fig.add_trace(go.Scatter(
|
|
503
|
+
x=sell_df.index, y=sell_df['price'], mode='markers', name='Sell',
|
|
504
|
+
marker=dict(symbol='triangle-down', color='red', size=10),
|
|
505
|
+
hovertemplate="Sell [%{customdata[0]}]<br>"
|
|
506
|
+
"Time: %{customdata[1]}<br>"
|
|
507
|
+
"Price: %{customdata[2]}<br>"
|
|
508
|
+
"Amount: %{customdata[3]}<br>"
|
|
509
|
+
"Fee: %{customdata[4]}<br>"
|
|
510
|
+
"Balance: %{customdata[5]}"
|
|
511
|
+
"<extra></extra>",
|
|
512
|
+
customdata=buy_df[['date_str', 'time_str', 'price_str', 'amount_str', 'fee_str', 'balance_str']].values
|
|
513
|
+
), row=2, col=1)
|
|
514
|
+
|
|
515
|
+
# Latest annotation
|
|
516
|
+
last_row = df.iloc[-1]
|
|
517
|
+
fig.add_trace(go.Scatter(
|
|
518
|
+
x=[last_row.index], y=[last_row['cum_ret']],
|
|
519
|
+
mode='text', text=[f" - {last_row['cum_ret']:.2f}"],
|
|
520
|
+
textposition='middle right',
|
|
521
|
+
textfont=dict(color='blue', size=12), showlegend=False
|
|
522
|
+
), row=1, col=1)
|
|
523
|
+
|
|
524
|
+
fig.add_trace(go.Scatter(
|
|
525
|
+
x=[last_row.index], y=[last_row['bm_cum_ret']],
|
|
526
|
+
mode='text', text=[f" - {last_row['bm_cum_ret']:.2f}"],
|
|
527
|
+
textposition='middle right',
|
|
528
|
+
textfont=dict(color='gray', size=12), showlegend=False
|
|
529
|
+
), row=1, col=1)
|
|
530
|
+
|
|
531
|
+
fig.add_trace(go.Scatter(
|
|
532
|
+
x=[last_row.index], y=[last_row['price']],
|
|
533
|
+
mode='text', text=[f" - {last_row['price']:.2f}"],
|
|
534
|
+
textposition='middle right',
|
|
535
|
+
textfont=dict(color='black', size=12), showlegend=False
|
|
536
|
+
), row=2, col=1)
|
|
537
|
+
|
|
538
|
+
# Table
|
|
539
|
+
fig.add_trace(go.Table(
|
|
540
|
+
header=dict(
|
|
541
|
+
values=["<b>Metric</b>", "<b>Value</b>"],
|
|
542
|
+
fill_color="lightgray", align="left",
|
|
543
|
+
font=dict(size=12)
|
|
544
|
+
),
|
|
545
|
+
cells=dict(
|
|
546
|
+
values=[metric_names, metric_values],
|
|
547
|
+
fill_color="white", align="left",
|
|
548
|
+
font=dict(size=12)
|
|
549
|
+
)
|
|
550
|
+
), row=1, col=2)
|
|
551
|
+
|
|
552
|
+
fig.update_layout(
|
|
553
|
+
title=f"Trading Strategy Performance: {self.name}",
|
|
554
|
+
margin=dict(l=10, r=10, t=40, b=10),
|
|
555
|
+
legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1),
|
|
556
|
+
xaxis2_title="Time",
|
|
557
|
+
yaxis1_title="Cumulative Return",
|
|
558
|
+
yaxis2_title="Price",
|
|
559
|
+
template='plotly_white',
|
|
560
|
+
hovermode='closest'
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
import IPython
|
|
564
|
+
if IPython.get_ipython():
|
|
565
|
+
fig.show()
|
|
566
|
+
else:
|
|
567
|
+
from IPython.utils import io
|
|
568
|
+
with io.capture_output() as captured:
|
|
569
|
+
fig.show()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
quantvn/vn/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from quantvn.vn import data, metrics
|