start experiment 73 (exp/73-scheduled-algo-trade-run-for-target-date)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from .optimal_stop import OptimalStopControl # noqa: F401
|
||||
from .long_short import LongShortTopkStrategy # noqa: F401
|
||||
|
||||
__all__ = ["OptimalStopControl", "LongShortTopkStrategy"]
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Fractional-Kelly dropout strategy for cross-sectional signals.
|
||||
|
||||
Sizing rule variant of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``:
|
||||
the topk/n_drop SELECTION is identical to the reference, but the buy size is
|
||||
proportional to the score MAGNITUDE (edge) instead of equal-weight, capped at a
|
||||
fraction ``cap_frac`` of the equal-weight notional so a single name cannot
|
||||
over-concentrate the book.
|
||||
|
||||
``cap_frac`` is the fraction of the equal-weight per-name notional that a top
|
||||
signal can deploy at most (e.g. 0.5 = at most half the equal-weight size).
|
||||
Names whose score is below the median of the buy set get a proportionally
|
||||
smaller slice; the residual stays in cash (that is the point of the rule:
|
||||
throw away less edge per name, deploy less capital when conviction is low).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from qlib.backtest import Order
|
||||
from qlib.backtest.decision import OrderDir, TradeDecisionWO
|
||||
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
|
||||
|
||||
__all__ = ["FractionalKellyDropoutStrategy"]
|
||||
|
||||
DEFAULT_CAP_FRAC = 0.5
|
||||
|
||||
|
||||
class FractionalKellyDropoutStrategy(TopkDropoutStrategy):
|
||||
"""TopkDropout selection with score-magnitude (fractional-Kelly) sizing.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable,
|
||||
forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``.
|
||||
cap_frac : max buy notional as a fraction of the equal-weight notional.
|
||||
"""
|
||||
|
||||
def __init__(self, *, topk, n_drop, cap_frac: float = DEFAULT_CAP_FRAC, **kwargs):
|
||||
super().__init__(topk=topk, n_drop=n_drop, **kwargs)
|
||||
self.cap_frac = cap_frac
|
||||
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
import copy
|
||||
|
||||
trade_step = self.trade_calendar.get_trade_step()
|
||||
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
||||
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
|
||||
if isinstance(pred_score, pd.DataFrame):
|
||||
pred_score = pred_score.iloc[:, 0]
|
||||
if pred_score is None:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
if self.only_tradable:
|
||||
|
||||
def get_first_n(li, n, reverse=False):
|
||||
cur_n = 0
|
||||
res = []
|
||||
for si in reversed(li) if reverse else li:
|
||||
if self.trade_exchange.is_stock_tradable(
|
||||
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
|
||||
):
|
||||
res.append(si)
|
||||
cur_n += 1
|
||||
if cur_n >= n:
|
||||
break
|
||||
return res[::-1] if reverse else res
|
||||
|
||||
def get_last_n(li, n):
|
||||
return get_first_n(li, n, reverse=True)
|
||||
|
||||
def filter_stock(li):
|
||||
return [
|
||||
si
|
||||
for si in li
|
||||
if self.trade_exchange.is_stock_tradable(
|
||||
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
]
|
||||
|
||||
else:
|
||||
|
||||
def get_first_n(li, n):
|
||||
return list(li)[:n]
|
||||
|
||||
def get_last_n(li, n):
|
||||
return list(li)[-n:]
|
||||
|
||||
def filter_stock(li):
|
||||
return li
|
||||
|
||||
current_temp: "object" = copy.deepcopy(self.trade_position)
|
||||
sell_order_list: List[Order] = []
|
||||
buy_order_list: List[Order] = []
|
||||
cash = current_temp.get_cash()
|
||||
current_stock_list = current_temp.get_stock_list()
|
||||
last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index
|
||||
|
||||
if self.method_buy == "top":
|
||||
today = get_first_n(
|
||||
pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index,
|
||||
self.n_drop + self.topk - len(last),
|
||||
)
|
||||
elif self.method_buy == "random":
|
||||
topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk)
|
||||
candi = list(filter(lambda x: x not in last, topk_candi))
|
||||
n = self.n_drop + self.topk - len(last)
|
||||
try:
|
||||
today = np.random.choice(candi, n, replace=False)
|
||||
except ValueError:
|
||||
today = candi
|
||||
else:
|
||||
raise NotImplementedError(f"This type of input is not supported")
|
||||
|
||||
comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index
|
||||
|
||||
if self.method_sell == "bottom":
|
||||
sell = last[last.isin(get_last_n(comb, self.n_drop))]
|
||||
elif self.method_sell == "random":
|
||||
candi = filter_stock(last)
|
||||
try:
|
||||
sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else [])
|
||||
except ValueError:
|
||||
sell = candi
|
||||
else:
|
||||
raise NotImplementedError(f"This type of input is not supported")
|
||||
|
||||
buy = today[: len(sell) + self.topk - len(last)]
|
||||
for code in current_stock_list:
|
||||
if not self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL,
|
||||
):
|
||||
continue
|
||||
if code in sell:
|
||||
time_per_step = self.trade_calendar.get_freq()
|
||||
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
|
||||
continue
|
||||
sell_amount = current_temp.get_stock_amount(code=code)
|
||||
sell_order = Order(
|
||||
stock_id=code,
|
||||
amount=sell_amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.SELL,
|
||||
)
|
||||
if self.trade_exchange.check_order(sell_order):
|
||||
sell_order_list.append(sell_order)
|
||||
trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(
|
||||
sell_order, position=current_temp
|
||||
)
|
||||
cash += trade_val - trade_cost
|
||||
|
||||
if len(buy) == 0:
|
||||
return TradeDecisionWO(sell_order_list, self)
|
||||
|
||||
# ---- fractional-Kelly sizing --------------------------------------
|
||||
# equal-weight notional (reference baseline)
|
||||
eq_notional = cash * self.risk_degree / len(buy)
|
||||
buy_scores = pred_score.reindex(buy).astype(float)
|
||||
lo, hi = buy_scores.min(), buy_scores.max()
|
||||
if hi == lo:
|
||||
w = pd.Series(1.0, index=buy_scores.index)
|
||||
else:
|
||||
w = (buy_scores - lo) / (hi - lo) # [0,1] edge magnitude
|
||||
w = w.clip(lower=0.0)
|
||||
w_max = w.max()
|
||||
w = w / w_max if w_max > 0 else w # max == 1.0
|
||||
for code in buy:
|
||||
if not self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY,
|
||||
):
|
||||
continue
|
||||
buy_price = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY
|
||||
)
|
||||
notional = eq_notional * min(self.cap_frac, float(w.get(code, 0.0)))
|
||||
buy_amount = notional / buy_price
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor)
|
||||
buy_order = Order(
|
||||
stock_id=code,
|
||||
amount=buy_amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.BUY,
|
||||
)
|
||||
buy_order_list.append(buy_order)
|
||||
|
||||
return TradeDecisionWO(sell_order_list + buy_order_list, self)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Long-short Top-K strategy for cross-sectional signals.
|
||||
|
||||
Each day the strategy ranks the cross-section by prediction score and rebalances
|
||||
to an equal-weight two-sided book: the ``topk`` highest-ranked names go long and
|
||||
the ``k_short`` lowest-ranked names go short. Net-new shorts are opened by
|
||||
selling beyond current holdings, which requires a short-aware exchange such as
|
||||
``tac_qlib.contrib.backtest.tradeac_exchange.TradeACExchange`` with
|
||||
``allow_short=True`` (borrow limits, margin requirements and borrow fees are
|
||||
enforced there, not here).
|
||||
|
||||
Sizing deploys ``equity * risk_degree`` as gross notional split evenly across
|
||||
all long and short legs, so the book is approximately market neutral.
|
||||
``allow_short=False`` disables the short side entirely (long-only ``topk``).
|
||||
|
||||
Short eligibility can be restricted further, with static or dynamic gates:
|
||||
``short_whitelist`` limits shorts to an explicit symbol set; ``short_vol_top_pct``
|
||||
requires a candidate's trailing realized volatility to rank in the top fraction
|
||||
of that day's cross-section; ``short_max_mom`` (falling-knife filter) only
|
||||
allows shorting names whose own trailing momentum is at/below a threshold;
|
||||
``short_regime_sma`` disables shorts entirely while the benchmark trades above
|
||||
its moving average (risk-on). Borrow availability itself is enforced by the
|
||||
exchange (``borrowable`` whitelist / per-symbol caps via ``TradeACExchange``).
|
||||
|
||||
Wired into qrun workflows like any ``BaseStrategy`` (see ``PortAnaRecord``
|
||||
config). Mirrors the API usage of qlib's ``TopkDropoutStrategy``: ``Order`` /
|
||||
``OrderDir`` from ``qlib.backtest.decision``, ``trade_calendar`` /
|
||||
``trade_exchange`` / ``trade_position`` injected by the backtest executor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from qlib.backtest import Order
|
||||
from qlib.backtest.decision import OrderDir, TradeDecisionWO
|
||||
from qlib.contrib.strategy.signal_strategy import BaseSignalStrategy
|
||||
from qlib.log import get_module_logger
|
||||
|
||||
__all__ = ["LongShortTopkStrategy"]
|
||||
|
||||
|
||||
class LongShortTopkStrategy(BaseSignalStrategy):
|
||||
"""Equal-weight long-short Top-K strategy over a cross-sectional signal.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk : number of long legs (highest-ranked names).
|
||||
k_short : number of short legs (lowest-ranked names).
|
||||
hold_thresh : minimum holding days before a leg may be closed/reduced.
|
||||
only_tradable : only select candidates tradable on the trade date.
|
||||
rebalance_tol : skip rebalances smaller than this fraction of a leg's
|
||||
target notional (turnover control).
|
||||
allow_short : enable/disable the short side. With ``False`` the bottom-ranked
|
||||
legs are dropped and the book is long-only ``topk``; pair with
|
||||
``allow_short=False`` on the exchange for a fully borrow-free run.
|
||||
Legacy alias ``enable_short`` is accepted.
|
||||
short_whitelist : optional list of symbols eligible for shorting; candidates
|
||||
outside the list are skipped (``None`` = all names eligible).
|
||||
short_vol_window : trailing window (trading days) for realized-vol estimation.
|
||||
short_vol_top_pct : if set, a short candidate's trailing realized volatility
|
||||
must rank at or above this percentile of that day's cross-section
|
||||
(e.g. ``0.5`` = only the more volatile half may be shorted). Candidates
|
||||
without measurable vol are never shorted.
|
||||
short_mom_window : trailing window (trading days) for the candidate momentum
|
||||
used by the falling-knife gate.
|
||||
short_max_mom : if set, a candidate's trailing ``short_mom_window``-day return
|
||||
must be <= this value to be shortable (e.g. ``0.0`` = only short names
|
||||
that are actually falling). Candidates without measurable momentum are
|
||||
never shorted.
|
||||
short_regime_symbol : benchmark symbol for the regime gate (default SPY).
|
||||
short_regime_sma : if set, shorts are only allowed on days where the regime
|
||||
symbol's last close (strictly before the execution bar) is BELOW its
|
||||
``short_regime_sma``-day moving average — i.e. shorts are disabled in
|
||||
risk-on regimes and enabled in drawdowns.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
signal=None,
|
||||
topk: int = 4,
|
||||
k_short: int = 2,
|
||||
hold_thresh: int = 1,
|
||||
only_tradable: bool = True,
|
||||
rebalance_tol: float = 0.05,
|
||||
allow_short: Optional[bool] = None,
|
||||
enable_short: Optional[bool] = None,
|
||||
short_whitelist: Optional[List[str]] = None,
|
||||
short_vol_window: int = 20,
|
||||
short_vol_top_pct: Optional[float] = None,
|
||||
short_mom_window: int = 20,
|
||||
short_max_mom: Optional[float] = None,
|
||||
short_regime_symbol: str = "SPY",
|
||||
short_regime_sma: Optional[int] = None,
|
||||
risk_degree: float = 0.95,
|
||||
trade_exchange=None,
|
||||
level_infra=None,
|
||||
common_infra=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
signal=signal,
|
||||
risk_degree=risk_degree,
|
||||
trade_exchange=trade_exchange,
|
||||
level_infra=level_infra,
|
||||
common_infra=common_infra,
|
||||
**kwargs,
|
||||
)
|
||||
if allow_short is None:
|
||||
allow_short = True if enable_short is None else bool(enable_short)
|
||||
self.allow_short = bool(allow_short)
|
||||
self.topk = topk
|
||||
self.k_short = k_short
|
||||
self.hold_thresh = hold_thresh
|
||||
self.only_tradable = only_tradable
|
||||
self.rebalance_tol = rebalance_tol
|
||||
self.short_whitelist = set(short_whitelist) if short_whitelist is not None else None
|
||||
if not 0 < float(short_vol_window) <= 1000:
|
||||
raise ValueError(f"short_vol_window must be in (0, 1000], got {short_vol_window}")
|
||||
self.short_vol_window = int(short_vol_window)
|
||||
if short_vol_top_pct is not None and not 0.0 < float(short_vol_top_pct) <= 1.0:
|
||||
raise ValueError(f"short_vol_top_pct must be in (0, 1], got {short_vol_top_pct}")
|
||||
self.short_vol_top_pct = None if short_vol_top_pct is None else float(short_vol_top_pct)
|
||||
if not 0 < float(short_mom_window) <= 1000:
|
||||
raise ValueError(f"short_mom_window must be in (0, 1000], got {short_mom_window}")
|
||||
self.short_mom_window = int(short_mom_window)
|
||||
self.short_max_mom = None if short_max_mom is None else float(short_max_mom)
|
||||
self.short_regime_symbol = str(short_regime_symbol)
|
||||
if short_regime_sma is not None and not 1 < int(short_regime_sma) <= 1000:
|
||||
raise ValueError(f"short_regime_sma must be in (1, 1000], got {short_regime_sma}")
|
||||
self.short_regime_sma = None if short_regime_sma is None else int(short_regime_sma)
|
||||
# per-day caches (keyed by trade date)
|
||||
self._vol_cache_key: Optional[str] = None
|
||||
self._vol_cache_val: Dict[str, Dict[str, float]] = {}
|
||||
self._regime_cache: Dict[str, bool] = {}
|
||||
|
||||
# ------------------------------------------------------------------ utils
|
||||
def _is_tradable(self, code, start, end) -> bool:
|
||||
if not self.only_tradable:
|
||||
return True
|
||||
try:
|
||||
return self.trade_exchange.is_stock_tradable(stock_id=code, start_time=start, end_time=end)
|
||||
except TypeError:
|
||||
return True
|
||||
|
||||
def _mark_price(self, code, start, end) -> Optional[float]:
|
||||
try:
|
||||
px = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=start, end_time=end, direction=OrderDir.BUY
|
||||
)
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
if px is None or px != px or px <= 0:
|
||||
return None
|
||||
return float(px)
|
||||
|
||||
def _day_stats(self, codes: List[str], trade_start) -> Dict[str, Dict[str, float]]:
|
||||
"""Per-day cross-sectional stats used by the dynamic short gates.
|
||||
|
||||
For each code, returns ``{"vol_rank": r}`` (percentile of trailing
|
||||
realized vol over ``short_vol_window`` bars across that day's
|
||||
cross-section) when the vol gate is on, and ``{"mom": m}`` (trailing
|
||||
``short_mom_window``-bar return) when the falling-knife gate is on.
|
||||
All series end on the last bar strictly BEFORE the execution bar (no
|
||||
lookahead). Codes without measurable data are simply absent — such
|
||||
candidates are never shorted (fail-closed).
|
||||
"""
|
||||
if self.short_vol_top_pct is None and self.short_max_mom is None:
|
||||
return {}
|
||||
key = str(pd.Timestamp(trade_start))
|
||||
if self._vol_cache_key == key:
|
||||
return self._vol_cache_val
|
||||
out: Dict[str, Dict[str, float]] = {}
|
||||
try:
|
||||
from qlib.data import D
|
||||
|
||||
end = pd.Timestamp(trade_start)
|
||||
buf = max(self.short_vol_window, self.short_mom_window) * 3 + 30
|
||||
df = D.features(
|
||||
sorted(codes),
|
||||
["$close"],
|
||||
start_time=end - pd.Timedelta(days=buf),
|
||||
end_time=end - pd.Timedelta(days=1),
|
||||
)
|
||||
close = df["$close"].unstack(level="instrument") if isinstance(df.index, pd.MultiIndex) else df["$close"]
|
||||
if self.short_vol_top_pct is not None:
|
||||
vol = close.pct_change().rolling(self.short_vol_window).std().iloc[-1]
|
||||
for code, rank in vol.rank(pct=True).dropna().items():
|
||||
out.setdefault(str(code), {})["vol_rank"] = float(rank)
|
||||
if self.short_max_mom is not None:
|
||||
w = min(self.short_mom_window, len(close) - 1)
|
||||
mom = close.iloc[-1] / close.iloc[-(w + 1)] - 1
|
||||
for code, m in mom.items():
|
||||
if m == m:
|
||||
out.setdefault(str(code), {})["mom"] = float(m)
|
||||
except Exception as e: # noqa: BLE001 - degrade to fail-closed (no shorts)
|
||||
get_module_logger(self.__class__.__name__).warning(
|
||||
f"short gates unavailable ({type(e).__name__}: {e}); no shorts this step"
|
||||
)
|
||||
self._vol_cache_key, self._vol_cache_val = key, out
|
||||
return out
|
||||
|
||||
def _regime_ok(self, trade_start) -> bool:
|
||||
"""True when shorting is allowed by the benchmark-regime gate.
|
||||
|
||||
With ``short_regime_sma`` set, shorts are permitted only while the
|
||||
regime symbol's last close strictly before the execution bar sits below
|
||||
its moving average (risk-off). Data failure fails closed (no shorts).
|
||||
"""
|
||||
if self.short_regime_sma is None:
|
||||
return True
|
||||
key = str(pd.Timestamp(trade_start))
|
||||
cached = self._regime_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
ok = False
|
||||
try:
|
||||
from qlib.data import D
|
||||
|
||||
end = pd.Timestamp(trade_start)
|
||||
df = D.features(
|
||||
[self.short_regime_symbol],
|
||||
["$close"],
|
||||
start_time=end - pd.Timedelta(days=int(self.short_regime_sma * 3 + 30)),
|
||||
end_time=end - pd.Timedelta(days=1),
|
||||
)
|
||||
s = df["$close"]
|
||||
if isinstance(s.index, pd.MultiIndex):
|
||||
s = s.droplevel("instrument")
|
||||
sma = s.rolling(self.short_regime_sma).mean().iloc[-1]
|
||||
px = s.iloc[-1]
|
||||
ok = bool(px < sma)
|
||||
except Exception as e: # noqa: BLE001 - degrade to fail-closed (no shorts)
|
||||
get_module_logger(self.__class__.__name__).warning(
|
||||
f"regime gate unavailable ({type(e).__name__}: {e}); no shorts this step"
|
||||
)
|
||||
self._regime_cache[key] = ok
|
||||
return ok
|
||||
|
||||
# ------------------------------------------------------------- decision
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
trade_step = self.trade_calendar.get_trade_step()
|
||||
trade_start, trade_end = self.trade_calendar.get_step_time(trade_step)
|
||||
pred_start, pred_end = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||
pred_score = self.signal.get_signal(start_time=pred_start, end_time=pred_end)
|
||||
if isinstance(pred_score, pd.DataFrame):
|
||||
pred_score = pred_score.iloc[:, 0]
|
||||
if pred_score is None or len(pred_score) == 0:
|
||||
return TradeDecisionWO([], self)
|
||||
pred_score = pred_score.dropna()
|
||||
if pred_score.empty:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
time_per_step = self.trade_calendar.get_freq()
|
||||
current_temp = copy.deepcopy(self.trade_position)
|
||||
|
||||
# ---- signed current holdings ---------------------------------------
|
||||
cur_amount: Dict[str, float] = {}
|
||||
for code in current_temp.get_stock_list():
|
||||
amt = float(current_temp.get_stock_amount(code))
|
||||
if abs(amt) > 1e-6:
|
||||
cur_amount[code] = amt
|
||||
|
||||
# ---- targets: top-k long, bottom-k_short short ----------------------
|
||||
ranked = list(pred_score.sort_values(ascending=False).index)
|
||||
longs: List[str] = []
|
||||
for code in ranked:
|
||||
if len(longs) >= self.topk:
|
||||
break
|
||||
if self._is_tradable(code, trade_start, trade_end):
|
||||
longs.append(code)
|
||||
shorts: List[str] = []
|
||||
if self.allow_short and self._regime_ok(trade_start):
|
||||
stats = self._day_stats(list(ranked), trade_start)
|
||||
for code in reversed(ranked):
|
||||
if len(shorts) >= self.k_short:
|
||||
break
|
||||
if code in longs:
|
||||
continue
|
||||
if not self._is_tradable(code, trade_start, trade_end):
|
||||
continue
|
||||
if self.short_whitelist is not None and code not in self.short_whitelist:
|
||||
continue
|
||||
st = stats.get(code)
|
||||
if self.short_vol_top_pct is not None:
|
||||
rank = None if st is None else st.get("vol_rank")
|
||||
if rank is None or rank < self.short_vol_top_pct:
|
||||
continue
|
||||
if self.short_max_mom is not None:
|
||||
mom = None if st is None else st.get("mom")
|
||||
if mom is None or mom > self.short_max_mom:
|
||||
continue
|
||||
shorts.append(code)
|
||||
|
||||
# ---- marks & equity --------------------------------------------------
|
||||
marks: Dict[str, float] = {}
|
||||
for code in set(cur_amount) | set(longs) | set(shorts):
|
||||
px = self._mark_price(code, trade_start, trade_end)
|
||||
if px is not None:
|
||||
marks[code] = px
|
||||
|
||||
equity = current_temp.get_cash()
|
||||
for code, amt in cur_amount.items():
|
||||
if code in marks:
|
||||
equity += amt * marks[code]
|
||||
if equity <= 0:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
n_legs = len([c for c in longs if c in marks]) + len([c for c in shorts if c in marks])
|
||||
if n_legs == 0:
|
||||
return TradeDecisionWO([], self)
|
||||
per_leg = equity * self.risk_degree / n_legs
|
||||
|
||||
target_signed: Dict[str, float] = {}
|
||||
for code in longs:
|
||||
if code in marks:
|
||||
target_signed[code] = per_leg / marks[code]
|
||||
for code in shorts:
|
||||
if code in marks:
|
||||
target_signed[code] = -(per_leg / marks[code])
|
||||
|
||||
# ---- order generation -------------------------------------------------
|
||||
sell_orders: List[Order] = []
|
||||
buy_orders: List[Order] = []
|
||||
|
||||
def submit(code: str, amount: float, direction: int) -> None:
|
||||
factor = self.trade_exchange.get_factor(stock_id=code, start_time=trade_start, end_time=trade_end)
|
||||
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
|
||||
if amount <= 1e-6:
|
||||
return
|
||||
o = Order(stock_id=code, amount=amount, start_time=trade_start, end_time=trade_end, direction=direction)
|
||||
if self.trade_exchange.check_order(o):
|
||||
(buy_orders if direction == Order.BUY else sell_orders).append(o)
|
||||
|
||||
# close holdings that are no longer targeted (frees cash / unwinds shorts)
|
||||
for code, amt in cur_amount.items():
|
||||
if code in target_signed:
|
||||
continue
|
||||
if marks.get(code) is None:
|
||||
continue
|
||||
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
|
||||
continue
|
||||
submit(code, abs(amt), Order.SELL if amt > 0 else Order.BUY)
|
||||
|
||||
# rebalance targeted legs toward their signed target quantity
|
||||
for code, tgt in target_signed.items():
|
||||
cur = cur_amount.get(code, 0.0)
|
||||
delta = tgt - cur
|
||||
if abs(delta * marks[code]) < max(self.rebalance_tol * per_leg, 1.0):
|
||||
continue
|
||||
if delta > 0:
|
||||
submit(code, delta, Order.BUY)
|
||||
else:
|
||||
if cur > 0 and current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
|
||||
continue
|
||||
submit(code, -delta, Order.SELL)
|
||||
|
||||
return TradeDecisionWO(sell_orders + buy_orders, self)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Optimal-stopping / stochastic-control strategy for cross-sectional signals.
|
||||
|
||||
Entry is a control policy: a symbol opens a position only when its cross-sectional
|
||||
signal percentile is at or above ``entry_pct`` (i.e. it is one of the top-ranked
|
||||
names) and the portfolio has fewer than ``topk`` open positions.
|
||||
|
||||
Exit is an optimal-stopping rule: a held position is stopped (closed) when its
|
||||
signal percentile falls below ``exit_pct`` (the continuation value of holding is
|
||||
no longer worth the risk), OR after ``max_hold_days`` (time stop / finite
|
||||
horizon), OR when the position P&L breaches ``sl`` (loss control) and the
|
||||
position has been held at least ``min_hold_days``.
|
||||
|
||||
Sizing is fixed ``notional`` per position (equal-weight control), unlike the
|
||||
TopkDropout cash-allocation heuristic.
|
||||
|
||||
Wired into qrun workflows like any ``BaseStrategy`` (see ``PortAnaRecord``
|
||||
config). Mirrors the API usage of qlib's ``TopkDropoutStrategy``: ``Order``/
|
||||
``OrderDir`` from ``qlib.backtest.decision``, ``trade_calendar`` /
|
||||
``trade_exchange`` / ``trade_position`` injected by the backtest executor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from qlib.backtest import Order
|
||||
from qlib.backtest.decision import OrderDir, TradeDecisionWO
|
||||
from qlib.contrib.strategy.signal_strategy import BaseSignalStrategy
|
||||
|
||||
__all__ = ["OptimalStopControl"]
|
||||
|
||||
DEFAULT_NOTIONAL = 20_000.0
|
||||
DEFAULT_ENTRY_PCT = 0.80
|
||||
DEFAULT_EXIT_PCT = 0.50
|
||||
DEFAULT_MAX_HOLD_DAYS = 10
|
||||
DEFAULT_MIN_HOLD_DAYS = 2
|
||||
DEFAULT_SL = -0.06
|
||||
|
||||
|
||||
class OptimalStopControl(BaseSignalStrategy):
|
||||
"""Optimal-stopping long-only strategy over a cross-sectional signal.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk : max number of concurrent positions.
|
||||
entry_pct : min cross-sectional score percentile required to OPEN (0..1).
|
||||
exit_pct : held positions are stopped when score percentile < exit_pct.
|
||||
max_hold_days : hard time stop (finite-horizon close).
|
||||
min_hold_days : minimum holding days before stop-loss is evaluated.
|
||||
notional : $ per position (equal-weight control).
|
||||
sl : stop-loss threshold as fraction of entry price (<= 0), disabled if 0.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
signal=None,
|
||||
topk: int = 10,
|
||||
entry_pct: float = DEFAULT_ENTRY_PCT,
|
||||
exit_pct: float = DEFAULT_EXIT_PCT,
|
||||
max_hold_days: int = DEFAULT_MAX_HOLD_DAYS,
|
||||
min_hold_days: int = DEFAULT_MIN_HOLD_DAYS,
|
||||
notional: float = DEFAULT_NOTIONAL,
|
||||
sl: float = DEFAULT_SL,
|
||||
risk_degree: float = 0.95,
|
||||
trade_exchange=None,
|
||||
level_infra=None,
|
||||
common_infra=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
signal=signal,
|
||||
trade_exchange=trade_exchange,
|
||||
level_infra=level_infra,
|
||||
common_infra=common_infra,
|
||||
**kwargs,
|
||||
)
|
||||
self.topk = topk
|
||||
self.entry_pct = entry_pct
|
||||
self.exit_pct = exit_pct
|
||||
self.max_hold_days = max_hold_days
|
||||
self.min_hold_days = min_hold_days
|
||||
self.notional = notional
|
||||
self.sl = sl
|
||||
|
||||
# ------------------------------------------------------------------ utils
|
||||
@staticmethod
|
||||
def _pct_rank(score: pd.Series) -> pd.Series:
|
||||
return score.rank(pct=True)
|
||||
|
||||
def _entry_price(self, pos) -> float:
|
||||
# Position stores avg entry price under key "price" (see Position.position)
|
||||
price = pos.position.get("price")
|
||||
if price is None:
|
||||
price = pos.get_stock_amount("price")
|
||||
return float(price)
|
||||
|
||||
def _pnl_pct(self, pos, mark: float) -> float:
|
||||
entry = self._entry_price(pos)
|
||||
if not entry or entry != entry:
|
||||
return 0.0
|
||||
return mark / entry - 1.0
|
||||
|
||||
def _is_tradable(self, code, start, end, direction) -> bool:
|
||||
try:
|
||||
return self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code, start_time=start, end_time=end, direction=direction
|
||||
)
|
||||
except TypeError: # some exchanges take no direction kwarg
|
||||
return self.trade_exchange.is_stock_tradable(stock_id=code, start_time=start, end_time=end)
|
||||
|
||||
# ------------------------------------------------------------ decision
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
trade_step = self.trade_calendar.get_trade_step()
|
||||
trade_start, trade_end = self.trade_calendar.get_step_time(trade_step)
|
||||
pred_start, pred_end = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||
pred_score = self.signal.get_signal(start_time=pred_start, end_time=pred_end)
|
||||
if isinstance(pred_score, pd.DataFrame):
|
||||
pred_score = pred_score.iloc[:, 0]
|
||||
if pred_score is None or len(pred_score) == 0:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
pct = self._pct_rank(pred_score)
|
||||
time_per_step = self.trade_calendar.get_freq()
|
||||
current_temp = __import__("copy").deepcopy(self.trade_position)
|
||||
|
||||
holdings = {}
|
||||
for code in current_temp.get_stock_list():
|
||||
if abs(current_temp.get_stock_amount(code)) > 1e-6:
|
||||
holdings[code] = current_temp
|
||||
|
||||
# ---- optimal stopping: close held positions -----------------------
|
||||
sell_orders: List[Order] = []
|
||||
closed_today = set()
|
||||
kept = {}
|
||||
for code, pos in holdings.items():
|
||||
held = current_temp.get_stock_count(code, bar=time_per_step)
|
||||
mark = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start, end_time=trade_end, direction=Order.SELL
|
||||
)
|
||||
if mark is None or mark != mark:
|
||||
continue
|
||||
rank = pct.get(code, 0.0)
|
||||
stop_pnl = held >= self.min_hold_days and self.sl < 0 and self._pnl_pct(pos, mark) <= self.sl
|
||||
if held >= self.max_hold_days or rank < self.exit_pct or stop_pnl:
|
||||
amt = abs(current_temp.get_stock_amount(code))
|
||||
o = Order(stock_id=code, amount=amt, start_time=trade_start,
|
||||
end_time=trade_end, direction=Order.SELL)
|
||||
if self.trade_exchange.check_order(o):
|
||||
sell_orders.append(o)
|
||||
self.trade_exchange.deal_order(o, position=current_temp)
|
||||
closed_today.add(code)
|
||||
else:
|
||||
kept[code] = mark
|
||||
|
||||
# ---- equal-weight control: target notional per name -----------------
|
||||
# candidate opens: top-ranked names whose signal pct >= entry_pct
|
||||
rank_desc = pred_score.sort_values(ascending=False)
|
||||
held_codes = set(kept)
|
||||
opens = []
|
||||
for sym in rank_desc.index:
|
||||
if len(opens) >= self.topk:
|
||||
break
|
||||
if sym in held_codes:
|
||||
continue
|
||||
if pct.get(sym, 0.0) < self.entry_pct:
|
||||
continue
|
||||
if not self._is_tradable(sym, trade_start, trade_end, OrderDir.BUY):
|
||||
continue
|
||||
opens.append(sym)
|
||||
|
||||
targets = held_codes | set(opens)
|
||||
if not targets:
|
||||
return TradeDecisionWO(sell_orders, self)
|
||||
|
||||
# total value (cash + marked positions) -> per-target notional
|
||||
total_value = current_temp.get_cash()
|
||||
for code, mark in kept.items():
|
||||
total_value += abs(current_temp.get_stock_amount(code)) * mark
|
||||
|
||||
target_notional = total_value * self.risk_degree / max(1, len(targets))
|
||||
|
||||
# ---- rebalance kept positions toward target weight ------------------
|
||||
buy_orders: List[Order] = []
|
||||
for code, mark in kept.items():
|
||||
cur = abs(current_temp.get_stock_amount(code)) * mark
|
||||
diff_notional = target_notional - cur
|
||||
if abs(diff_notional) / target_notional < 0.02:
|
||||
continue # skip tiny rebalances
|
||||
amount_delta = diff_notional / mark
|
||||
direction = Order.BUY if amount_delta > 0 else Order.SELL
|
||||
o = Order(stock_id=code, amount=abs(amount_delta), start_time=trade_start,
|
||||
end_time=trade_end, direction=direction)
|
||||
if self.trade_exchange.check_order(o):
|
||||
(buy_orders if direction == Order.BUY else sell_orders).append(o)
|
||||
self.trade_exchange.deal_order(o, position=current_temp)
|
||||
|
||||
# ---- open new positions at target weight ----------------------------
|
||||
for sym in opens:
|
||||
px = self.trade_exchange.get_deal_price(
|
||||
stock_id=sym, start_time=trade_start, end_time=trade_end, direction=OrderDir.BUY
|
||||
)
|
||||
if px is None or px != px or px <= 0:
|
||||
continue
|
||||
amount = target_notional / px
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=sym, start_time=trade_start, end_time=trade_end
|
||||
)
|
||||
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
|
||||
o = Order(stock_id=sym, amount=amount, start_time=trade_start,
|
||||
end_time=trade_end, direction=Order.BUY)
|
||||
if self.trade_exchange.check_order(o):
|
||||
buy_orders.append(o)
|
||||
|
||||
return TradeDecisionWO(sell_orders + buy_orders, self)
|
||||
@@ -0,0 +1,231 @@
|
||||
"""HMM-regime overlay TopkDropout strategy.
|
||||
|
||||
Regime-gate overlay on ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``:
|
||||
selection and sizing are identical to the reference, but a name is only BOUGHT
|
||||
(entry gate) when its per-symbol HMM regime posterior ``sp_hmm_p_regime1`` on
|
||||
the signal date is >= ``regime_threshold``; otherwise it is held in cash instead
|
||||
of being opened.
|
||||
|
||||
The regime posterior is read from the lake feature provider on the fly via
|
||||
``qlib.data.D.features`` (field ``$sp_hmm_p_regime1``) for the signal window, so
|
||||
no regime column needs to enter the model's ``feature_fields`` — the gate is a
|
||||
pure overlay (book ch.01: regime flags regressed as model features, survived
|
||||
only as an overlay). The HMM itself was fit with ``fit_end=<train end>`` when
|
||||
the lake features were backfilled, so there is no lookahead.
|
||||
|
||||
Names already held are NOT force-sold when the regime turns unfavourable
|
||||
(entry gate only, matching the queue-10 design).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from qlib.backtest import Order
|
||||
from qlib.backtest.decision import OrderDir, TradeDecisionWO
|
||||
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
|
||||
|
||||
try:
|
||||
from qlib.data import D
|
||||
except ImportError: # pragma: no cover - qlib always present in this stack
|
||||
D = None
|
||||
|
||||
__all__ = ["RegimeGateDropoutStrategy"]
|
||||
|
||||
DEFAULT_REGIME_THRESHOLD = 0.5
|
||||
REGIME_FIELD = "$sp_hmm_p_regime1"
|
||||
|
||||
|
||||
class RegimeGateDropoutStrategy(TopkDropoutStrategy):
|
||||
"""TopkDropout with an HMM-regime entry gate on buy candidates.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable,
|
||||
forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``.
|
||||
regime_threshold : minimum ``sp_hmm_p_regime1`` posterior required to open a
|
||||
new position (default 0.5).
|
||||
"""
|
||||
|
||||
def __init__(self, *, topk, n_drop, regime_threshold: float = DEFAULT_REGIME_THRESHOLD, **kwargs):
|
||||
super().__init__(topk=topk, n_drop=n_drop, **kwargs)
|
||||
self.regime_threshold = regime_threshold
|
||||
|
||||
def _regime_for(self, codes, pred_start, pred_end) -> pd.Series:
|
||||
"""Return {code: sp_hmm_p_regime1} for the signal window (last day)."""
|
||||
if D is None:
|
||||
return pd.Series(dtype=float)
|
||||
try:
|
||||
df = D.features(list(codes), [REGIME_FIELD], start_time=pred_start, end_time=pred_end, freq="day")
|
||||
except Exception: # noqa: BLE001 - a regime read failure should gate open, not crash
|
||||
return pd.Series(dtype=float)
|
||||
if df is None or len(df) == 0:
|
||||
return pd.Series(dtype=float)
|
||||
# df index is MultiIndex (datetime, instrument); take the last day's values
|
||||
df = df.reset_index()
|
||||
ts_col = "datetime" if "datetime" in df.columns else df.columns[0]
|
||||
sym_col = "instrument" if "instrument" in df.columns else df.columns[1]
|
||||
last_ts = df[ts_col].max()
|
||||
last = df[df[ts_col] == last_ts]
|
||||
out = {}
|
||||
for _, row in last.iterrows():
|
||||
sym = str(row[sym_col]).split("/")[-1].upper()
|
||||
val = row.iloc[-1]
|
||||
out[sym] = float(val) if val == val else np.nan
|
||||
return pd.Series(out)
|
||||
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
import copy
|
||||
|
||||
trade_step = self.trade_calendar.get_trade_step()
|
||||
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
||||
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
|
||||
if isinstance(pred_score, pd.DataFrame):
|
||||
pred_score = pred_score.iloc[:, 0]
|
||||
if pred_score is None:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
if self.only_tradable:
|
||||
|
||||
def get_first_n(li, n, reverse=False):
|
||||
cur_n = 0
|
||||
res = []
|
||||
for si in reversed(li) if reverse else li:
|
||||
if self.trade_exchange.is_stock_tradable(
|
||||
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
|
||||
):
|
||||
res.append(si)
|
||||
cur_n += 1
|
||||
if cur_n >= n:
|
||||
break
|
||||
return res[::-1] if reverse else res
|
||||
|
||||
def get_last_n(li, n):
|
||||
return get_first_n(li, n, reverse=True)
|
||||
|
||||
def filter_stock(li):
|
||||
return [
|
||||
si
|
||||
for si in li
|
||||
if self.trade_exchange.is_stock_tradable(
|
||||
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
]
|
||||
|
||||
else:
|
||||
|
||||
def get_first_n(li, n):
|
||||
return list(li)[:n]
|
||||
|
||||
def get_last_n(li, n):
|
||||
return list(li)[-n:]
|
||||
|
||||
def filter_stock(li):
|
||||
return li
|
||||
|
||||
current_temp: "object" = copy.deepcopy(self.trade_position)
|
||||
sell_order_list: List[Order] = []
|
||||
buy_order_list: List[Order] = []
|
||||
cash = current_temp.get_cash()
|
||||
current_stock_list = current_temp.get_stock_list()
|
||||
last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index
|
||||
|
||||
if self.method_buy == "top":
|
||||
today = get_first_n(
|
||||
pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index,
|
||||
self.n_drop + self.topk - len(last),
|
||||
)
|
||||
elif self.method_buy == "random":
|
||||
topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk)
|
||||
candi = list(filter(lambda x: x not in last, topk_candi))
|
||||
n = self.n_drop + self.topk - len(last)
|
||||
try:
|
||||
today = np.random.choice(candi, n, replace=False)
|
||||
except ValueError:
|
||||
today = candi
|
||||
else:
|
||||
raise NotImplementedError(f"This type of input is not supported")
|
||||
|
||||
comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index
|
||||
|
||||
if self.method_sell == "bottom":
|
||||
sell = last[last.isin(get_last_n(comb, self.n_drop))]
|
||||
elif self.method_sell == "random":
|
||||
candi = filter_stock(last)
|
||||
try:
|
||||
sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else [])
|
||||
except ValueError:
|
||||
sell = candi
|
||||
else:
|
||||
raise NotImplementedError(f"This type of input is not supported")
|
||||
|
||||
buy = today[: len(sell) + self.topk - len(last)]
|
||||
|
||||
# ---- regime gate -----------------------------------------------------
|
||||
if buy:
|
||||
regime = self._regime_for(buy, pred_start_time, pred_end_time)
|
||||
gated = [c for c in buy if regime.get(c, np.nan) >= self.regime_threshold]
|
||||
else:
|
||||
gated = []
|
||||
|
||||
for code in current_stock_list:
|
||||
if not self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL,
|
||||
):
|
||||
continue
|
||||
if code in sell:
|
||||
time_per_step = self.trade_calendar.get_freq()
|
||||
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
|
||||
continue
|
||||
sell_amount = current_temp.get_stock_amount(code=code)
|
||||
sell_order = Order(
|
||||
stock_id=code,
|
||||
amount=sell_amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.SELL,
|
||||
)
|
||||
if self.trade_exchange.check_order(sell_order):
|
||||
sell_order_list.append(sell_order)
|
||||
trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(
|
||||
sell_order, position=current_temp
|
||||
)
|
||||
cash += trade_val - trade_cost
|
||||
|
||||
if len(gated) == 0:
|
||||
return TradeDecisionWO(sell_order_list, self)
|
||||
|
||||
value = cash * self.risk_degree / len(gated)
|
||||
for code in gated:
|
||||
if not self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY,
|
||||
):
|
||||
continue
|
||||
buy_price = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY
|
||||
)
|
||||
buy_amount = value / buy_price
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor)
|
||||
buy_order = Order(
|
||||
stock_id=code,
|
||||
amount=buy_amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.BUY,
|
||||
)
|
||||
buy_order_list.append(buy_order)
|
||||
|
||||
return TradeDecisionWO(sell_order_list + buy_order_list, self)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Weekly-rebalance TopkDropout strategy.
|
||||
|
||||
Turnover-reduction variant of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``:
|
||||
the topk/n_drop selection and sizing are identical to the reference, but the
|
||||
target book is recomputed only on the first trading day of each ISO week; on the
|
||||
other days the strategy issues NO orders (holds the book untouched).
|
||||
|
||||
The weekly cadence is derived from the qlib trade calendar: a rebalance happens
|
||||
when the current trade step's date belongs to a different ISO ``(year, week)``
|
||||
than the previous trade step. ``hold_band_pct`` (default 0) optionally skips
|
||||
tiny rebalances: when a name's existing position differs from the new target by
|
||||
less than this fraction, no order is generated for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from qlib.backtest import Order
|
||||
from qlib.backtest.decision import OrderDir, TradeDecisionWO
|
||||
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
|
||||
|
||||
__all__ = ["WeeklyRebalanceDropoutStrategy"]
|
||||
|
||||
DEFAULT_HOLD_BAND_PCT = 0.0
|
||||
|
||||
|
||||
class WeeklyRebalanceDropoutStrategy(TopkDropoutStrategy):
|
||||
"""TopkDropout rebalanced once per ISO week; holds otherwise.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable,
|
||||
forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``.
|
||||
hold_band_pct : skip order for a name whose deviation from target weight is
|
||||
below this fraction of the target (no-trade buffer band).
|
||||
rebalance_every_n_weeks : rebalance every N ISO weeks instead of every week
|
||||
(default 1 = weekly; 2 = biweekly). Ignored when
|
||||
``rebalance_every_n_days`` is set.
|
||||
rebalance_every_n_days : rebalance every N trading days (daily when N=1).
|
||||
When set, overrides the weekly gating logic entirely.
|
||||
"""
|
||||
|
||||
def __init__(self, *, topk, n_drop, hold_band_pct: float = DEFAULT_HOLD_BAND_PCT,
|
||||
rebalance_every_n_weeks: int = 1,
|
||||
rebalance_every_n_days: int = 0, **kwargs):
|
||||
super().__init__(topk=topk, n_drop=n_drop, **kwargs)
|
||||
self.hold_band_pct = hold_band_pct
|
||||
self.rebalance_every_n_weeks = rebalance_every_n_weeks
|
||||
self.rebalance_every_n_days = rebalance_every_n_days
|
||||
|
||||
@staticmethod
|
||||
def _iso_week(ts) -> tuple:
|
||||
return (ts.year, ts.week)
|
||||
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
import copy
|
||||
|
||||
trade_step = self.trade_calendar.get_trade_step()
|
||||
trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step)
|
||||
|
||||
if self.rebalance_every_n_days > 0:
|
||||
# daily gating: count trading steps since last rebalance
|
||||
step_num = trade_step
|
||||
if hasattr(self, "_last_rebal_step"):
|
||||
if (step_num - self._last_rebal_step) < self.rebalance_every_n_days:
|
||||
return TradeDecisionWO([], self)
|
||||
self._last_rebal_step = step_num
|
||||
else:
|
||||
cur_week = self._iso_week(trade_start_time)
|
||||
prev_week = getattr(self, "_last_week", None)
|
||||
self._last_week = cur_week
|
||||
|
||||
if prev_week is not None and prev_week == cur_week:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
if self.rebalance_every_n_weeks > 1:
|
||||
week_num = cur_week[1]
|
||||
if prev_week is not None and (week_num % self.rebalance_every_n_weeks) != 1:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||
pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time)
|
||||
if isinstance(pred_score, pd.DataFrame):
|
||||
pred_score = pred_score.iloc[:, 0]
|
||||
if pred_score is None:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
if self.only_tradable:
|
||||
|
||||
def get_first_n(li, n, reverse=False):
|
||||
cur_n = 0
|
||||
res = []
|
||||
for si in reversed(li) if reverse else li:
|
||||
if self.trade_exchange.is_stock_tradable(
|
||||
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
|
||||
):
|
||||
res.append(si)
|
||||
cur_n += 1
|
||||
if cur_n >= n:
|
||||
break
|
||||
return res[::-1] if reverse else res
|
||||
|
||||
def get_last_n(li, n):
|
||||
return get_first_n(li, n, reverse=True)
|
||||
|
||||
def filter_stock(li):
|
||||
return [
|
||||
si
|
||||
for si in li
|
||||
if self.trade_exchange.is_stock_tradable(
|
||||
stock_id=si, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
]
|
||||
|
||||
else:
|
||||
|
||||
def get_first_n(li, n):
|
||||
return list(li)[:n]
|
||||
|
||||
def get_last_n(li, n):
|
||||
return list(li)[-n:]
|
||||
|
||||
def filter_stock(li):
|
||||
return li
|
||||
|
||||
current_temp: "object" = copy.deepcopy(self.trade_position)
|
||||
sell_order_list: List[Order] = []
|
||||
buy_order_list: List[Order] = []
|
||||
cash = current_temp.get_cash()
|
||||
current_stock_list = current_temp.get_stock_list()
|
||||
last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index
|
||||
|
||||
if self.method_buy == "top":
|
||||
today = get_first_n(
|
||||
pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index,
|
||||
self.n_drop + self.topk - len(last),
|
||||
)
|
||||
elif self.method_buy == "random":
|
||||
topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk)
|
||||
candi = list(filter(lambda x: x not in last, topk_candi))
|
||||
n = self.n_drop + self.topk - len(last)
|
||||
try:
|
||||
today = np.random.choice(candi, n, replace=False)
|
||||
except ValueError:
|
||||
today = candi
|
||||
else:
|
||||
raise NotImplementedError(f"This type of input is not supported")
|
||||
|
||||
comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index
|
||||
|
||||
if self.method_sell == "bottom":
|
||||
sell = last[last.isin(get_last_n(comb, self.n_drop))]
|
||||
elif self.method_sell == "random":
|
||||
candi = filter_stock(last)
|
||||
try:
|
||||
sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else [])
|
||||
except ValueError:
|
||||
sell = candi
|
||||
else:
|
||||
raise NotImplementedError(f"This type of input is not supported")
|
||||
|
||||
buy = today[: len(sell) + self.topk - len(last)]
|
||||
for code in current_stock_list:
|
||||
if not self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL,
|
||||
):
|
||||
continue
|
||||
if code in sell:
|
||||
time_per_step = self.trade_calendar.get_freq()
|
||||
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
|
||||
continue
|
||||
sell_amount = current_temp.get_stock_amount(code=code)
|
||||
sell_order = Order(
|
||||
stock_id=code,
|
||||
amount=sell_amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.SELL,
|
||||
)
|
||||
if self.trade_exchange.check_order(sell_order):
|
||||
sell_order_list.append(sell_order)
|
||||
trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(
|
||||
sell_order, position=current_temp
|
||||
)
|
||||
cash += trade_val - trade_cost
|
||||
|
||||
if len(buy) == 0:
|
||||
return TradeDecisionWO(sell_order_list, self)
|
||||
|
||||
value = cash * self.risk_degree / len(buy)
|
||||
for code in buy:
|
||||
if not self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY,
|
||||
):
|
||||
continue
|
||||
buy_price = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY
|
||||
)
|
||||
buy_amount = value / buy_price
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor)
|
||||
buy_order = Order(
|
||||
stock_id=code,
|
||||
amount=buy_amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.BUY,
|
||||
)
|
||||
buy_order_list.append(buy_order)
|
||||
|
||||
return TradeDecisionWO(sell_order_list + buy_order_list, self)
|
||||
Reference in New Issue
Block a user