Skip to content

Commit d876466

Browse files
[BUGFIX] allow sell in limit-up case and allow buy in limit-down case in topk strategy (#1407)
* 1) check limit_up/down should consider direction; 2) fix some typo, typehint etc * fix error * Update test_all_pipeline.py Believe it's just some arbitrary number. The excess return is expected to change when trading logic changes. * add flag forbid_all_trade_at_limit to keep previous behivour for backward compatibility
1 parent 7f08e6c commit d876466

File tree

2 files changed

+36
-16
lines changed

2 files changed

+36
-16
lines changed

qlib/contrib/strategy/signal_strategy.py

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pandas as pd
88

99
from typing import Dict, List, Text, Tuple, Union
10+
from abc import ABC
1011

1112
from qlib.data import D
1213
from qlib.data.dataset import Dataset
@@ -17,11 +18,11 @@
1718
from qlib.backtest.decision import Order, OrderDir, TradeDecisionWO
1819
from qlib.log import get_module_logger
1920
from qlib.utils import get_pre_trading_date, load_dataset
20-
from qlib.contrib.strategy.order_generator import OrderGenWOInteract
21+
from qlib.contrib.strategy.order_generator import OrderGenerator, OrderGenWOInteract
2122
from qlib.contrib.strategy.optimizer import EnhancedIndexingOptimizer
2223

2324

24-
class BaseSignalStrategy(BaseStrategy):
25+
class BaseSignalStrategy(BaseStrategy, ABC):
2526
def __init__(
2627
self,
2728
*,
@@ -47,7 +48,7 @@ def __init__(
4748
- If `trade_exchange` is None, self.trade_exchange will be set with common_infra
4849
- It allowes different trade_exchanges is used in different executions.
4950
- For example:
50-
- In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it run faster.
51+
- In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it runs faster.
5152
- In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended.
5253
5354
"""
@@ -64,7 +65,7 @@ def __init__(
6465

6566
def get_risk_degree(self, trade_step=None):
6667
"""get_risk_degree
67-
Return the proportion of your total value you will used in investment.
68+
Return the proportion of your total value you will use in investment.
6869
Dynamically risk_degree will result in Market timing.
6970
"""
7071
# It will use 95% amount of your total value by default
@@ -76,6 +77,7 @@ class TopkDropoutStrategy(BaseSignalStrategy):
7677
# 1. Supporting leverage the get_range_limit result from the decision
7778
# 2. Supporting alter_outer_trade_decision
7879
# 3. Supporting checking the availability of trade decision
80+
# 4. Regenerate results with forbid_all_trade_at_limit set to false and flip the default to false, as it is consistent with reality.
7981
def __init__(
8082
self,
8183
*,
@@ -85,6 +87,7 @@ def __init__(
8587
method_buy="top",
8688
hold_thresh=1,
8789
only_tradable=False,
90+
forbid_all_trade_at_limit=True,
8891
**kwargs,
8992
):
9093
"""
@@ -111,6 +114,17 @@ def __init__(
111114
else:
112115
113116
strategy will make buy sell decision without checking the tradable state of the stock.
117+
forbid_all_trade_at_limit : bool
118+
if forbid all trades when limit_up or limit_down reached.
119+
120+
if forbid_all_trade_at_limit:
121+
122+
strategy will not do any trade when price reaches limit up/down, even not sell at limit up nor buy at
123+
limit down, though allowed in reality.
124+
125+
else:
126+
127+
strategy will sell at limit up and buy ad limit down.
114128
"""
115129
super().__init__(**kwargs)
116130
self.topk = topk
@@ -119,6 +133,7 @@ def __init__(
119133
self.method_buy = method_buy
120134
self.hold_thresh = hold_thresh
121135
self.only_tradable = only_tradable
136+
self.forbid_all_trade_at_limit = forbid_all_trade_at_limit
122137

123138
def generate_trade_decision(self, execute_result=None):
124139
# get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1]
@@ -161,7 +176,7 @@ def filter_stock(li):
161176
]
162177

163178
else:
164-
# Otherwise, the stock will make decision with out the stock tradable info
179+
# Otherwise, the stock will make decision without the stock tradable info
165180
def get_first_n(li, n):
166181
return list(li)[:n]
167182

@@ -171,7 +186,7 @@ def get_last_n(li, n):
171186
def filter_stock(li):
172187
return li
173188

174-
current_temp = copy.deepcopy(self.trade_position)
189+
current_temp: Position = copy.deepcopy(self.trade_position)
175190
# generate order list for this adjust date
176191
sell_order_list = []
177192
buy_order_list = []
@@ -216,7 +231,10 @@ def filter_stock(li):
216231
buy = today[: len(sell) + self.topk - len(last)]
217232
for code in current_stock_list:
218233
if not self.trade_exchange.is_stock_tradable(
219-
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
234+
stock_id=code,
235+
start_time=trade_start_time,
236+
end_time=trade_end_time,
237+
direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL,
220238
):
221239
continue
222240
if code in sell:
@@ -244,7 +262,7 @@ def filter_stock(li):
244262
cash += trade_val - trade_cost
245263
# buy new stock
246264
# note the current has been changed
247-
current_stock_list = current_temp.get_stock_list()
265+
# current_stock_list = current_temp.get_stock_list()
248266
value = cash * self.risk_degree / len(buy) if len(buy) > 0 else 0
249267

250268
# open_cost should be considered in the real trading environment, while the backtest in evaluate.py does not
@@ -253,7 +271,10 @@ def filter_stock(li):
253271
for code in buy:
254272
# check is stock suspended
255273
if not self.trade_exchange.is_stock_tradable(
256-
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
274+
stock_id=code,
275+
start_time=trade_start_time,
276+
end_time=trade_end_time,
277+
direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY,
257278
):
258279
continue
259280
# buy order
@@ -296,15 +317,15 @@ def __init__(
296317
- It allowes different trade_exchanges is used in different executions.
297318
- For example:
298319
299-
- In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it run faster.
320+
- In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it runs faster.
300321
- In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended.
301322
"""
302323
super().__init__(**kwargs)
303324

304325
if isinstance(order_generator_cls_or_obj, type):
305-
self.order_generator = order_generator_cls_or_obj()
326+
self.order_generator: OrderGenerator = order_generator_cls_or_obj()
306327
else:
307-
self.order_generator = order_generator_cls_or_obj
328+
self.order_generator: OrderGenerator = order_generator_cls_or_obj
308329

309330
def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time):
310331
"""
@@ -316,9 +337,8 @@ def generate_target_weight_position(self, score, current, trade_start_time, trad
316337
pred score for this trade date, index is stock_id, contain 'score' column.
317338
current : Position()
318339
current position.
319-
trade_exchange : Exchange()
320-
trade_date : pd.Timestamp
321-
trade date.
340+
trade_start_time: pd.Timestamp
341+
trade_end_time: pd.Timestamp
322342
"""
323343
raise NotImplementedError()
324344

tests/test_all_pipeline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def test_1_backtest(self):
165165
analyze_df = backtest_analysis(TestAllFlow.PRED_SCORE, TestAllFlow.RID, self.URI_PATH)
166166
self.assertGreaterEqual(
167167
analyze_df.loc(axis=0)["excess_return_with_cost", "annualized_return"].values[0],
168-
0.10,
168+
0.05,
169169
"backtest failed",
170170
)
171171
self.assertTrue(not analyze_df.isna().any().any(), "backtest failed")

0 commit comments

Comments
 (0)