1
0
mirror of https://github.com/microsoft/qlib.git synced 2026-07-14 16:26:55 +08:00

support optimization based strategy (#754)

* support optimization based strategy

* fix riskdata not found & update doc

* refactor signal_strategy

* add portfolio example

* Update examples/portfolio/prepare_riskdata.py

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>

* fix typo

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>

* fix typo

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>

* update doc

* fix riskmodel doc

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>
This commit is contained in:
Dong Zhou
2021-12-28 18:44:20 +08:00
committed by GitHub
parent 4709909782
commit 1b8f0b4575
14 changed files with 668 additions and 262 deletions

View File

@@ -5,6 +5,7 @@
from .signal_strategy import (
TopkDropoutStrategy,
WeightStrategyBase,
EnhancedIndexingStrategy,
)
from .rule_strategy import (

View File

@@ -0,0 +1,6 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
from .base import BaseOptimizer
from .optimizer import PortfolioOptimizer
from .enhanced_indexing import EnhancedIndexingOptimizer

View File

@@ -0,0 +1,13 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import abc
class BaseOptimizer(abc.ABC):
"""Construct portfolio with a optimization related method"""
@abc.abstractmethod
def __call__(self, *args, **kwargs) -> object:
"""Generate a optimized portfolio allocation"""
pass

View File

@@ -0,0 +1,203 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import numpy as np
import cvxpy as cp
import pandas as pd
from typing import Union, Optional, Dict, Any, List
from qlib.log import get_module_logger
from .base import BaseOptimizer
logger = get_module_logger("EnhancedIndexingOptimizer")
class EnhancedIndexingOptimizer(BaseOptimizer):
"""
Portfolio Optimizer for Enhanced Indexing
Notations:
w0: current holding weights
wb: benchmark weight
r: expected return
F: factor exposure
cov_b: factor covariance
var_u: residual variance (diagonal)
lamb: risk aversion parameter
delta: total turnover limit
b_dev: benchmark deviation limit
f_dev: factor deviation limit
Also denote:
d = w - wb: benchmark deviation
v = d @ F: factor deviation
The optimization problem for enhanced indexing:
max_w d @ r - lamb * (v @ cov_b @ v + var_u @ d**2)
s.t. w >= 0
sum(w) == 1
sum(|w - w0|) <= delta
d >= -b_dev
d <= b_dev
v >= -f_dev
v <= f_dev
"""
def __init__(
self,
lamb: float = 1,
delta: Optional[float] = 0.2,
b_dev: Optional[float] = 0.01,
f_dev: Optional[Union[List[float], np.ndarray]] = None,
scale_return: bool = True,
epsilon: float = 5e-5,
solver_kwargs: Optional[Dict[str, Any]] = {},
):
"""
Args:
lamb (float): risk aversion parameter (larger `lamb` means more focus on risk)
delta (float): total turnover limit
b_dev (float): benchmark deviation limit
f_dev (list): factor deviation limit
scale_return (bool): whether scale return to match estimated volatility
epsilon (float): minimum weight
solver_kwargs (dict): kwargs for cvxpy solver
"""
assert lamb >= 0, "risk aversion parameter `lamb` should be positive"
self.lamb = lamb
assert delta >= 0, "turnover limit `delta` should be positive"
self.delta = delta
assert b_dev is None or b_dev >= 0, "benchmark deviation limit `b_dev` should be positive"
self.b_dev = b_dev
if isinstance(f_dev, float):
assert f_dev >= 0, "factor deviation limit `f_dev` should be positive"
elif f_dev is not None:
f_dev = np.array(f_dev)
assert all(f_dev >= 0), "factor deviation limit `f_dev` should be positive"
self.f_dev = f_dev
self.scale_return = scale_return
self.epsilon = epsilon
self.solver_kwargs = solver_kwargs
def __call__(
self,
r: np.ndarray,
F: np.ndarray,
cov_b: np.ndarray,
var_u: np.ndarray,
w0: np.ndarray,
wb: np.ndarray,
mfh: Optional[np.ndarray] = None,
mfs: Optional[np.ndarray] = None,
) -> np.ndarray:
"""
Args:
r (np.ndarray): expected returns
F (np.ndarray): factor exposure
cov_b (np.ndarray): factor covariance
var_u (np.ndarray): residual variance
w0 (np.ndarray): current holding weights
wb (np.ndarray): benchmark weights
mfh (np.ndarray): mask force holding
mfs (np.ndarray): mask force selling
Returns:
np.ndarray: optimized portfolio allocation
"""
# scale return to match volatility
if self.scale_return:
r = r / r.std()
r *= np.sqrt(np.mean(np.diag(F @ cov_b @ F.T) + var_u))
# target weight
w = cp.Variable(len(r), nonneg=True)
w.value = wb # for warm start
# precompute exposure
d = w - wb # benchmark exposure
v = d @ F # factor exposure
# objective
ret = d @ r # excess return
risk = cp.quad_form(v, cov_b) + var_u @ (d ** 2) # tracking error
obj = cp.Maximize(ret - self.lamb * risk)
# weight bounds
lb = np.zeros_like(wb)
ub = np.ones_like(wb)
# bench bounds
if self.b_dev is not None:
lb = np.maximum(lb, wb - self.b_dev)
ub = np.minimum(ub, wb + self.b_dev)
# force holding
if mfh is not None:
lb[mfh] = w0[mfh]
ub[mfh] = w0[mfh]
# force selling
# NOTE: this will override mfh
if mfs is not None:
lb[mfs] = 0
ub[mfs] = 0
# constraints
# TODO: currently we assume fullly invest in the stocks,
# in the future we should support holding cash as an asset
cons = [cp.sum(w) == 1, w >= lb, w <= ub]
# factor deviation
if self.f_dev is not None:
cons.extend([v >= -self.f_dev, v <= self.f_dev])
# total turnover constraint
t_cons = []
if self.delta is not None:
if w0 is not None and w0.sum() > 0:
t_cons.extend([cp.norm(w - w0, 1) <= self.delta])
# optimize
# trial 1: use all constraints
success = False
try:
prob = cp.Problem(obj, cons + t_cons)
prob.solve(solver=cp.ECOS, warm_start=True, **self.solver_kwargs)
assert prob.status == "optimal"
success = True
except Exception as e:
logger.warning(f"trial 1 failed {e} (status: {prob.status})")
# trial 2: remove turnover constraint
if not success and len(t_cons):
logger.info("try removing turnover constraint as the last optimization failed")
try:
w.value = wb
prob = cp.Problem(obj, cons)
prob.solve(solver=cp.ECOS, warm_start=True, **self.solver_kwargs)
assert prob.status in ["optimal", "optimal_inaccurate"]
success = True
except Exception as e:
logger.warning(f"trial 2 failed {e} (status: {prob.status})")
# return current weight if not success
if not success:
logger.warning("optimization failed, will return current holding weight")
return w0
if prob.status == "optimal_inaccurate":
logger.warning(f"the optimization is inaccurate")
# remove small weight
w = np.asarray(w.value)
w[w < self.epsilon] = 0
w /= w.sum()
return w

View File

@@ -0,0 +1,266 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import warnings
import numpy as np
import pandas as pd
import scipy.optimize as so
from typing import Optional, Union, Callable, List
from .base import BaseOptimizer
class PortfolioOptimizer(BaseOptimizer):
"""Portfolio Optimizer
The following optimization algorithms are supported:
- `gmv`: Global Minimum Variance Portfolio
- `mvo`: Mean Variance Optimized Portfolio
- `rp`: Risk Parity
- `inv`: Inverse Volatility
Note:
This optimizer always assumes full investment and no-shorting.
"""
OPT_GMV = "gmv"
OPT_MVO = "mvo"
OPT_RP = "rp"
OPT_INV = "inv"
def __init__(
self,
method: str = "inv",
lamb: float = 0,
delta: float = 0,
alpha: float = 0.0,
scale_return: bool = True,
tol: float = 1e-8,
):
"""
Args:
method (str): portfolio optimization method
lamb (float): risk aversion parameter (larger `lamb` means more focus on return)
delta (float): turnover rate limit
alpha (float): l2 norm regularizer
scale_return (bool): if to scale alpha to match the volatility of the covariance matrix
tol (float): tolerance for optimization termination
"""
assert method in [self.OPT_GMV, self.OPT_MVO, self.OPT_RP, self.OPT_INV], f"method `{method}` is not supported"
self.method = method
assert lamb >= 0, f"risk aversion parameter `lamb` should be positive"
self.lamb = lamb
assert delta >= 0, f"turnover limit `delta` should be positive"
self.delta = delta
assert alpha >= 0, f"l2 norm regularizer `alpha` should be positive"
self.alpha = alpha
self.tol = tol
self.scale_return = scale_return
def __call__(
self,
S: Union[np.ndarray, pd.DataFrame],
r: Optional[Union[np.ndarray, pd.Series]] = None,
w0: Optional[Union[np.ndarray, pd.Series]] = None,
) -> Union[np.ndarray, pd.Series]:
"""
Args:
S (np.ndarray or pd.DataFrame): covariance matrix
r (np.ndarray or pd.Series): expected return
w0 (np.ndarray or pd.Series): initial weights (for turnover control)
Returns:
np.ndarray or pd.Series: optimized portfolio allocation
"""
# transform dataframe into array
index = None
if isinstance(S, pd.DataFrame):
index = S.index
S = S.values
# transform return
if r is not None:
assert len(r) == len(S), "`r` has mismatched shape"
if isinstance(r, pd.Series):
assert r.index.equals(index), "`r` has mismatched index"
r = r.values
# transform initial weights
if w0 is not None:
assert len(w0) == len(S), "`w0` has mismatched shape"
if isinstance(w0, pd.Series):
assert w0.index.equals(index), "`w0` has mismatched index"
w0 = w0.values
# scale return to match volatility
if r is not None and self.scale_return:
r = r / r.std()
r *= np.sqrt(np.mean(np.diag(S)))
# optimize
w = self._optimize(S, r, w0)
# restore index if needed
if index is not None:
w = pd.Series(w, index=index)
return w
def _optimize(self, S: np.ndarray, r: Optional[np.ndarray] = None, w0: Optional[np.ndarray] = None) -> np.ndarray:
# inverse volatility
if self.method == self.OPT_INV:
if r is not None:
warnings.warn("`r` is set but will not be used for `inv` portfolio")
if w0 is not None:
warnings.warn("`w0` is set but will not be used for `inv` portfolio")
return self._optimize_inv(S)
# global minimum variance
if self.method == self.OPT_GMV:
if r is not None:
warnings.warn("`r` is set but will not be used for `gmv` portfolio")
return self._optimize_gmv(S, w0)
# mean-variance
if self.method == self.OPT_MVO:
return self._optimize_mvo(S, r, w0)
# risk parity
if self.method == self.OPT_RP:
if r is not None:
warnings.warn("`r` is set but will not be used for `rp` portfolio")
return self._optimize_rp(S, w0)
def _optimize_inv(self, S: np.ndarray) -> np.ndarray:
"""Inverse volatility"""
vola = np.diag(S) ** 0.5
w = 1 / vola
w /= w.sum()
return w
def _optimize_gmv(self, S: np.ndarray, w0: Optional[np.ndarray] = None) -> np.ndarray:
"""optimize global minimum variance portfolio
This method solves the following optimization problem
min_w w' S w
s.t. w >= 0, sum(w) == 1
where `S` is the covariance matrix.
"""
return self._solve(len(S), self._get_objective_gmv(S), *self._get_constrains(w0))
def _optimize_mvo(
self, S: np.ndarray, r: Optional[np.ndarray] = None, w0: Optional[np.ndarray] = None
) -> np.ndarray:
"""optimize mean-variance portfolio
This method solves the following optimization problem
min_w - w' r + lamb * w' S w
s.t. w >= 0, sum(w) == 1
where `S` is the covariance matrix, `u` is the expected returns,
and `lamb` is the risk aversion parameter.
"""
return self._solve(len(S), self._get_objective_mvo(S, r), *self._get_constrains(w0))
def _optimize_rp(self, S: np.ndarray, w0: Optional[np.ndarray] = None) -> np.ndarray:
"""optimize risk parity portfolio
This method solves the following optimization problem
min_w sum_i [w_i - (w' S w) / ((S w)_i * N)]**2
s.t. w >= 0, sum(w) == 1
where `S` is the covariance matrix and `N` is the number of stocks.
"""
return self._solve(len(S), self._get_objective_rp(S), *self._get_constrains(w0))
def _get_objective_gmv(self, S: np.ndarray) -> Callable:
"""global minimum variance optimization objective
Optimization objective
min_w w' S w
"""
def func(x):
return x @ S @ x
return func
def _get_objective_mvo(self, S: np.ndarray, r: np.ndarray = None) -> Callable:
"""mean-variance optimization objective
Optimization objective
min_w - w' r + lamb * w' S w
"""
def func(x):
risk = x @ S @ x
ret = x @ r
return -ret + self.lamb * risk
return func
def _get_objective_rp(self, S: np.ndarray) -> Callable:
"""risk-parity optimization objective
Optimization objective
min_w sum_i [w_i - (w' S w) / ((S w)_i * N)]**2
"""
def func(x):
N = len(x)
Sx = S @ x
xSx = x @ Sx
return np.sum((x - xSx / Sx / N) ** 2)
return func
def _get_constrains(self, w0: Optional[np.ndarray] = None):
"""optimization constraints
Defines the following constraints:
- no shorting and leverage: 0 <= w <= 1
- full investment: sum(w) == 1
- turnover constraint: |w - w0| <= delta
"""
# no shorting and leverage
bounds = so.Bounds(0.0, 1.0)
# full investment constraint
cons = [{"type": "eq", "fun": lambda x: np.sum(x) - 1}] # == 0
# turnover constraint
if w0 is not None:
cons.append({"type": "ineq", "fun": lambda x: self.delta - np.sum(np.abs(x - w0))}) # >= 0
return bounds, cons
def _solve(self, n: int, obj: Callable, bounds: so.Bounds, cons: List) -> np.ndarray:
"""solve optimization
Args:
n (int): number of parameters
obj (callable): optimization objective
bounds (Bounds): bounds of parameters
cons (list): optimization constraints
"""
# add l2 regularization
wrapped_obj = obj
if self.alpha > 0:
def opt_obj(x):
return obj(x) + self.alpha * np.sum(np.square(x))
wrapped_obj = opt_obj
# solve
x0 = np.ones(n) / n # init results
sol = so.minimize(wrapped_obj, x0, bounds=bounds, constraints=cons, tol=self.tol)
if not sol.success:
warnings.warn(f"optimization not success ({sol.status})")
return sol.x

View File

@@ -1,70 +1,49 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import os
import copy
from qlib.backtest.signal import Signal, create_signal_from
from typing import Dict, List, Text, Tuple, Union
from qlib.data.dataset import Dataset
from qlib.model.base import BaseModel
from qlib.backtest.position import Position
import warnings
import cvxpy as cp
import numpy as np
import pandas as pd
from ...utils.resam import resam_ts_data
from ...strategy.base import BaseStrategy
from ...backtest.decision import Order, BaseTradeDecision, OrderDir, TradeDecisionWO
from typing import Dict, List, Text, Tuple, Union
from .order_generator import OrderGenWInteract
from qlib.data import D
from qlib.data.dataset import Dataset
from qlib.model.base import BaseModel
from qlib.strategy.base import BaseStrategy
from qlib.backtest.position import Position
from qlib.backtest.signal import Signal, create_signal_from
from qlib.backtest.decision import Order, BaseTradeDecision, OrderDir, TradeDecisionWO
from qlib.log import get_module_logger
from qlib.utils import get_pre_trading_date, load_dataset
from qlib.utils.resam import resam_ts_data
from qlib.contrib.strategy.order_generator import OrderGenWInteract, OrderGenWOInteract
from qlib.contrib.strategy.optimizer import EnhancedIndexingOptimizer
class TopkDropoutStrategy(BaseStrategy):
# TODO:
# 1. Supporting leverage the get_range_limit result from the decision
# 2. Supporting alter_outer_trade_decision
# 3. Supporting checking the availability of trade decision
class BaseSignalStrategy(BaseStrategy):
def __init__(
self,
*,
topk,
n_drop,
signal: Union[Signal, Tuple[BaseModel, Dataset], List, Dict, Text, pd.Series, pd.DataFrame] = None,
method_sell="bottom",
method_buy="top",
risk_degree=0.95,
hold_thresh=1,
only_tradable=False,
model=None,
dataset=None,
risk_degree: float = 0.95,
trade_exchange=None,
level_infra=None,
common_infra=None,
model=None,
dataset=None,
**kwargs,
):
"""
Parameters
-----------
topk : int
the number of stocks in the portfolio.
n_drop : int
number of stocks to be replaced in each trading date.
signal :
the information to describe a signal. Please refer to the docs of `qlib.backtest.signal.create_signal_from`
the decision of the strategy will base on the given signal
method_sell : str
dropout method_sell, random/bottom.
method_buy : str
dropout method_buy, random/top.
risk_degree : float
position percentage of total value.
hold_thresh : int
minimum holding days
before sell stock , will check current.get_stock_count(order.stock_id) >= self.hold_thresh.
only_tradable : bool
will the strategy only consider the tradable stock when buying and selling.
if only_tradable:
strategy will make buy sell decision without checking the tradable state of the stock.
else:
strategy will make decision with the tradable state of the stock info and avoid buy and sell them.
trade_exchange : Exchange
exchange that provides market info, used to deal order and generate report
- If `trade_exchange` is None, self.trade_exchange will be set with common_infra
@@ -74,16 +53,9 @@ class TopkDropoutStrategy(BaseStrategy):
- In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended.
"""
super(TopkDropoutStrategy, self).__init__(
level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs
)
self.topk = topk
self.n_drop = n_drop
self.method_sell = method_sell
self.method_buy = method_buy
super().__init__(level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs)
self.risk_degree = risk_degree
self.hold_thresh = hold_thresh
self.only_tradable = only_tradable
# This is trying to be compatible with previous version of qlib task config
if model is not None and dataset is not None:
@@ -100,6 +72,52 @@ class TopkDropoutStrategy(BaseStrategy):
# It will use 95% amoutn of your total value by default
return self.risk_degree
class TopkDropoutStrategy(BaseSignalStrategy):
# TODO:
# 1. Supporting leverage the get_range_limit result from the decision
# 2. Supporting alter_outer_trade_decision
# 3. Supporting checking the availability of trade decision
def __init__(
self,
*,
topk,
n_drop,
method_sell="bottom",
method_buy="top",
hold_thresh=1,
only_tradable=False,
**kwargs,
):
"""
Parameters
-----------
topk : int
the number of stocks in the portfolio.
n_drop : int
number of stocks to be replaced in each trading date.
method_sell : str
dropout method_sell, random/bottom.
method_buy : str
dropout method_buy, random/top.
hold_thresh : int
minimum holding days
before sell stock , will check current.get_stock_count(order.stock_id) >= self.hold_thresh.
only_tradable : bool
will the strategy only consider the tradable stock when buying and selling.
if only_tradable:
strategy will make buy sell decision without checking the tradable state of the stock.
else:
strategy will make decision with the tradable state of the stock info and avoid buy and sell them.
"""
super().__init__(**kwargs)
self.topk = topk
self.n_drop = n_drop
self.method_sell = method_sell
self.method_buy = method_buy
self.hold_thresh = hold_thresh
self.only_tradable = only_tradable
def generate_trade_decision(self, execute_result=None):
# get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1]
trade_step = self.trade_calendar.get_trade_step()
@@ -253,7 +271,7 @@ class TopkDropoutStrategy(BaseStrategy):
return TradeDecisionWO(sell_order_list + buy_order_list, self)
class WeightStrategyBase(BaseStrategy):
class WeightStrategyBase(BaseSignalStrategy):
# TODO:
# 1. Supporting leverage the get_range_limit result from the decision
# 2. Supporting alter_outer_trade_decision
@@ -261,11 +279,7 @@ class WeightStrategyBase(BaseStrategy):
def __init__(
self,
*,
signal: Union[Signal, Tuple[BaseModel, Dataset], List, Dict, Text, pd.Series, pd.DataFrame],
order_generator_cls_or_obj=OrderGenWInteract,
trade_exchange=None,
level_infra=None,
common_infra=None,
order_generator_cls_or_obj=OrderGenWOInteract,
**kwargs,
):
"""
@@ -280,24 +294,13 @@ class WeightStrategyBase(BaseStrategy):
- In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it run faster.
- In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended.
"""
super(WeightStrategyBase, self).__init__(
level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs
)
super().__init__(**kwargs)
if isinstance(order_generator_cls_or_obj, type):
self.order_generator = order_generator_cls_or_obj()
else:
self.order_generator = order_generator_cls_or_obj
self.signal: Signal = create_signal_from(signal)
def get_risk_degree(self, trade_step=None):
"""get_risk_degree
Return the proportion of your total value you will used in investment.
Dynamically risk_degree will result in Market timing.
"""
# It will use 95% amoutn of your total value by default
return 0.95
def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time):
"""
Generate target position from score for this date and the current position.The cash is not considered in the position
@@ -341,3 +344,154 @@ class WeightStrategyBase(BaseStrategy):
trade_end_time=trade_end_time,
)
return TradeDecisionWO(order_list, self)
class EnhancedIndexingStrategy(WeightStrategyBase):
"""Enhanced Indexing Strategy
Enhanced indexing combines the arts of active management and passive management,
with the aim of outperforming a benchmark index (e.g., S&P 500) in terms of
portfolio return while controlling the risk exposure (a.k.a. tracking error).
Users need to prepare their risk model data like below:
├── /path/to/riskmodel
├──── 20210101
├────── factor_exp.{csv|pkl|h5}
├────── factor_cov.{csv|pkl|h5}
├────── specific_risk.{csv|pkl|h5}
├────── blacklist.{csv|pkl|h5} # optional
The risk model data can be obtained from risk data provider. You can also use
`qlib.model.riskmodel.structured.StructuredCovEstimator` to prepare these data.
Args:
riskmodel_path (str): risk model path
name_mapping (dict): alternative file names
"""
FACTOR_EXP_NAME = "factor_exp.pkl"
FACTOR_COV_NAME = "factor_cov.pkl"
SPECIFIC_RISK_NAME = "specific_risk.pkl"
BLACKLIST_NAME = "blacklist.pkl"
def __init__(
self,
*,
riskmodel_root,
market="csi500",
turn_limit=None,
name_mapping={},
optimizer_kwargs={},
verbose=False,
**kwargs,
):
super().__init__(**kwargs)
self.logger = get_module_logger("EnhancedIndexingStrategy")
self.riskmodel_root = riskmodel_root
self.market = market
self.turn_limit = turn_limit
self.factor_exp_path = name_mapping.get("factor_exp", self.FACTOR_EXP_NAME)
self.factor_cov_path = name_mapping.get("factor_cov", self.FACTOR_COV_NAME)
self.specific_risk_path = name_mapping.get("specific_risk", self.SPECIFIC_RISK_NAME)
self.blacklist_path = name_mapping.get("blacklist", self.BLACKLIST_NAME)
self.optimizer = EnhancedIndexingOptimizer(**optimizer_kwargs)
self.verbose = verbose
self._riskdata_cache = {}
def get_risk_data(self, date):
if date in self._riskdata_cache:
return self._riskdata_cache[date]
root = self.riskmodel_root + "/" + date.strftime("%Y%m%d")
if not os.path.exists(root):
return None
factor_exp = load_dataset(root + "/" + self.factor_exp_path, index_col=[0])
factor_cov = load_dataset(root + "/" + self.factor_cov_path, index_col=[0])
specific_risk = load_dataset(root + "/" + self.specific_risk_path, index_col=[0])
if not factor_exp.index.equals(specific_risk.index):
# NOTE: for stocks missing specific_risk, we always assume it have the highest volatility
specific_risk = specific_risk.reindex(factor_exp.index, fill_value=specific_risk.max())
universe = factor_exp.index.tolist()
blacklist = []
if os.path.exists(root + "/" + self.blacklist_path):
blacklist = load_dataset(root + "/" + self.blacklist_path).index.tolist()
self._riskdata_cache[date] = factor_exp.values, factor_cov.values, specific_risk.values, universe, blacklist
return self._riskdata_cache[date]
def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time):
trade_date = trade_start_time
pre_date = get_pre_trading_date(trade_date, future=True) # previous trade date
# load risk data
outs = self.get_risk_data(pre_date)
if outs is None:
self.logger.warning(f"no risk data for {pre_date:%Y-%m-%d}, skip optimization")
return None
factor_exp, factor_cov, specific_risk, universe, blacklist = outs
# transform score
# NOTE: for stocks missing score, we always assume they have the lowest score
score = score.reindex(universe).fillna(score.min()).values
# get current weight
# NOTE: if a stock is not in universe, its current weight will be zero
cur_weight = current.get_stock_weight_dict(only_stock=False)
cur_weight = np.array([cur_weight.get(stock, 0) for stock in universe])
assert all(cur_weight >= 0), "current weight has negative values"
cur_weight = cur_weight / self.get_risk_degree(trade_date) # sum of weight should be risk_degree
if cur_weight.sum() > 1 and self.verbose:
self.logger.warning(f"previous total holdings excess risk degree (current: {cur_weight.sum()})")
# load bench weight
bench_weight = D.features(
D.instruments("all"), [f"${self.market}_weight"], start_time=pre_date, end_time=pre_date
).squeeze()
bench_weight.index = bench_weight.index.droplevel(level="datetime")
bench_weight = bench_weight.reindex(universe).fillna(0).values
# whether stock tradable
# NOTE: currently we use last day volume to check whether tradable
tradable = D.features(D.instruments("all"), ["$volume"], start_time=pre_date, end_time=pre_date).squeeze()
tradable.index = tradable.index.droplevel(level="datetime")
tradable = tradable.reindex(universe).gt(0).values
mask_force_hold = ~tradable
# mask force sell
mask_force_sell = np.array([stock in blacklist for stock in universe], dtype=bool)
# optimize
weight = self.optimizer(
r=score,
F=factor_exp,
cov_b=factor_cov,
var_u=specific_risk ** 2,
w0=cur_weight,
wb=bench_weight,
mfh=mask_force_hold,
mfs=mask_force_sell,
)
target_weight_position = {stock: weight for stock, weight in zip(universe, weight) if weight > 0}
if self.verbose:
self.logger.info("trade date: {:%Y-%m-%d}".format(trade_date))
self.logger.info("number of holding stocks: {}".format(len(target_weight_position)))
self.logger.info("total holding weight: {:.6f}".format(weight.sum()))
return target_weight_position