2626
2727
2828class Exchange :
29+ # `quote_df` is a pd.DataFrame class that contains basic information for backtesting
30+ # After some processing, the data will later be maintained by `quote_cls` object for faster data retriving.
31+ # Some conventions for `quote_df`
32+ # - $close is for calculating the total value at end of each day.
33+ # - if $close is None, the stock on that day is reguarded as suspended.
34+ # - $factor is for rounding to the trading unit;
35+ # - if any $factor is missing when $close exists, trading unit rounding will be disabled
36+ quote_df : pd .DataFrame
37+
2938 def __init__ (
3039 self ,
3140 freq : str = "day" ,
@@ -159,6 +168,7 @@ def __init__(
159168 self .codes = codes
160169 # Necessary fields
161170 # $close is for calculating the total value at end of each day.
171+ # - if $close is None, the stock on that day is reguarded as suspended.
162172 # $factor is for rounding to the trading unit
163173 # $change is for calculating the limit of the stock
164174
@@ -199,7 +209,7 @@ def get_quote_from_qlib(self) -> None:
199209 self .end_time ,
200210 freq = self .freq ,
201211 disk_cache = True ,
202- ). dropna ( subset = [ "$close" ])
212+ )
203213 self .quote_df .columns = self .all_fields
204214
205215 # check buy_price data and sell_price data
@@ -209,7 +219,7 @@ def get_quote_from_qlib(self) -> None:
209219 self .logger .warning ("{} field data contains nan." .format (pstr ))
210220
211221 # update trade_w_adj_price
212- if self .quote_df ["$factor" ].isna ().any ():
222+ if ( self .quote_df ["$factor" ].isna () & ~ self . quote_df [ "$close" ]. isna () ).any ():
213223 # The 'factor.day.bin' file not exists, and `factor` field contains `nan`
214224 # Use adjusted price
215225 self .trade_w_adj_price = True
@@ -245,9 +255,9 @@ def get_quote_from_qlib(self) -> None:
245255 assert set (self .extra_quote .columns ) == set (self .quote_df .columns ) - {"$change" }
246256 self .quote_df = pd .concat ([self .quote_df , self .extra_quote ], sort = False , axis = 0 )
247257
248- LT_TP_EXP = "(exp)" # Tuple[str, str]
249- LT_FLT = "float" # float
250- LT_NONE = "none" # none
258+ LT_TP_EXP = "(exp)" # Tuple[str, str]: the limitation is calculated by a Qlib expression.
259+ LT_FLT = "float" # float: the trading limitation is based on `abs($change) < limit_threshold`
260+ LT_NONE = "none" # none: there is no trading limitation
251261
252262 def _get_limit_type (self , limit_threshold : Union [tuple , float , None ]) -> str :
253263 """get limit type"""
@@ -261,20 +271,25 @@ def _get_limit_type(self, limit_threshold: Union[tuple, float, None]) -> str:
261271 raise NotImplementedError (f"This type of `limit_threshold` is not supported" )
262272
263273 def _update_limit (self , limit_threshold : Union [Tuple , float , None ]) -> None :
274+ # $close is may contains NaN, the nan indicates that the stock is not tradable at that timestamp
275+ suspended = self .quote_df ["$close" ].isna ()
264276 # check limit_threshold
265277 limit_type = self ._get_limit_type (limit_threshold )
266278 if limit_type == self .LT_NONE :
267- self .quote_df ["limit_buy" ] = False
268- self .quote_df ["limit_sell" ] = False
279+ self .quote_df ["limit_buy" ] = suspended
280+ self .quote_df ["limit_sell" ] = suspended
269281 elif limit_type == self .LT_TP_EXP :
270282 # set limit
271283 limit_threshold = cast (tuple , limit_threshold )
272- self .quote_df ["limit_buy" ] = self .quote_df [limit_threshold [0 ]]
273- self .quote_df ["limit_sell" ] = self .quote_df [limit_threshold [1 ]]
284+ # astype bool is necessary, because quote_df is an expression and could be float
285+ self .quote_df ["limit_buy" ] = self .quote_df [limit_threshold [0 ]].astype ("bool" ) | suspended
286+ self .quote_df ["limit_sell" ] = self .quote_df [limit_threshold [1 ]].astype ("bool" ) | suspended
274287 elif limit_type == self .LT_FLT :
275288 limit_threshold = cast (float , limit_threshold )
276- self .quote_df ["limit_buy" ] = self .quote_df ["$change" ].ge (limit_threshold )
277- self .quote_df ["limit_sell" ] = self .quote_df ["$change" ].le (- limit_threshold ) # pylint: disable=E1130
289+ self .quote_df ["limit_buy" ] = self .quote_df ["$change" ].ge (limit_threshold ) | suspended
290+ self .quote_df ["limit_sell" ] = (
291+ self .quote_df ["$change" ].le (- limit_threshold ) | suspended
292+ ) # pylint: disable=E1130
278293
279294 @staticmethod
280295 def _get_vol_limit (volume_threshold : Union [tuple , dict , None ]) -> Tuple [Optional [list ], Optional [list ], set ]:
@@ -338,8 +353,18 @@ def check_stock_limit(
338353 - if direction is None, check if tradable for buying and selling.
339354 - if direction == Order.BUY, check the if tradable for buying
340355 - if direction == Order.SELL, check the sell limit for selling.
356+
357+ Returns
358+ -------
359+ True: the trading of the stock is limted (maybe hit the highest/lowest price), hence the stock is not tradable
360+ False: the trading of the stock is not limited, hence the stock may be tradable
341361 """
362+ # NOTE:
363+ # **all** is used when checking limitation.
364+ # For example, the stock trading is limited in a day if every miniute is limited in a day if every miniute is limited.
342365 if direction is None :
366+ # The trading limitation is related to the trading direction
367+ # if the direction is not provided, then any limitation from buy or sell will result in trading limitation
343368 buy_limit = self .quote .get_data (stock_id , start_time , end_time , field = "limit_buy" , method = "all" )
344369 sell_limit = self .quote .get_data (stock_id , start_time , end_time , field = "limit_sell" , method = "all" )
345370 return bool (buy_limit or sell_limit )
@@ -356,10 +381,24 @@ def check_stock_suspended(
356381 start_time : pd .Timestamp ,
357382 end_time : pd .Timestamp ,
358383 ) -> bool :
384+ """if stock is suspended(hence not tradable), True will be returned"""
359385 # is suspended
360386 if stock_id in self .quote .get_all_stock ():
361- return self .quote .get_data (stock_id , start_time , end_time , "$close" ) is None
387+ # suspended stocks are represented by None $close stock
388+ # The $close may contains NaN,
389+ close = self .quote .get_data (stock_id , start_time , end_time , "$close" )
390+ if close is None :
391+ # if no close record exists
392+ return True
393+ elif isinstance (close , IndexData ):
394+ # **any** non-NaN $close represents trading opportunity may exists
395+ # if all returned is nan, then the stock is suspended
396+ return cast (bool , cast (IndexData , close ).isna ().all ())
397+ else :
398+ # it is single value, make sure is is not None
399+ return np .isnan (close )
362400 else :
401+ # if the stock is not in the stock list, then it is not tradable and regarded as suspended
363402 return True
364403
365404 def is_stock_tradable (
0 commit comments