mirror of
https://github.com/microsoft/qlib.git
synced 2026-07-15 00:36:55 +08:00
refactor: implement deterministic budget allocation in SoftTopkStrategy (#2077)
* refactor: implement deterministic budget allocation in SoftTopkStrategy * style: fix formatting issues using black * fix: remove unused imports and pass pylint * refactor: simplify SoftTopkStrategy impact limit * style: relocate test files per maintainer request
This commit is contained in:
@@ -1,101 +1,117 @@
|
|||||||
# Copyright (c) Microsoft Corporation.
|
# Copyright (c) Microsoft Corporation.
|
||||||
# Licensed under the MIT License.
|
# Licensed under the MIT License.
|
||||||
"""
|
|
||||||
This strategy is not well maintained
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
from .order_generator import OrderGenWInteract
|
from .order_generator import OrderGenWInteract
|
||||||
from .signal_strategy import WeightStrategyBase
|
from .signal_strategy import WeightStrategyBase
|
||||||
import copy
|
|
||||||
|
|
||||||
|
|
||||||
class SoftTopkStrategy(WeightStrategyBase):
|
class SoftTopkStrategy(WeightStrategyBase):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model,
|
model=None,
|
||||||
dataset,
|
dataset=None,
|
||||||
topk,
|
topk=None,
|
||||||
order_generator_cls_or_obj=OrderGenWInteract,
|
order_generator_cls_or_obj=OrderGenWInteract,
|
||||||
max_sold_weight=1.0,
|
max_sold_weight=1.0,
|
||||||
|
trade_impact_limit=None,
|
||||||
risk_degree=0.95,
|
risk_degree=0.95,
|
||||||
buy_method="first_fill",
|
buy_method="first_fill",
|
||||||
trade_exchange=None,
|
|
||||||
level_infra=None,
|
|
||||||
common_infra=None,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
Refactored SoftTopkStrategy with a budget-constrained rebalancing engine.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
topk : int
|
topk : int
|
||||||
top-N stocks to buy
|
The number of top-N stocks to be held in the portfolio.
|
||||||
|
trade_impact_limit : float
|
||||||
|
Maximum weight change for each stock in one trade. If None, fallback to max_sold_weight.
|
||||||
|
max_sold_weight : float
|
||||||
|
Backward-compatible alias for trade_impact_limit. Use 1.0 to effectively disable the limit.
|
||||||
risk_degree : float
|
risk_degree : float
|
||||||
position percentage of total value buy_method:
|
The target percentage of total value to be invested.
|
||||||
|
|
||||||
rank_fill: assign the weight stocks that rank high first(1/topk max)
|
|
||||||
average_fill: assign the weight to the stocks rank high averagely.
|
|
||||||
"""
|
"""
|
||||||
super(SoftTopkStrategy, self).__init__(
|
super(SoftTopkStrategy, self).__init__(
|
||||||
model, dataset, order_generator_cls_or_obj, trade_exchange, level_infra, common_infra, **kwargs
|
model=model, dataset=dataset, order_generator_cls_or_obj=order_generator_cls_or_obj, **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
self.topk = topk
|
self.topk = topk
|
||||||
self.max_sold_weight = max_sold_weight
|
self.trade_impact_limit = trade_impact_limit if trade_impact_limit is not None else max_sold_weight
|
||||||
self.risk_degree = risk_degree
|
self.risk_degree = risk_degree
|
||||||
self.buy_method = buy_method
|
self.buy_method = buy_method
|
||||||
|
|
||||||
def get_risk_degree(self, trade_step=None):
|
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% amount of your total value by default
|
|
||||||
return self.risk_degree
|
return self.risk_degree
|
||||||
|
|
||||||
def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time):
|
def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time, **kwargs):
|
||||||
"""
|
"""
|
||||||
Parameters
|
Generates target position using Proportional Budget Allocation.
|
||||||
----------
|
Ensures deterministic sells and synchronized buys under impact limits.
|
||||||
score:
|
|
||||||
pred score for this trade date, pd.Series, index is stock_id, contain 'score' column
|
|
||||||
current:
|
|
||||||
current position, use Position() class
|
|
||||||
trade_date:
|
|
||||||
trade date
|
|
||||||
|
|
||||||
generate target position from score for this date and the current position
|
|
||||||
|
|
||||||
The cache is not considered in the position
|
|
||||||
"""
|
"""
|
||||||
# TODO:
|
|
||||||
# If the current stock list is more than topk(eg. The weights are modified
|
|
||||||
# by risk control), the weight will not be handled correctly.
|
|
||||||
buy_signal_stocks = set(score.sort_values(ascending=False).iloc[: self.topk].index)
|
|
||||||
cur_stock_weight = current.get_stock_weight_dict(only_stock=True)
|
|
||||||
|
|
||||||
if len(cur_stock_weight) == 0:
|
if self.topk is None or self.topk <= 0:
|
||||||
final_stock_weight = {code: 1 / self.topk for code in buy_signal_stocks}
|
return {}
|
||||||
else:
|
|
||||||
final_stock_weight = copy.deepcopy(cur_stock_weight)
|
def apply_impact_limit(weight):
|
||||||
sold_stock_weight = 0.0
|
return weight if self.trade_impact_limit is None else min(weight, self.trade_impact_limit)
|
||||||
for stock_id in final_stock_weight:
|
|
||||||
if stock_id not in buy_signal_stocks:
|
ideal_per_stock = self.risk_degree / self.topk
|
||||||
sw = min(self.max_sold_weight, final_stock_weight[stock_id])
|
ideal_list = score.sort_values(ascending=False).iloc[: self.topk].index.tolist()
|
||||||
sold_stock_weight += sw
|
|
||||||
final_stock_weight[stock_id] -= sw
|
cur_weights = current.get_stock_weight_dict(only_stock=True)
|
||||||
if self.buy_method == "first_fill":
|
initial_total_weight = sum(cur_weights.values())
|
||||||
for stock_id in buy_signal_stocks:
|
|
||||||
add_weight = min(
|
# --- Case A: Cold Start ---
|
||||||
max(1 / self.topk - final_stock_weight.get(stock_id, 0), 0.0),
|
if not cur_weights:
|
||||||
sold_stock_weight,
|
fill = apply_impact_limit(ideal_per_stock)
|
||||||
)
|
return {code: fill for code in ideal_list}
|
||||||
final_stock_weight[stock_id] = final_stock_weight.get(stock_id, 0.0) + add_weight
|
|
||||||
sold_stock_weight -= add_weight
|
# --- Case B: Rebalancing ---
|
||||||
elif self.buy_method == "average_fill":
|
all_tickers = set(cur_weights.keys()) | set(ideal_list)
|
||||||
for stock_id in buy_signal_stocks:
|
next_weights = {t: cur_weights.get(t, 0.0) for t in all_tickers}
|
||||||
final_stock_weight[stock_id] = final_stock_weight.get(stock_id, 0.0) + sold_stock_weight / len(
|
|
||||||
buy_signal_stocks
|
# Phase 1: Deterministic Sell Phase
|
||||||
)
|
released_cash = 0.0
|
||||||
else:
|
for t in list(next_weights.keys()):
|
||||||
raise ValueError("Buy method not found")
|
cur = next_weights[t]
|
||||||
return final_stock_weight
|
if cur <= 1e-8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if t not in ideal_list:
|
||||||
|
sell = apply_impact_limit(cur)
|
||||||
|
next_weights[t] -= sell
|
||||||
|
released_cash += sell
|
||||||
|
elif cur > ideal_per_stock + 1e-8:
|
||||||
|
excess = cur - ideal_per_stock
|
||||||
|
sell = apply_impact_limit(excess)
|
||||||
|
next_weights[t] -= sell
|
||||||
|
released_cash += sell
|
||||||
|
|
||||||
|
# Phase 2: Budget Calculation
|
||||||
|
# Budget = Cash from sells + Available space from target risk degree
|
||||||
|
total_budget = released_cash + (self.risk_degree - initial_total_weight)
|
||||||
|
|
||||||
|
# Phase 3: Proportional Buy Allocation
|
||||||
|
if total_budget > 1e-8:
|
||||||
|
shortfalls = {
|
||||||
|
t: (ideal_per_stock - next_weights.get(t, 0.0))
|
||||||
|
for t in ideal_list
|
||||||
|
if next_weights.get(t, 0.0) < ideal_per_stock - 1e-8
|
||||||
|
}
|
||||||
|
|
||||||
|
if shortfalls:
|
||||||
|
total_shortfall = sum(shortfalls.values())
|
||||||
|
# Normalize total_budget to not exceed total_shortfall
|
||||||
|
available_to_spend = min(total_budget, total_shortfall)
|
||||||
|
|
||||||
|
for t, shortfall in shortfalls.items():
|
||||||
|
# Every stock gets its fair share based on its distance to target
|
||||||
|
share_of_budget = (shortfall / total_shortfall) * available_to_spend
|
||||||
|
|
||||||
|
# Capped by impact limit
|
||||||
|
max_buy_cap = apply_impact_limit(shortfall)
|
||||||
|
|
||||||
|
next_weights[t] += min(share_of_budget, max_buy_cap)
|
||||||
|
|
||||||
|
return {k: v for k, v in next_weights.items() if v > 1e-8}
|
||||||
|
|||||||
56
tests/backtest/test_soft_topk_strategy.py
Normal file
56
tests/backtest/test_soft_topk_strategy.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from qlib.contrib.strategy.cost_control import SoftTopkStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class MockPosition:
|
||||||
|
def __init__(self, weights):
|
||||||
|
self.weights = weights
|
||||||
|
|
||||||
|
def get_stock_weight_dict(self, only_stock=True):
|
||||||
|
return self.weights
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_topk_logic():
|
||||||
|
# Initial: A=0.8, B=0.2 (Total=1.0). Target Risk=0.95.
|
||||||
|
# Scores: A and B are low, C and D are topk.
|
||||||
|
scores = pd.Series({"C": 0.9, "D": 0.8, "A": 0.1, "B": 0.1})
|
||||||
|
current_pos = MockPosition({"A": 0.8, "B": 0.2})
|
||||||
|
|
||||||
|
topk = 2
|
||||||
|
risk_degree = 0.95
|
||||||
|
impact_limit = 0.1 # Max change per step
|
||||||
|
|
||||||
|
def create_test_strategy(impact_limit_value):
|
||||||
|
strat = SoftTopkStrategy.__new__(SoftTopkStrategy)
|
||||||
|
strat.topk = topk
|
||||||
|
strat.risk_degree = risk_degree
|
||||||
|
strat.trade_impact_limit = impact_limit_value
|
||||||
|
return strat
|
||||||
|
|
||||||
|
# 1. With impact limit: Expect deterministic sell and limited buy
|
||||||
|
strat_i = create_test_strategy(impact_limit)
|
||||||
|
res_i = strat_i.generate_target_weight_position(scores, current_pos, None, None)
|
||||||
|
|
||||||
|
# A should be exactly 0.8 - 0.1 = 0.7
|
||||||
|
assert abs(res_i["A"] - 0.7) < 1e-8
|
||||||
|
# B should be exactly 0.2 - 0.1 = 0.1
|
||||||
|
assert abs(res_i["B"] - 0.1) < 1e-8
|
||||||
|
# Total sells = 0.2 released. New budget = 0.2 + (0.95 - 1.0) = 0.15.
|
||||||
|
# C and D share 0.15 -> 0.075 each.
|
||||||
|
assert abs(res_i["C"] - 0.075) < 1e-8
|
||||||
|
assert abs(res_i["D"] - 0.075) < 1e-8
|
||||||
|
|
||||||
|
# 2. Without impact limit: Expect full liquidation and full target fill
|
||||||
|
strat_c = create_test_strategy(1.0)
|
||||||
|
res_c = strat_c.generate_target_weight_position(scores, current_pos, None, None)
|
||||||
|
|
||||||
|
# A, B not in topk -> Liquidated
|
||||||
|
assert "A" not in res_c and "B" not in res_c
|
||||||
|
# C, D should reach ideal_per_stock (0.95/2 = 0.475)
|
||||||
|
assert abs(res_c["C"] - 0.475) < 1e-8
|
||||||
|
assert abs(res_c["D"] - 0.475) < 1e-8
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__])
|
||||||
38
tests/backtest/test_soft_topk_strategy_cold_start.py
Normal file
38
tests/backtest/test_soft_topk_strategy_cold_start.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from qlib.contrib.strategy.cost_control import SoftTopkStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class MockPosition:
|
||||||
|
def __init__(self, weights):
|
||||||
|
self.weights = weights
|
||||||
|
|
||||||
|
def get_stock_weight_dict(self, only_stock=True):
|
||||||
|
return self.weights
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_strategy(topk, risk_degree, impact_limit):
|
||||||
|
strat = SoftTopkStrategy.__new__(SoftTopkStrategy)
|
||||||
|
strat.topk = topk
|
||||||
|
strat.risk_degree = risk_degree
|
||||||
|
strat.trade_impact_limit = impact_limit
|
||||||
|
return strat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("impact_limit", "expected_fill"),
|
||||||
|
[
|
||||||
|
(0.1, 0.1),
|
||||||
|
(1.0, 0.475),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_soft_topk_cold_start_impact_limit(impact_limit, expected_fill):
|
||||||
|
scores = pd.Series({"C": 0.9, "D": 0.8, "A": 0.1, "B": 0.1})
|
||||||
|
current_pos = MockPosition({})
|
||||||
|
|
||||||
|
strat = create_test_strategy(topk=2, risk_degree=0.95, impact_limit=impact_limit)
|
||||||
|
res = strat.generate_target_weight_position(scores, current_pos, None, None)
|
||||||
|
|
||||||
|
assert abs(res["C"] - expected_fill) < 1e-8
|
||||||
|
assert abs(res["D"] - expected_fill) < 1e-8
|
||||||
Reference in New Issue
Block a user