| Overall Statistics |
|
Total Orders 4370 Average Win 0.10% Average Loss -0.10% Compounding Annual Return -0.006% Drawdown 18.600% Expectancy 0.001 Start Equity 100000 End Equity 99942.38 Net Profit -0.058% Sharpe Ratio -0.755 Sortino Ratio -0.634 Probabilistic Sharpe Ratio 0.007% Loss Rate 49% Win Rate 51% Profit-Loss Ratio 0.98 Alpha -0.023 Beta -0.001 Annual Standard Deviation 0.03 Annual Variance 0.001 Information Ratio -0.61 Tracking Error 0.153 Treynor Ratio 38.968 Total Fees $156.65 Estimated Strategy Capacity $0 Lowest Capacity Asset 600132.ChineseStocks 2S Portfolio Turnover 0.80% |
#region imports
from AlgorithmImports import *
import bz2
import pickle
import base64
import numpy as np
from typing import List, Dict, OrderedDict
#endregion
def initialize_QP_custom_data(algo: QCAlgorithm, leverage: int, top_count: int, period: int) -> Dict:
QP_data: Dict[Symbol, SymbolData] = {}
ticker_file_str: str = algo.download('data.quantpedia.com/backtesting_data/equity/chinese_stocks/large_cap_500.csv')
ticker_lines: List[str] = ticker_file_str.split('\r\n')[:top_count]
tickers = [ ticker_line.split(',')[0] for ticker_line in ticker_lines[1:] ]
for t in tickers:
# price data subscription
data: Security = algo.add_data(ChineseStocks, t, Resolution.DAILY)
data.set_fee_model(CustomFeeModel())
data.set_leverage(leverage)
stock_symbol: Symbol = data.symbol
QP_data[stock_symbol] = SymbolData(period)
return QP_data
class SymbolData():
def __init__(
self,
period: int,
) -> None:
# self._prices: List[float] = []
self._prices: RollingWindow = RollingWindow[float](period)
def _update_price(self, price: float) -> None:
self._prices.add(price)
def _is_ready(self) -> bool:
return self._prices.is_ready
def _get_percentile(self, percentile: float) -> np.float64:
returns: np.ndarray = pd.Series(list(self._prices)[::-1]).pct_change().values[1:]
return np.percentile(returns, percentile)
# Chinese stock price/volume data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class ChineseStocks(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return ChineseStocks._last_update_date
def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/chinese_stocks/large_cap_300_close_open_size.dat", SubscriptionTransportMedium.REMOTE_FILE, FileFormat.UNFOLDING_COLLECTION)
def Reader(self, config:SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseDataCollection:
# Columns: ['closePrice', 'turnoverVol', 'negMarketValue', 'marketValue']
# closePrice = daily close price
# turnoverVol = daily share volume
# marketValue = market cap
# negMarketValue = shares outstd * closePrice
#
# more can be calculated based of it:
#
# shares outstd = negMarketValue / closePrice
# turnoverValue = closePrice * turnoverVol
# turnoverRatio = turnoverValue / negMarketValue
objects:list[ChineseStocks] = []
base64_bytes = line.encode('ascii')
data_to_decompress = base64.b64decode(base64_bytes)
decompressed_data = bz2.decompress(data_to_decompress)
data:list[dict] = pickle.loads(decompressed_data)
for index, sample in enumerate(data):
custom_data: ChineseStocks = ChineseStocks()
custom_data.symbol = config.symbol
curr_date: datetime = datetime.strptime(sample['date'], '%Y-%m-%d')# + timedelta(days=1)
custom_data.Time = curr_date
custom_data.EndTime = curr_date + timedelta(days=1)
custom_data.Time = curr_date
if config.symbol.value in sample['stocks']:
custom_data['price_data'] = sample['stocks'][config.symbol.value]
custom_data.value = float(sample['stocks'][config.symbol.value]['openPrice'])
custom_data.close = float(sample['stocks'][config.symbol.value]['closePrice'])
custom_data.open = float(sample['stocks'][config.symbol.value]['openPrice'])
# store last date of the symbol
if config.symbol not in ChineseStocks._last_update_date:
ChineseStocks._last_update_date[config.symbol] = datetime(1,1,1).date()
if custom_data.Time.date() > ChineseStocks._last_update_date[config.symbol]:
ChineseStocks._last_update_date[config.symbol] = custom_data.Time.date()
else:
custom_data['price_data'] = {}
custom_data.Value = 0
objects.append(custom_data)
return BaseDataCollection(objects[-1].EndTime, config.symbol, objects)
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.security.price * parameters.order.absolute_quantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))# https://quantpedia.com/strategies/righ-tail-vs-left-tail-stock-picking-strategy-in-china
#
# The investment universe for this strategy consists of stocks listed on the Chinese stock market (China A-share common stocks traded on the Shanghai Stock
# Exchange (SHSE) and the Shenzhen Stock Exchange (SZSE)).
# (All data used in this paper are sourced from the China Stock Market & Accounting Research Database (CSMAR). The risk-free interest rate for calculating
# excess returns is based on the one-year China Treasury bond rate, applicable to both daily and monthly frequencies. Monthly excess returns on the market
# (MaT), size (SMB), value (HML), momentum (MOM), profitability (RMW), and investment (CMA) factors, as defined by Fama and French (2018), are also obtained
# from CSMAR.)
# Fundamental Recapitulation: Individual instruments are selected based on their Value at Risk (VaR) measures, explicitly focusing on the right-tail bonus
# (VaR95) and left-tail risk (VaR5). Stocks are chosen for long or short positions depending on their quintile rankings in these measures, as described in
# the research paper. The universe is mainly focused on stocks that attract significant retail investor attention, as these are more likely to exhibit the
# right-tail reversal phenomenon. This approach aims to exploit the mispricing caused by retail investors' lottery-like preferences.
# Computational Process: The trading rules involve calculating the VaR95 and VaR5 for each stock in the investment universe. The methodology consists of
# sorting stocks monthly into quintiles based on their VaR95 values to identify the right-tail bonus. Within each VaR95 quintile, stocks are further sorted
# into quintiles based on their VaR5 values to assess left-tail risk.
# Investment Strategy: Perform the investment strategy based on dependent double sorts of VaR95 (RT) and VaR5 (LT): quintile portfolios are formed every
# month based on right-tail bonus VaR95 first, and then additional quintile portfolios are formed based on left-tail risk VaR5 within each VaR95 quintile.
# The investment strategy longs stocks with the lowest right-tail bonus and highest left-tail risk, and
# shorts stocks with the highest right-tail bonus and lowest left-tail risk, i.e., RT1LT5-RT5LT1.
# Rebalancing & Weighting: The strategy involves rebalancing the portfolio monthly to adjust for changes in VaR95 and VaR5 values and maintain the desired
# exposure. To maintain a market-neutral stance, the quintile sorting process determines the number of positions, with equal capital allocated to each long
# and short position.
#
# QC Implementation changes:
# - QP large cap Chinese custom data are used as trading universe (data available from 2015 to 2022).
# region imports
from AlgorithmImports import *
import data_tools
from typing import List, Dict
# endregion
class RighTailVsLeftTailStockPickingStrategyInChina(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2015, 1, 1)
self.set_cash(100_000)
self._excluded_tickers: List[str] = ['601398', '601939', '600941', '300750', '601288']
self._quantile: int = 5
self._percentile: float = .95
top_count: int = 300
leverage: int = 5
period: int = 12 * 21
self._data: Dict[Symbol, data_tools.SymbolData] = data_tools.initialize_QP_custom_data(
self,
leverage,
top_count,
period,
)
self.settings.minimum_order_margin_portfolio_percentage = 0.
self.settings.daily_precise_end_time = False
self._current_month: int = -1
def on_data(self, slice: Slice) -> None:
# Check if custom data is still comming in.
price_last_update_date:Dict[Symbol, datetime.date] = data_tools.ChineseStocks.get_last_update_date()
if any(self.securities[x].get_last_data() and self.time.date() >= price_last_update_date[x] for x in price_last_update_date):
if self.portfolio.invested:
self.log('QP chinese custom data stopped coming.')
self.liquidate()
return
# Store daily price data.
for symbol, symbol_data in self._data.items():
if symbol.value in self._excluded_tickers:
continue
if slice.contains_key(symbol) and slice[symbol]:
price: float = slice[symbol].value
if price != 0:
self._data[symbol]._update_price(price)
# Monthly rebalance.
if self.time.month == self._current_month:
return
self._current_month = self.time.month
# Calculate Value at Risk (VaR) measures.
performance: Dict[Symbol, tuple[Any, Any]] = {
symbol: (symbol_data._get_percentile(self._percentile), symbol_data._get_percentile(1 - self._percentile))
for symbol, symbol_data in self._data.items()
if symbol_data._is_ready()
}
if len(performance) < self._quantile:
self.log('Not enough data to further sorting.')
return
# Sort and divide.
RT_sorted: List[tuple[Symbol, tuple[Any, Any]]] = sorted(performance.items(), key=lambda x:x[1][0])
RT_quantile: int = len(RT_sorted) // self._quantile
RT_low: List[Symbol] = [x[0] for x in RT_sorted][:RT_quantile]
RT_high: List[Symbol] = [x[0] for x in RT_sorted][-RT_quantile:]
LT_sorted: List[tuple[Symbol, tuple[Any, Any]]] = sorted(performance.items(), key=lambda x:x[1][1])
LT_quantile: int = len(LT_sorted) // self._quantile
LT_low: List[Symbol] = [x[0] for x in LT_sorted][:LT_quantile]
LT_high: List[Symbol] = [x[0] for x in LT_sorted][-LT_quantile:]
# Trade execution.
portions = {}
for i, portfolio in enumerate([RT_low + LT_high, RT_high + LT_low]):
for symbol in portfolio:
if slice.contains_key(symbol) and slice[symbol]:
if symbol not in portions:
portions[symbol] = 0
portions[symbol] += (((-1)**i) * (self.portfolio.total_portfolio_value / len(portfolio)))
invested: List[Symbol] = [x.key for x in self.portfolio if x.value.invested]
for symbol in invested:
if symbol not in portions:
self.liquidate(symbol)
for symbol, portion in portions.items():
if slice.contains_key(symbol) and slice[symbol]:
if slice[symbol].price != 0:
quantity: int = portion // slice[symbol].price
self.market_order(symbol, quantity - self.portfolio[symbol].quantity)