Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276f463085 | ||
|
|
35a5e745b9 | ||
|
|
216052adc0 |
+11
-17
@@ -1,31 +1,25 @@
|
||||
# TradeAC custom-qlib-code snapshot (auto-generated)
|
||||
# parent repo HEAD : 507846cee16eeee11daf33c4176e8aec79b985b2
|
||||
# parent repo HEAD : 35a5e745b96cb64748e964279e7b8e52f1ad2c73
|
||||
# tac-qlib/tac_qlib/contrib
|
||||
# tac-qlib/tac_qlib/data
|
||||
# per-file hashes (git hash-object):
|
||||
1b6298c4a5652f2e863cbdc385a1014a570fcd59 tac-qlib/tac_qlib/contrib/__init__.py
|
||||
b419ee55ed455a1c45423d1c9025ca5cc0a98576 tac-qlib/tac_qlib/contrib/__pycache__/__init__.cpython-312.pyc
|
||||
2224424d0ff193be4f55d1b791f8fce89439c5d2 tac-qlib/tac_qlib/contrib/backtest/__init__.py
|
||||
0bf40dee440ddbded357d7bbb4efc67c62c4b084 tac-qlib/tac_qlib/contrib/backtest/tradeac_exchange.py
|
||||
c76a9f17f680e74eea766eff27f7624359749ed6 tac-qlib/tac_qlib/contrib/data/__init__.py
|
||||
2f6c67620aa2f9e6aaaef3369361d9b3eac3d6ca tac-qlib/tac_qlib/contrib/data/__pycache__/__init__.cpython-312.pyc
|
||||
fdd5923a70a399e8680913593ff111641947898e tac-qlib/tac_qlib/contrib/data/__pycache__/handler.cpython-312.pyc
|
||||
0dd25ef161c6e0f15eafc84886e7e1381deb38c3 tac-qlib/tac_qlib/contrib/data/handler.py
|
||||
3bba0f1696e4ab4b3deebec3f31f269b2e713899 tac-qlib/tac_qlib/contrib/data/handler.py
|
||||
b151d139a0dcde87d74b21e7c4b729176ba5c39b tac-qlib/tac_qlib/contrib/model/__init__.py
|
||||
08dec87ccdf6bb5d2cf611ca3032a4280aaab8cf tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc
|
||||
6fb61946ea9a83dfb560de3717f5fbf482c4c00e tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc
|
||||
3e80f2e08b661ddd2f58ffe5a6196063fa41ae51 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc
|
||||
d3f051f3a8650c42fedc7b367b966f7c74fb5789 tac-qlib/tac_qlib/contrib/model/rank_ensemble.py
|
||||
d03e6611338918d4aac5eea4adf26f85a3763652 tac-qlib/tac_qlib/contrib/model/rank_gbdt.py
|
||||
c4ef84ffda2a611262412fe1127689c667f3d0c1 tac-qlib/tac_qlib/contrib/strategy/__init__.py
|
||||
6ad10c2ebe37c16417e67c7aeb731ad1fcb6da2f tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc
|
||||
8d684b3216b040071d9ee4fa920a0e0c7486d278 tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc
|
||||
184f80da8edf944bad3c8fb4d4d3d189bf4f082b tac-qlib/tac_qlib/contrib/strategy/__init__.py
|
||||
896ef74ae47bcd1ed388e1e5d9c8d70c28097fe9 tac-qlib/tac_qlib/contrib/strategy/kelly_dropout.py
|
||||
9090fc6dfbd339f2f4df4b0c9b87f400ecb5c9d5 tac-qlib/tac_qlib/contrib/strategy/long_short.py
|
||||
79aaad9e39fcc740a773f4f63c512ce1086cfde0 tac-qlib/tac_qlib/contrib/strategy/optimal_stop.py
|
||||
5b9acfb4340111b204249add7760bd53c6ae03f1 tac-qlib/tac_qlib/contrib/strategy/regime_gate.py
|
||||
aa1ee880d52ceb5821d65973962099c2254f710a tac-qlib/tac_qlib/contrib/strategy/top_bottom.py
|
||||
fe60bacdfedd48617863be31f24b7c7daebfac5a tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py
|
||||
839abb89ad40cd516eabcfd91fe1be626b9f091f tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py
|
||||
92e6e90eb0cd0a25142034560f27adb6b705b1a8 tac-qlib/tac_qlib/data/__init__.py
|
||||
7c4e6c345fad1978efe8860c0d977d0c02d6f8d9 tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc
|
||||
99e602392d51663cb06d5c425000b1ed1e5a916b tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc
|
||||
020dcdcf288e4832c8cf2386351f78d5ceb4fe13 tac-qlib/tac_qlib/data/__pycache__/providers.cpython-312.pyc
|
||||
53c9007a928841fd3c3b08450f9a6520ce1ac091 tac-qlib/tac_qlib/data/config.py
|
||||
932a06725337edd98e3e4d47b429505d58b63db0 tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc
|
||||
4017cccb1cec19ab669e7e310f6577d81233ea6c tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc
|
||||
a615243ed287e3dab9e7ef20d8354a602fb01291 tac-qlib/tac_qlib/data/__pycache__/providers.cpython-312.pyc
|
||||
1953fb2a6371525db7f7b0e1c9dfbf3492d82110 tac-qlib/tac_qlib/data/config.py
|
||||
8d0644f6f0d1efb94798ed444cc73e63b643459b tac-qlib/tac_qlib/data/providers.py
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
from .tradeac_exchange import TradeACExchange
|
||||
@@ -0,0 +1,432 @@
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT License.
|
||||
"""
|
||||
TradeACExchange
|
||||
|
||||
A short/borrow enabled Exchange implementation built on top of qlib.backtest.exchange.Exchange.
|
||||
This exchange adds simple, configurable margin logic (initial/maintenance), borrowing support for
|
||||
shorts, a borrow fee, and a lightweight SMA (Special Memorandum Account) concept to emulate
|
||||
behaviors similar to brokers such as IBKR and Alpaca for backtesting purposes.
|
||||
|
||||
Notes / limitations
|
||||
- This implementation is intentionally lightweight and conservative: it implements the
|
||||
key behaviors needed for strategy/backtest experiments (allowing short selling, computing
|
||||
margin requirements, performing margin-call checks, and tracking SMA-like excess equity).
|
||||
- It makes some simplifying assumptions compared to real brokers (no per-product house margins,
|
||||
simplified SMA bookkeeping, borrow availability modeled only by a per-symbol boolean/limit).
|
||||
- The Position class in qlib.backtest.position was not changed. To support shorts we update the
|
||||
position.position dict directly when necessary. This keeps integration simple but bypasses some
|
||||
internal Position helpers. Use with care.
|
||||
|
||||
API additions
|
||||
- allow_short: enable short selling (bool)
|
||||
- initial_margin_long/short: fraction required to open a position
|
||||
- maintenance_margin_long/short: fraction required to keep a position
|
||||
- borrow_fee_rate: periodic borrow fee applied on short value (applied at trade time as additional cost)
|
||||
- borrowable: dict mapping stock_id -> bool or float (max borrowable shares). Symbols missing from
|
||||
the dict follow `borrow_default` (default True = unlimited; set False for a strict whitelist)
|
||||
- get_sma(position): returns SMA-like excess equity available as "buying power credit"
|
||||
- check_margin_call(position): returns True if position is below maintenance requirement
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from qlib.backtest.decision import Order
|
||||
from qlib.backtest.exchange import Exchange
|
||||
from qlib.backtest.position import BasePosition
|
||||
|
||||
|
||||
class TradeACExchange(Exchange):
|
||||
"""An exchange that supports short selling / borrowing and basic margin rules.
|
||||
|
||||
The implementation aims to be compatible with the Exchange API used by Account and
|
||||
Position classes in qlib.backtest. It overrides only the minimum methods required to
|
||||
enable short/borrow behavior and margin calculations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
allow_short: bool = True,
|
||||
initial_margin_long: float = 0.5,
|
||||
initial_margin_short: float = 0.5,
|
||||
maintenance_margin_long: float = 0.25,
|
||||
maintenance_margin_short: float = 0.3,
|
||||
borrow_fee_rate: float = 0.0,
|
||||
borrowable: Optional[Dict[str, float]] = None,
|
||||
borrow_default: bool = True,
|
||||
sma_enabled: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Create TradeACExchange.
|
||||
|
||||
Parameters mirror Exchange with additional tradeac-specific options.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.allow_short = allow_short
|
||||
self.initial_margin_long = initial_margin_long
|
||||
self.initial_margin_short = initial_margin_short
|
||||
self.maintenance_margin_long = maintenance_margin_long
|
||||
self.maintenance_margin_short = maintenance_margin_short
|
||||
self.borrow_fee_rate = borrow_fee_rate
|
||||
# borrowable can be a dict with per-symbol max borrowable amount, or None (unlimited)
|
||||
self.borrowable = borrowable or {}
|
||||
# borrow_default: policy for symbols absent from `borrowable`.
|
||||
# True -> unlisted symbols are unlimited-borrowable (legacy behavior)
|
||||
# False -> unlisted symbols are NOT borrowable; only listed ones can be shorted
|
||||
self.borrow_default = bool(borrow_default)
|
||||
# sma_enabled: whether to expose lightweight SMA calculation
|
||||
self.sma_enabled = sma_enabled
|
||||
|
||||
# --------------------------- Helper calculations ---------------------------
|
||||
def _initial_margin_requirement(self, position: BasePosition) -> float:
|
||||
"""Compute the initial margin requirement (money) for the given position.
|
||||
|
||||
We treat longs and shorts separately and sum their required initial margins.
|
||||
"""
|
||||
im_req = 0.0
|
||||
for sid in position.get_stock_list():
|
||||
amt = position.get_stock_amount(sid)
|
||||
price = position.get_stock_price(sid)
|
||||
val = amt * price
|
||||
if val > 0:
|
||||
im_req += abs(val) * self.initial_margin_long
|
||||
elif val < 0:
|
||||
im_req += abs(val) * self.initial_margin_short
|
||||
return im_req
|
||||
|
||||
def _maintenance_margin_requirement(self, position: BasePosition) -> float:
|
||||
"""Compute the maintenance margin requirement (money) for the given position."""
|
||||
mm_req = 0.0
|
||||
for sid in position.get_stock_list():
|
||||
amt = position.get_stock_amount(sid)
|
||||
price = position.get_stock_price(sid)
|
||||
val = amt * price
|
||||
if val > 0:
|
||||
mm_req += abs(val) * self.maintenance_margin_long
|
||||
elif val < 0:
|
||||
mm_req += abs(val) * self.maintenance_margin_short
|
||||
return mm_req
|
||||
|
||||
def get_equity(self, position: BasePosition) -> float:
|
||||
"""Return account equity (position value + cash)."""
|
||||
return position.calculate_value()
|
||||
|
||||
def get_sma(self, position: BasePosition) -> float:
|
||||
"""Return a simplified SMA: excess equity above initial margin requirement.
|
||||
|
||||
Note: This is a synthetic/Simplified SMA used for strategy/backtest logic. Real-broker
|
||||
SMA accounting (e.g. credits/debits across days) can be more complex.
|
||||
"""
|
||||
if not self.sma_enabled:
|
||||
return 0.0
|
||||
equity = self.get_equity(position)
|
||||
im_req = self._initial_margin_requirement(position)
|
||||
return max(0.0, equity - im_req)
|
||||
|
||||
def check_margin_call(self, position: BasePosition) -> bool:
|
||||
"""Return True when the account is under maintenance margin (margin call).
|
||||
|
||||
Margin call condition here is simple: equity < maintenance requirement.
|
||||
"""
|
||||
equity = self.get_equity(position)
|
||||
mm_req = self._maintenance_margin_requirement(position)
|
||||
return equity < mm_req
|
||||
|
||||
def get_buying_power(self, position: BasePosition) -> float:
|
||||
"""Estimate buying power for new long positions assuming opening margin requirement.
|
||||
|
||||
Simplified: the maximum notional long value = equity / initial_margin_long.
|
||||
"""
|
||||
equity = self.get_equity(position)
|
||||
if self.initial_margin_long <= 0:
|
||||
return 0.0
|
||||
return equity / self.initial_margin_long
|
||||
|
||||
# --------------------------- Order / execution overrides ---------------------------
|
||||
def _borrow_headroom(self, stock_id: str, current_short: float) -> float:
|
||||
"""Remaining borrowable shares for `stock_id` given an already-open short of `current_short` shares.
|
||||
|
||||
borrowable values: bool (True=unlimited, False=not borrowable) or numeric max shares.
|
||||
Missing symbols follow `borrow_default` (True = unlimited when allow_short is enabled).
|
||||
"""
|
||||
if not self.allow_short:
|
||||
return 0.0
|
||||
v = self.borrowable.get(stock_id, self.borrow_default)
|
||||
if isinstance(v, bool):
|
||||
return float("inf") if v else 0.0
|
||||
try:
|
||||
limit = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return float("inf")
|
||||
return max(0.0, limit - max(current_short, 0.0))
|
||||
|
||||
def _calc_trade_info_by_order(
|
||||
self,
|
||||
order: Order,
|
||||
position: Optional[BasePosition],
|
||||
dealt_order_amount: Dict[str, float],
|
||||
) -> Tuple[float, float, float]:
|
||||
"""Override to allow (optionally) short selling and to apply borrow fees.
|
||||
|
||||
The original Exchange implementation forbids selling more than you own. Here we allow
|
||||
sell orders to create/expand short positions when allow_short is True. We still rely on
|
||||
most base logic (price discovery, impact, cost calculation) by calling super(), but we
|
||||
adjust the sell-side clipping behavior before delegating to the base implementation.
|
||||
"""
|
||||
# When selling and shorts are allowed, temporarily relax the clipping logic in the base
|
||||
# implementation by monkey-patching current position check. Simpler: replicate minimal
|
||||
# parts of logic from Exchange._calc_trade_info_by_order with the key change.
|
||||
|
||||
# Get basic trade price & volume info using Exchange helpers
|
||||
trade_price = float(self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction))
|
||||
total_trade_val = float(self.get_volume(order.stock_id, order.start_time, order.end_time) or 0.0) * trade_price
|
||||
|
||||
order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time)
|
||||
order.deal_amount = order.amount # attempt full
|
||||
|
||||
# volume clipping (same as base)
|
||||
self._clip_amount_by_volume(order, dealt_order_amount)
|
||||
|
||||
# approximate adjusted cost ratio based on liquidity
|
||||
if not total_trade_val or np.isnan(total_trade_val) or total_trade_val <= 0:
|
||||
adj_cost_ratio = self.impact_cost
|
||||
else:
|
||||
trade_val_tmp = order.deal_amount * trade_price
|
||||
adj_cost_ratio = self.impact_cost * (trade_val_tmp / total_trade_val) ** 2
|
||||
|
||||
# Differentiate buy / sell
|
||||
if order.direction == Order.SELL:
|
||||
cost_ratio = self.close_cost + adj_cost_ratio
|
||||
current_amount = (
|
||||
position.get_stock_amount(order.stock_id) if (position is not None and position.check_stock(order.stock_id)) else 0.0
|
||||
)
|
||||
long_held = max(current_amount, 0.0)
|
||||
short_open = max(-current_amount, 0.0)
|
||||
|
||||
if position is not None:
|
||||
if not self.allow_short:
|
||||
# clip by current holdings only
|
||||
if not np.isclose(order.deal_amount, current_amount):
|
||||
order.deal_amount = self.round_amount_by_trade_unit(
|
||||
min(long_held, order.deal_amount), order.factor
|
||||
)
|
||||
else:
|
||||
# allow selling beyond holdings up to the remaining borrow limit;
|
||||
# later when updating the position we create/expand a short if necessary.
|
||||
max_sell = long_held + self._borrow_headroom(order.stock_id, short_open)
|
||||
if order.deal_amount > max_sell and not np.isclose(order.deal_amount, max_sell):
|
||||
order.deal_amount = self.round_amount_by_trade_unit(max_sell, order.factor)
|
||||
|
||||
elif order.direction == Order.BUY:
|
||||
cost_ratio = self.open_cost + adj_cost_ratio
|
||||
if position is not None:
|
||||
cash = position.get_cash()
|
||||
trade_val = order.deal_amount * trade_price
|
||||
if cash < max(trade_val * cost_ratio, self.min_cost):
|
||||
order.deal_amount = 0
|
||||
self.logger.debug(f"Order clipped due to cost higher than cash: {order}")
|
||||
elif cash < trade_val + max(trade_val * cost_ratio, self.min_cost):
|
||||
max_buy_amount = self._get_buy_amount_by_cash_limit(trade_price, cash, cost_ratio)
|
||||
order.deal_amount = self.round_amount_by_trade_unit(min(max_buy_amount, order.deal_amount), order.factor)
|
||||
self.logger.debug(f"Order clipped due to cash limitation: {order}")
|
||||
else:
|
||||
order.deal_amount = self.round_amount_by_trade_unit(order.deal_amount, order.factor)
|
||||
else:
|
||||
order.deal_amount = self.round_amount_by_trade_unit(order.deal_amount, order.factor)
|
||||
else:
|
||||
raise NotImplementedError("order direction {} error".format(order.direction))
|
||||
|
||||
# compute final trade_val & trade_cost
|
||||
trade_val = order.deal_amount * trade_price
|
||||
# base trade_cost
|
||||
trade_cost = max(trade_val * cost_ratio, self.min_cost)
|
||||
# apply borrow fee only on the net-new short portion of the sell
|
||||
if order.direction == Order.SELL and self.allow_short:
|
||||
new_short = max(0.0, order.deal_amount - long_held)
|
||||
trade_cost += new_short * trade_price * self.borrow_fee_rate
|
||||
|
||||
if trade_val <= 1e-5:
|
||||
trade_cost = 0
|
||||
|
||||
return trade_price, trade_val, trade_cost
|
||||
|
||||
def deal_order(
|
||||
self,
|
||||
order: Order,
|
||||
trade_account: Optional[Any] = None,
|
||||
position: Optional[BasePosition] = None,
|
||||
dealt_order_amount: Dict[str, float] = None,
|
||||
) -> Tuple[float, float, float]:
|
||||
"""Deal order and handle short position bookkeeping.
|
||||
|
||||
This method mirrors Exchange.deal_order but when a position is provided and shorts are
|
||||
allowed it will update the Position.position dict directly to support negative amounts.
|
||||
"""
|
||||
if dealt_order_amount is None:
|
||||
dealt_order_amount = {}
|
||||
|
||||
if not self.check_order(order):
|
||||
order.deal_amount = 0.0
|
||||
self.logger.debug(f"Order failed due to trading limitation: {order}")
|
||||
return 0.0, 0.0, np.nan
|
||||
|
||||
if trade_account is not None and position is not None:
|
||||
raise ValueError("trade_account and position can only choose one")
|
||||
|
||||
pos = position or (trade_account.current_position if trade_account is not None else None)
|
||||
trade_price, trade_val, trade_cost = self._calc_trade_info_by_order(order, pos, dealt_order_amount)
|
||||
|
||||
if trade_val > 1e-5:
|
||||
if trade_account is not None:
|
||||
cp = trade_account.current_position
|
||||
if not cp.skip_update():
|
||||
held = cp.check_stock(order.stock_id)
|
||||
# Account-level bookkeeping (turnover/cost/returns). Mirrors
|
||||
# Account._update_state_from_order except for fresh short sales,
|
||||
# where no prior price exists to compute order profit from.
|
||||
if order.direction == Order.SELL and not held:
|
||||
trade_account.accum_info.add_turnover(trade_val)
|
||||
trade_account.accum_info.add_cost(trade_cost)
|
||||
trade_account.accum_info.add_return_value(0.0)
|
||||
if order.direction == Order.SELL:
|
||||
# sell: update account state first (stock entry may be deleted)
|
||||
if held:
|
||||
trade_account._update_state_from_order(order, trade_val, trade_cost, trade_price)
|
||||
self._position_sell(cp, order, trade_val, trade_cost, trade_price)
|
||||
else:
|
||||
# buy: update position first (entry may be created), then account state
|
||||
# A buy that covers a short to exactly flat deletes the entry inside
|
||||
# _position_buy; re-seed a transient zero-amount stub so the
|
||||
# account's order-profit lookup still finds the trade price,
|
||||
# then drop it (_update_state_from_order never mutates entries).
|
||||
sid = order.stock_id
|
||||
had_entry = isinstance(cp.position.get(sid), dict)
|
||||
self._position_buy(cp, order, trade_val, trade_cost, trade_price)
|
||||
covered_to_flat = had_entry and not isinstance(cp.position.get(sid), dict)
|
||||
if covered_to_flat:
|
||||
cp.position[sid] = {"amount": 0.0, "price": trade_price, "weight": 0}
|
||||
trade_account._update_state_from_order(order, trade_val, trade_cost, trade_price)
|
||||
if covered_to_flat:
|
||||
cp.position.pop(sid, None)
|
||||
elif position is not None:
|
||||
if order.direction == Order.BUY:
|
||||
self._position_buy(position, order, trade_val, trade_cost, trade_price)
|
||||
else:
|
||||
self._position_sell(position, order, trade_val, trade_cost, trade_price)
|
||||
return trade_val, trade_cost, trade_price
|
||||
|
||||
# --------------------------- Position mutation helpers ---------------------------
|
||||
def _position_buy(self, position: BasePosition, order: Order, trade_val: float, cost: float, trade_price: float) -> None:
|
||||
"""Handle buy order bookkeeping against a BasePosition while supporting shorts.
|
||||
|
||||
Rules implemented (simplified):
|
||||
- If there is an existing short (amount < 0), the buy will first cover the short.
|
||||
- If covering closes the short completely, the remaining buy becomes a long
|
||||
- Cash updates mimic Position._buy_stock/_sell_stock (cash decreases by trade_val+cost for buys)
|
||||
"""
|
||||
trade_amount = trade_val / trade_price
|
||||
sid = order.stock_id
|
||||
current_amount = position.get_stock_amount(sid) if position.check_stock(sid) else 0.0
|
||||
|
||||
# covering existing short
|
||||
if current_amount < -1e-12:
|
||||
# amount is negative -> we are short. Buying reduces the short.
|
||||
new_amount = current_amount + trade_amount
|
||||
if abs(new_amount) <= 1e-8:
|
||||
# short fully covered exactly -> remove entry
|
||||
if sid in position.position:
|
||||
del position.position[sid]
|
||||
elif new_amount > 0:
|
||||
# short fully covered with leftover buy amount -> leftover becomes a long position
|
||||
position.position[sid] = {"amount": new_amount, "price": trade_price, "weight": 0}
|
||||
else:
|
||||
# partially cover
|
||||
position.position[sid]["amount"] = new_amount
|
||||
position.position[sid]["price"] = trade_price
|
||||
else:
|
||||
# normal or increasing long
|
||||
if sid not in position.position or not isinstance(position.position[sid], dict):
|
||||
# initialize stock
|
||||
position.position[sid] = {"amount": trade_amount, "price": trade_price, "weight": 0}
|
||||
else:
|
||||
position.position[sid]["amount"] = position.position[sid].get("amount", 0.0) + trade_amount
|
||||
position.position[sid]["price"] = trade_price
|
||||
|
||||
# cash effect same as Position._buy_stock
|
||||
position.position["cash"] -= trade_val + cost
|
||||
|
||||
def _position_sell(self, position: BasePosition, order: Order, trade_val: float, cost: float, trade_price: float) -> None:
|
||||
"""Handle sell order bookkeeping against a BasePosition while supporting shorts.
|
||||
|
||||
Rules implemented (simplified):
|
||||
- If holding enough long shares, sell will reduce/close the long position normally.
|
||||
- If not holding enough long shares and shorts are allowed, the remaining sold amount will create/expand a short position.
|
||||
- Cash update for sells follows Position._sell_stock logic (cash increases by trade_val - cost)
|
||||
"""
|
||||
trade_amount = trade_val / trade_price
|
||||
sid = order.stock_id
|
||||
current_amount = position.get_stock_amount(sid) if position.check_stock(sid) else 0.0
|
||||
|
||||
if current_amount > 1e-12:
|
||||
# we have long shares; sell from them first
|
||||
if trade_amount >= current_amount - 1e-8:
|
||||
# selling all or more than holdings
|
||||
# remove long position
|
||||
if sid in position.position:
|
||||
del position.position[sid]
|
||||
# remaining sold amount becomes short if allowed
|
||||
remain = trade_amount - current_amount
|
||||
if remain > 1e-8:
|
||||
if not self.allow_short:
|
||||
# should not happen due to clipping earlier, but guard anyway
|
||||
raise ValueError(f"Attempt to short {sid} while shorting disabled")
|
||||
# create short entry
|
||||
position.position[sid] = {"amount": -remain, "price": trade_price, "weight": 0}
|
||||
else:
|
||||
# partial sell
|
||||
position.position[sid]["amount"] = current_amount - trade_amount
|
||||
position.position[sid]["price"] = trade_price
|
||||
else:
|
||||
# currently flat or already short
|
||||
if not self.allow_short:
|
||||
raise ValueError(f"Attempt to short {sid} while shorting disabled")
|
||||
# expand short
|
||||
new_amount = current_amount - trade_amount
|
||||
if sid not in position.position or not isinstance(position.position[sid], dict):
|
||||
position.position[sid] = {"amount": new_amount, "price": trade_price, "weight": 0}
|
||||
else:
|
||||
position.position[sid]["amount"] = new_amount
|
||||
position.position[sid]["price"] = trade_price
|
||||
|
||||
# cash effect same as Position._sell_stock
|
||||
new_cash = trade_val - cost
|
||||
if getattr(position, "_settle_type", None) == position.ST_CASH:
|
||||
position.position["cash_delay"] = position.position.get("cash_delay", 0.0) + new_cash
|
||||
else:
|
||||
position.position["cash"] = position.position.get("cash", 0.0) + new_cash
|
||||
|
||||
# --------------------------- Borrow availability helpers ---------------------------
|
||||
def is_borrowable(self, stock_id: str, amount: float) -> bool:
|
||||
"""Check whether the requested amount is borrowable for the given stock.
|
||||
|
||||
If a borrowable dict is provided, it may contain either booleans or numeric limits (maximum borrowable shares).
|
||||
Symbols absent from the dict follow `borrow_default`.
|
||||
"""
|
||||
if not self.allow_short:
|
||||
return False
|
||||
if stock_id not in self.borrowable:
|
||||
return self.borrow_default
|
||||
v = self.borrowable[stock_id]
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
try:
|
||||
limit = float(v)
|
||||
return amount <= limit
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -15,6 +15,9 @@ import os
|
||||
from inspect import getfullargspec
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from qlib.data.dataset import processor as processor_module
|
||||
from qlib.data.dataset.handler import DataHandlerLP
|
||||
from qlib.utils import get_callable_kwargs
|
||||
@@ -22,6 +25,7 @@ from qlib.utils import get_callable_kwargs
|
||||
from ...data.config import (
|
||||
LakeConfig,
|
||||
timeframe_for_freq,
|
||||
FEATURE_FAMILIES,
|
||||
NON_FEATURE_COLUMNS,
|
||||
)
|
||||
|
||||
@@ -92,7 +96,7 @@ def get_common_feature_fields(lake_root=None, market="US", timeframe="1d") -> Li
|
||||
|
||||
common: set = set()
|
||||
# family tier: features/market=*/timeframe=*/family=*/symbol=*.parquet
|
||||
for fam in ("ta", "sp"):
|
||||
for fam in FEATURE_FAMILIES:
|
||||
fam_dir = feat_dir / f"family={fam}"
|
||||
if fam_dir.is_dir():
|
||||
common |= _family_common(fam_dir)
|
||||
@@ -143,6 +147,174 @@ class DropAllNaN(processor_module.Processor):
|
||||
return df
|
||||
|
||||
|
||||
class BenchResidual(processor_module.Processor):
|
||||
"""Subtract a benchmark instrument's forward return from the label, per datetime.
|
||||
|
||||
Turns the training target from an absolute-return rank into a *residual* rank:
|
||||
``r_i - r_bench`` is ranked cross-sectionally by the downstream ``CSRankNorm`` /
|
||||
``CSZScoreNorm`` processors instead of ``r_i`` alone. Must be inserted BEFORE any
|
||||
per-date normalization so the ranking itself is computed on residual returns
|
||||
(ordering flips exactly where the benchmark trends).
|
||||
|
||||
Stateless: ``fit`` is a no-op and the benchmark forward return is recomputed from
|
||||
the lake parquet on first ``__call__``. Rows whose benchmark value is missing are
|
||||
left untouched. Accepts ``fit_start_time``/``fit_end_time`` (ignored) so
|
||||
``check_transform_proc`` can inject the fit window uniformly.
|
||||
|
||||
NOTE: under any cross-sectional normalization downstream (``CSRankNorm`` /
|
||||
``CSZScoreNorm``) this processor is a mathematical no-op: subtracting the same
|
||||
per-date constant preserves ranks, and z-scoring absorbs constant shifts. Use
|
||||
``BenchBetaResidual`` for a target that actually reorders.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
benchmark="SPY",
|
||||
fields_group="label",
|
||||
lake_root=None,
|
||||
market="US",
|
||||
timeframe=None,
|
||||
freq="day",
|
||||
fit_start_time=None,
|
||||
fit_end_time=None,
|
||||
):
|
||||
self.benchmark = benchmark
|
||||
self.fields_group = fields_group
|
||||
self.lake_root = lake_root
|
||||
self.market = market
|
||||
self.timeframe = timeframe or timeframe_for_freq(freq)
|
||||
self.fit_start_time = fit_start_time
|
||||
self.fit_end_time = fit_end_time
|
||||
self._bench_label = None
|
||||
|
||||
def _load_bench_label(self):
|
||||
if self._bench_label is not None:
|
||||
return self._bench_label
|
||||
cfg = LakeConfig(self.lake_root, self.market)
|
||||
p = cfg.bar_path(self.timeframe, self.benchmark)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"BenchResidual: benchmark bar file not found: {p}")
|
||||
df = pd.read_parquet(p)
|
||||
s = pd.Series(df["c"].astype(float).values, index=pd.to_datetime(df["t"])).sort_index()
|
||||
s.index = s.index.normalize()
|
||||
# mirror Ref($close,-6)/Ref($close,-1)-1 on the benchmark's own calendar
|
||||
bench_label = s.shift(-6) / s.shift(-1) - 1
|
||||
self._bench_label = bench_label[~bench_label.index.duplicated(keep="last")]
|
||||
return self._bench_label
|
||||
|
||||
def fit(self, df=None):
|
||||
return self
|
||||
|
||||
def __call__(self, df):
|
||||
bl = self._load_bench_label()
|
||||
cols = processor_module.get_group_columns(df, self.fields_group)
|
||||
dt = df.index.get_level_values("datetime")
|
||||
aligned = bl.reindex(pd.DatetimeIndex(dt.unique())).reindex(dt)
|
||||
mask = aligned.notna().values
|
||||
out = df.copy()
|
||||
for c in cols:
|
||||
vals = df[c].values
|
||||
res = vals.copy()
|
||||
res[mask] = np.asarray(vals[mask], dtype=float) - aligned[mask].values
|
||||
out[c] = res
|
||||
return out
|
||||
|
||||
|
||||
class BenchBetaResidual(processor_module.Processor):
|
||||
"""Residualize the label against a beta-scaled benchmark move: ``r_i - b_i * r_bench``.
|
||||
|
||||
Unlike a plain constant subtraction (see ``BenchResidual``), the name-specific rolling
|
||||
beta ``b_i`` makes this survive cross-sectional normalization: in up-weeks high-beta
|
||||
names lose rank, in down-weeks they gain — exactly the relative structure an absolute-
|
||||
return ranking hides.
|
||||
|
||||
Beta is estimated from *past* data only (rolling ``window`` trading days of daily close
|
||||
returns of each instrument vs the benchmark, both read up to and including ``t``), so
|
||||
no lookahead enters the target. The benchmark leg uses the same horizon as the label
|
||||
expression (``Ref($close,-6)/Ref($close,-1)-1`` by default via ``horizon``/``base``,
|
||||
matching the yaml's 6-day label). Rows with missing beta or benchmark values keep
|
||||
their raw label.
|
||||
|
||||
Requires ``$close`` to be present in the feature group (it always is for TACHandler).
|
||||
Stateless; accepts ``fit_start_time``/``fit_end_time`` (ignored) for uniform kwargs
|
||||
injection. Must be inserted BEFORE any per-date normalization processor.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
benchmark="SPY",
|
||||
fields_group="label",
|
||||
lake_root=None,
|
||||
market="US",
|
||||
timeframe=None,
|
||||
freq="day",
|
||||
window=63,
|
||||
horizon=6,
|
||||
base=1,
|
||||
feature_field="$close",
|
||||
fit_start_time=None,
|
||||
fit_end_time=None,
|
||||
):
|
||||
self.benchmark = benchmark
|
||||
self.fields_group = fields_group
|
||||
self.lake_root = lake_root
|
||||
self.market = market
|
||||
self.timeframe = timeframe or timeframe_for_freq(freq)
|
||||
self.window = int(window)
|
||||
self.horizon = int(horizon)
|
||||
self.base = int(base)
|
||||
self.feature_field = feature_field
|
||||
self.fit_start_time = fit_start_time
|
||||
self.fit_end_time = fit_end_time
|
||||
self._bench = None
|
||||
|
||||
def _load_bench_close(self):
|
||||
if self._bench is not None:
|
||||
return self._bench
|
||||
cfg = LakeConfig(self.lake_root, self.market)
|
||||
p = cfg.bar_path(self.timeframe, self.benchmark)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"BenchBetaResidual: benchmark bar file not found: {p}")
|
||||
df = pd.read_parquet(p)
|
||||
s = pd.Series(df["c"].astype(float).values, index=pd.to_datetime(df["t"])).sort_index()
|
||||
s.index = s.index.normalize()
|
||||
self._bench = s[~s.index.duplicated(keep="last")]
|
||||
return self._bench
|
||||
|
||||
def fit(self, df=None):
|
||||
return self
|
||||
|
||||
def __call__(self, df):
|
||||
bench = self._load_bench_close()
|
||||
|
||||
# benchmark forward return over the same horizon as the label expression
|
||||
fwd = bench.shift(-(self.base + self.horizon - 1)) / bench.shift(-self.base) - 1
|
||||
|
||||
px_col = ("feature", self.feature_field)
|
||||
if px_col not in df.columns:
|
||||
raise KeyError(f"BenchBetaResidual: {self.feature_field} not found in features")
|
||||
px = df[px_col].unstack("instrument").sort_index()
|
||||
rets = px / px.shift(1) - 1
|
||||
bret = bench.reindex(px.index).pct_change()
|
||||
|
||||
# rolling beta per instrument using data <= t (no lookahead)
|
||||
cov = rets.rolling(self.window, min_periods=max(10, self.window // 2)).cov(bret)
|
||||
var = bret.rolling(self.window, min_periods=max(10, self.window // 2)).var()
|
||||
beta = cov.div(var, axis=0)
|
||||
|
||||
contrib = beta.mul(fwd.reindex(px.index), axis=0)
|
||||
cols = list(processor_module.get_group_columns(df, self.fields_group))
|
||||
out = df.copy()
|
||||
for c in cols:
|
||||
lab = df[c].unstack("instrument").reindex(px.index)
|
||||
resid = lab - contrib.where(contrib.notna() & lab.notna(), 0.0)
|
||||
new_vals = resid.stack()
|
||||
new_vals.index.names = df.index.names
|
||||
# residual where available, raw label otherwise (e.g. beta warm-up rows)
|
||||
out[c] = new_vals.reindex(out.index).fillna(df[c])
|
||||
return out
|
||||
|
||||
|
||||
class TACHandler(DataHandlerLP):
|
||||
"""DataHandlerLP backed by the TradeAC parquet lake.
|
||||
|
||||
@@ -245,10 +417,12 @@ class TACHandler(DataHandlerLP):
|
||||
return get_common_feature_fields(lake_root, market, timeframe_for_freq(freq))
|
||||
|
||||
|
||||
__all__ = ["TACHandler", "DropAllNaN", "get_common_feature_fields"]
|
||||
__all__ = ["TACHandler", "DropAllNaN", "BenchResidual", "BenchBetaResidual", "get_common_feature_fields"]
|
||||
|
||||
|
||||
# Make `DropAllNaN` resolvable by bare name from processor configs (e.g. the default
|
||||
# ``infer_processors`` and workflow yamls that reference it without a ``module_path``),
|
||||
# mirroring how qlib registers its own processors in ``qlib.data.dataset.processor``.
|
||||
# Make `DropAllNaN`/`BenchResidual`/`BenchBetaResidual` resolvable by bare name from processor
|
||||
# configs (e.g. the default ``infer_processors`` and workflow yamls that reference them without a
|
||||
# ``module_path``), mirroring how qlib registers its own processors in ``qlib.data.dataset.processor``.
|
||||
processor_module.DropAllNaN = DropAllNaN
|
||||
processor_module.BenchResidual = BenchResidual
|
||||
processor_module.BenchBetaResidual = BenchBetaResidual
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +1,4 @@
|
||||
from .kelly_dropout import FractionalKellyDropoutStrategy # noqa: F401
|
||||
from .optimal_stop import OptimalStopControl # noqa: F401
|
||||
from .regime_gate import RegimeGateDropoutStrategy # noqa: F401
|
||||
from .top_bottom import TopBottomDropoutStrategy # noqa: F401
|
||||
from .weekly_rebalance import WeeklyRebalanceDropoutStrategy # noqa: F401
|
||||
from .long_short import LongShortTopkStrategy # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"OptimalStopControl",
|
||||
"FractionalKellyDropoutStrategy",
|
||||
"WeeklyRebalanceDropoutStrategy",
|
||||
"TopBottomDropoutStrategy",
|
||||
"RegimeGateDropoutStrategy",
|
||||
]
|
||||
__all__ = ["OptimalStopControl", "LongShortTopkStrategy"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Market-neutral top/bottom long-short strategy for cross-sectional signals.
|
||||
|
||||
Captures the cross-sectional long-short spread net of costs: buys the top-ranked
|
||||
``topk`` names and shorts the bottom-ranked ``topk`` names, equal-weight per
|
||||
side, sized to ``risk_degree`` of total value per side. Rebalances daily to the
|
||||
current rank (dropout-free: the book converges to the latest top/bottom sets).
|
||||
|
||||
The long and short legs use equal notional per side (gross exposure ~2x
|
||||
``risk_degree`` of NAV, i.e. approximately market neutral before transaction
|
||||
costs). Benchmark neutrality (SPY beta ~ 0) is the secondary sanity metric.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import copy
|
||||
|
||||
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__ = ["TopBottomDropoutStrategy"]
|
||||
|
||||
DEFAULT_SHORT_LEG = True
|
||||
DEFAULT_REBALANCE_DAILY = True
|
||||
|
||||
|
||||
class TopBottomDropoutStrategy(BaseSignalStrategy):
|
||||
"""Long top-k / short bottom-k equal-weight market-neutral book.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk : number of names on each side (long top-k and short bottom-k).
|
||||
short_leg : whether to open the short side (if False, long-only topk).
|
||||
rebalance_daily : if True rebalance to current rank every day; else keep
|
||||
positions and only refresh on score changes (dropout-style).
|
||||
risk_degree : fraction of total value deployed per side.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
topk: int = 10,
|
||||
short_leg: bool = DEFAULT_SHORT_LEG,
|
||||
rebalance_daily: bool = DEFAULT_REBALANCE_DAILY,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.topk = topk
|
||||
self.short_leg = short_leg
|
||||
self.rebalance_daily = rebalance_daily
|
||||
self._prev_longs = set()
|
||||
self._prev_shorts = set()
|
||||
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
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 or len(pred_score) == 0:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
# rank all names; topk longs and topk shorts
|
||||
ranked = pred_score.sort_values(ascending=False)
|
||||
longs = list(ranked.index[: self.topk])
|
||||
shorts = list(ranked.index[-self.topk :]) if self.short_leg else []
|
||||
|
||||
current_temp: "object" = copy.deepcopy(self.trade_position)
|
||||
current_codes = set(current_temp.get_stock_list())
|
||||
holdings = {c: current_temp for c in current_codes if abs(current_temp.get_stock_amount(c)) > 1e-6}
|
||||
|
||||
sell_orders: List[Order] = []
|
||||
buy_orders: List[Order] = []
|
||||
|
||||
def _tradable(code, direction):
|
||||
try:
|
||||
return self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=direction
|
||||
)
|
||||
except TypeError:
|
||||
return self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
|
||||
# determine target set (long/short)
|
||||
target_longs = set(longs)
|
||||
target_shorts = set(shorts)
|
||||
|
||||
# close positions not in the target book
|
||||
for code in list(holdings):
|
||||
if code in target_longs or code in target_shorts:
|
||||
continue
|
||||
amt = abs(current_temp.get_stock_amount(code))
|
||||
o = Order(
|
||||
stock_id=code,
|
||||
amount=amt,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.SELL if code in target_longs else Order.SELL,
|
||||
)
|
||||
if self.trade_exchange.check_order(o):
|
||||
sell_orders.append(o)
|
||||
self.trade_exchange.deal_order(o, position=current_temp)
|
||||
|
||||
# equal-weight notional per side
|
||||
total_value = current_temp.get_cash()
|
||||
for code, pos in holdings.items():
|
||||
if code in target_longs or code in target_shorts:
|
||||
mark = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=Order.SELL
|
||||
)
|
||||
if mark is not None and mark == mark:
|
||||
total_value += abs(current_temp.get_stock_amount(code)) * mark
|
||||
|
||||
side_notional = total_value * self.risk_degree / max(1, self.topk)
|
||||
|
||||
for code in longs:
|
||||
if code in holdings and abs(current_temp.get_stock_amount(code)) > 1e-6:
|
||||
continue
|
||||
px = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=Order.BUY
|
||||
)
|
||||
if px is None or px != px or px <= 0:
|
||||
continue
|
||||
amount = side_notional / px
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
|
||||
o = Order(
|
||||
stock_id=code,
|
||||
amount=amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.BUY,
|
||||
)
|
||||
if self.trade_exchange.check_order(o):
|
||||
buy_orders.append(o)
|
||||
|
||||
if self.short_leg:
|
||||
for code in shorts:
|
||||
if code in holdings and abs(current_temp.get_stock_amount(code)) > 1e-6:
|
||||
continue
|
||||
px = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=Order.SELL
|
||||
)
|
||||
if px is None or px != px or px <= 0:
|
||||
continue
|
||||
amount = side_notional / px
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=code, start_time=trade_start_time, end_time=trade_end_time
|
||||
)
|
||||
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
|
||||
o = Order(
|
||||
stock_id=code,
|
||||
amount=amount,
|
||||
start_time=trade_start_time,
|
||||
end_time=trade_end_time,
|
||||
direction=Order.SELL,
|
||||
)
|
||||
if self.trade_exchange.check_order(o):
|
||||
sell_orders.append(o)
|
||||
|
||||
return TradeDecisionWO(sell_orders + buy_orders, self)
|
||||
@@ -37,11 +37,20 @@ class WeeklyRebalanceDropoutStrategy(TopkDropoutStrategy):
|
||||
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, **kwargs):
|
||||
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:
|
||||
@@ -53,12 +62,24 @@ class WeeklyRebalanceDropoutStrategy(TopkDropoutStrategy):
|
||||
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:
|
||||
# not the first trading day of this ISO week -> hold
|
||||
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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -61,6 +61,11 @@ UNKNOWN_FIELD_NAMES = ("factor", "change", "trade_unit", "suspend_flag")
|
||||
#: columns in the parquet files that are not features
|
||||
NON_FEATURE_COLUMNS = ("t", "date", "market", "timeframe", "symbol")
|
||||
|
||||
#: Feature-family partitions merged by ``LakeConfig.load_features`` and scanned
|
||||
#: by the handler's field discovery. ``macro`` holds broadcast market-state
|
||||
#: columns (see skills/tac-qlib-custom/examples/persist_macro_broadcast.py).
|
||||
FEATURE_FAMILIES = ("ta", "sp", "macro")
|
||||
|
||||
|
||||
def timeframe_for_freq(freq: str) -> str:
|
||||
"""Map a qlib frequency (e.g. ``day``, ``1min``) to a lake timeframe (e.g. ``1d``)."""
|
||||
@@ -113,12 +118,12 @@ class LakeConfig:
|
||||
return self.features_dir(timeframe) / f"symbol={str(symbol).upper()}.parquet"
|
||||
|
||||
def load_features(self, timeframe: str, symbol: str) -> pd.DataFrame:
|
||||
"""All feature columns for a symbol, merging the `family=ta` and
|
||||
`family=sp` partitions by timestamp. Returns an empty frame when no
|
||||
"""All feature columns for a symbol, merging the `family=ta|sp|macro`
|
||||
partitions by timestamp. Returns an empty frame when no
|
||||
feature files exist (legacy flat layout falls back transparently)."""
|
||||
sym = str(symbol).upper()
|
||||
frames = []
|
||||
for family in ("ta", "sp"):
|
||||
for family in FEATURE_FAMILIES:
|
||||
p = self.features_dir(timeframe) / f"family={family}" / f"symbol={sym}.parquet"
|
||||
if p.exists():
|
||||
frames.append(pd.read_parquet(p))
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Q08 — Risk-limit A/B re-validation (trace 40)
|
||||
|
||||
**Status:** DONE (verdict: REFUTED as an IR edge; safety-net value retained)
|
||||
|
||||
## Input
|
||||
- Reference signal: exp-26 pred, run `21afc6afdb674a399b59dd76c97628ce` (mlflow exp 25)
|
||||
- Window: 2026-01-04 → 2026-08-10, Topk10 n_drop1, SPY benchmark, $1M, 5/15bp/$5
|
||||
- Tool: `rd_risk_calibrate` (A/B + sensitivity grid). Full JSON: `risk_calibration.json`
|
||||
|
||||
## Candidate spec (round-3 live spec)
|
||||
`{"liquidity_floor_adv": 5000000, "size_cap_pct": 0.12, "concentration_cap_pct": 0.95, "drawdown_pause_pct": 0.10}`
|
||||
|
||||
## Results (net, with cost)
|
||||
| Config | IR | Ann. return | Max DD |
|
||||
|---|---|---|---|
|
||||
| baseline (no limits) | 1.5804 | +27.50% | −6.91% |
|
||||
| **candidate (5M floor + caps)** | **1.5121** | +2.20% | **−0.65%** |
|
||||
| liquidity $10M | 1.5457 | +2.25% | −0.64% |
|
||||
|
||||
## Findings
|
||||
- **Floor binds, not a no-op**: $5M liquidity floor dropped 8 symbols —
|
||||
`DBA, DBC, ESPO, FDN, REM, TAN, UNG, XAR`.
|
||||
- **No IR edge from the gate**: candidate IR (1.512) is BELOW baseline (1.580).
|
||||
The exp-18 direction (floor IR 0.81→0.98) does NOT reproduce on the clean-lake
|
||||
reference signal.
|
||||
- **Drawdown cut is pure defunding**: size_cap 0.12 × concentration 0.95 fold
|
||||
the effective risk_degree to ~0.0095 → ~$9.5k deployed of $1M (~100x less).
|
||||
Sensitivity grid shows both caps are no-ops (conc 20–50% identical,
|
||||
size_cap 5–20% identical); only the liquidity floor moves returns, marginally.
|
||||
- **Conclusion**: keep the live spec as a safety net; there is no risk-limit
|
||||
gate IR edge to harvest when the signal is the bottleneck (exp-20 pattern).
|
||||
|
||||
## Artifacts on this branch
|
||||
- `evidence/q08-risklimit/risk_calibration.json` — full calibration dump
|
||||
- `queue/designs/q08_risk_limit_ab.md` — the pre-registered design doc
|
||||
@@ -1,401 +0,0 @@
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"label": "baseline (no limits)",
|
||||
"mean": 0.001155,
|
||||
"std": 0.011279,
|
||||
"annualized_return": 0.274989,
|
||||
"information_ratio": 1.580427,
|
||||
"max_drawdown": -0.069145
|
||||
},
|
||||
{
|
||||
"label": "liquidity $10,000,000",
|
||||
"mean": 9.4e-05,
|
||||
"std": 0.000942,
|
||||
"annualized_return": 0.022464,
|
||||
"information_ratio": 1.545736,
|
||||
"max_drawdown": -0.006389
|
||||
},
|
||||
{
|
||||
"label": "conc 20%",
|
||||
"mean": 0.000115,
|
||||
"std": 0.001168,
|
||||
"annualized_return": 0.027285,
|
||||
"information_ratio": 1.513718,
|
||||
"max_drawdown": -0.008104
|
||||
},
|
||||
{
|
||||
"label": "conc 30%",
|
||||
"mean": 0.000115,
|
||||
"std": 0.001168,
|
||||
"annualized_return": 0.027285,
|
||||
"information_ratio": 1.513718,
|
||||
"max_drawdown": -0.008104
|
||||
},
|
||||
{
|
||||
"label": "conc 40%",
|
||||
"mean": 0.000115,
|
||||
"std": 0.001168,
|
||||
"annualized_return": 0.027285,
|
||||
"information_ratio": 1.513718,
|
||||
"max_drawdown": -0.008104
|
||||
},
|
||||
{
|
||||
"label": "conc 50%",
|
||||
"mean": 0.000115,
|
||||
"std": 0.001168,
|
||||
"annualized_return": 0.027285,
|
||||
"information_ratio": 1.513718,
|
||||
"max_drawdown": -0.008104
|
||||
},
|
||||
{
|
||||
"label": "candidate {\"liquidity_floor_adv\": 5000000.0, \"size_cap_pct\": 0.12, \"concentration_cap_pct\": 0.95, \"drawdown_pause_pct\": 0.1}",
|
||||
"mean": 9.2e-05,
|
||||
"std": 0.000943,
|
||||
"annualized_return": 0.021991,
|
||||
"information_ratio": 1.512051,
|
||||
"max_drawdown": -0.00653
|
||||
},
|
||||
{
|
||||
"label": "size_cap 5%",
|
||||
"mean": 9.2e-05,
|
||||
"std": 0.000943,
|
||||
"annualized_return": 0.021991,
|
||||
"information_ratio": 1.512051,
|
||||
"max_drawdown": -0.00653
|
||||
},
|
||||
{
|
||||
"label": "size_cap 10%",
|
||||
"mean": 9.2e-05,
|
||||
"std": 0.000943,
|
||||
"annualized_return": 0.021991,
|
||||
"information_ratio": 1.512051,
|
||||
"max_drawdown": -0.00653
|
||||
},
|
||||
{
|
||||
"label": "size_cap 15%",
|
||||
"mean": 9.2e-05,
|
||||
"std": 0.000943,
|
||||
"annualized_return": 0.021991,
|
||||
"information_ratio": 1.512051,
|
||||
"max_drawdown": -0.00653
|
||||
},
|
||||
{
|
||||
"label": "size_cap 20%",
|
||||
"mean": 9.2e-05,
|
||||
"std": 0.000943,
|
||||
"annualized_return": 0.021991,
|
||||
"information_ratio": 1.512051,
|
||||
"max_drawdown": -0.00653
|
||||
},
|
||||
{
|
||||
"label": "liquidity $5,000,000",
|
||||
"mean": 9.2e-05,
|
||||
"std": 0.000943,
|
||||
"annualized_return": 0.021991,
|
||||
"information_ratio": 1.512051,
|
||||
"max_drawdown": -0.00653
|
||||
},
|
||||
{
|
||||
"label": "liquidity $1,000,000",
|
||||
"mean": 9.1e-05,
|
||||
"std": 0.000929,
|
||||
"annualized_return": 0.021625,
|
||||
"information_ratio": 1.508748,
|
||||
"max_drawdown": -0.006376
|
||||
},
|
||||
{
|
||||
"label": "liquidity $2,500,000",
|
||||
"mean": 7.1e-05,
|
||||
"std": 0.000918,
|
||||
"annualized_return": 0.017,
|
||||
"information_ratio": 1.199721,
|
||||
"max_drawdown": -0.007158
|
||||
}
|
||||
],
|
||||
"runs": {
|
||||
"baseline": {
|
||||
"risk": {
|
||||
"mean": 0.0011554172081987572,
|
||||
"std": 0.01127853762493476,
|
||||
"annualized_return": 0.27498929555130425,
|
||||
"information_ratio": 1.5804272791471323,
|
||||
"max_drawdown": -0.06914515336341577
|
||||
},
|
||||
"applied": {}
|
||||
},
|
||||
"candidate": {
|
||||
"risk": {
|
||||
"mean": 9.239707947451976e-05,
|
||||
"std": 0.0009427144352738658,
|
||||
"annualized_return": 0.0219905049149357,
|
||||
"information_ratio": 1.5120514373488407,
|
||||
"max_drawdown": -0.006530482262119444
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"size_cap 5%": {
|
||||
"risk": {
|
||||
"mean": 9.239707947451976e-05,
|
||||
"std": 0.0009427144352738658,
|
||||
"annualized_return": 0.0219905049149357,
|
||||
"information_ratio": 1.5120514373488407,
|
||||
"max_drawdown": -0.006530482262119444
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"size_cap 10%": {
|
||||
"risk": {
|
||||
"mean": 9.239707947451976e-05,
|
||||
"std": 0.0009427144352738658,
|
||||
"annualized_return": 0.0219905049149357,
|
||||
"information_ratio": 1.5120514373488407,
|
||||
"max_drawdown": -0.006530482262119444
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"size_cap 15%": {
|
||||
"risk": {
|
||||
"mean": 9.239707947451976e-05,
|
||||
"std": 0.0009427144352738658,
|
||||
"annualized_return": 0.0219905049149357,
|
||||
"information_ratio": 1.5120514373488407,
|
||||
"max_drawdown": -0.006530482262119444
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"size_cap 20%": {
|
||||
"risk": {
|
||||
"mean": 9.239707947451976e-05,
|
||||
"std": 0.0009427144352738658,
|
||||
"annualized_return": 0.0219905049149357,
|
||||
"information_ratio": 1.5120514373488407,
|
||||
"max_drawdown": -0.006530482262119444
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"conc 20%": {
|
||||
"risk": {
|
||||
"mean": 0.00011464156491316718,
|
||||
"std": 0.0011683839517000441,
|
||||
"annualized_return": 0.027284692449333788,
|
||||
"information_ratio": 1.5137180903503433,
|
||||
"max_drawdown": -0.008103887185240407
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"conc 30%": {
|
||||
"risk": {
|
||||
"mean": 0.00011464156491316718,
|
||||
"std": 0.0011683839517000441,
|
||||
"annualized_return": 0.027284692449333788,
|
||||
"information_ratio": 1.5137180903503433,
|
||||
"max_drawdown": -0.008103887185240407
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"conc 40%": {
|
||||
"risk": {
|
||||
"mean": 0.00011464156491316718,
|
||||
"std": 0.0011683839517000441,
|
||||
"annualized_return": 0.027284692449333788,
|
||||
"information_ratio": 1.5137180903503433,
|
||||
"max_drawdown": -0.008103887185240407
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"conc 50%": {
|
||||
"risk": {
|
||||
"mean": 0.00011464156491316718,
|
||||
"std": 0.0011683839517000441,
|
||||
"annualized_return": 0.027284692449333788,
|
||||
"information_ratio": 1.5137180903503433,
|
||||
"max_drawdown": -0.008103887185240407
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"liquidity $1,000,000": {
|
||||
"risk": {
|
||||
"mean": 9.086210454881382e-05,
|
||||
"std": 0.0009290831160004576,
|
||||
"annualized_return": 0.021625180882617688,
|
||||
"information_ratio": 1.508747982736451,
|
||||
"max_drawdown": -0.006376134679664126
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"ESPO"
|
||||
]
|
||||
}
|
||||
},
|
||||
"liquidity $2,500,000": {
|
||||
"risk": {
|
||||
"mean": 7.142665167642606e-05,
|
||||
"std": 0.0009184775632266332,
|
||||
"annualized_return": 0.016999543098989402,
|
||||
"information_ratio": 1.1997208834083914,
|
||||
"max_drawdown": -0.0071582979845040825
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"REM",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"liquidity $5,000,000": {
|
||||
"risk": {
|
||||
"mean": 9.239707947451976e-05,
|
||||
"std": 0.0009427144352738658,
|
||||
"annualized_return": 0.0219905049149357,
|
||||
"information_ratio": 1.5120514373488407,
|
||||
"max_drawdown": -0.006530482262119444
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"REM",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
"liquidity $10,000,000": {
|
||||
"risk": {
|
||||
"mean": 9.438545151345752e-05,
|
||||
"std": 0.0009420158170657147,
|
||||
"annualized_return": 0.02246373746020289,
|
||||
"information_ratio": 1.5457360696934006,
|
||||
"max_drawdown": -0.006388809561209335
|
||||
},
|
||||
"applied": {
|
||||
"dropped_liquidity": [
|
||||
"DBA",
|
||||
"DBC",
|
||||
"ESPO",
|
||||
"FDN",
|
||||
"ICLN",
|
||||
"ITA",
|
||||
"MDY",
|
||||
"REM",
|
||||
"SHY",
|
||||
"TAN",
|
||||
"UNG",
|
||||
"XAR"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"candidate": {
|
||||
"liquidity_floor_adv": 5000000.0,
|
||||
"size_cap_pct": 0.12,
|
||||
"concentration_cap_pct": 0.95,
|
||||
"drawdown_pause_pct": 0.1
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
# QUEUE-08 — Risk-limit A/B re-validation: $5M liquidity floor on the exp-26 reference
|
||||
|
||||
**Status:** QUEUED · **Priority:** P1 · **Effort:** tool-only (no new code)
|
||||
|
||||
## Hypothesis (prove)
|
||||
The $5M liquidity floor improves net IR and cuts drawdown on the **post-reset**
|
||||
reference signal (pre-reset exp 18, EVIDENCE#008: net IR 0.81→0.98, cumDD
|
||||
7.93%→5.44%), while size/concentration caps hurt by cutting deployed capital.
|
||||
Needs re-validation on the exp-26 lineage because exp 18 is pre-clean-lake and
|
||||
not comparable (EVIDENCE#009/010). Source: `book/CLAIMS.md` open question +
|
||||
`book/README.md` `TODO(evidence-needed: reconciliation of exp 18 risk-limit spec
|
||||
on the post-reset reference signal)`.
|
||||
|
||||
## Change vs exp-26 reference (ONE variable)
|
||||
- Reference: the saved exp-26 prediction (run `21afc6af…`, mlflow exp 25).
|
||||
- A/B via `rd_risk_calibrate` (runs limit-vs-no-limit A/B + sensitivity grid
|
||||
over size_cap_pct, concentration_cap_pct, liquidity_floor_adv) and/or
|
||||
`rd_backtest` with `risk_limits` on the SAME saved `pred.pkl`:
|
||||
- baseline: no limits (this must reproduce the exp-26 net +2.13% / IR 0.21);
|
||||
- candidate: `{"liquidity_floor_adv": 5000000, "size_cap_pct": 0.12,
|
||||
"concentration_cap_pct": 0.95, "drawdown_pause_pct": 0.10}` (round-3 spec).
|
||||
- Pick the spec (B2 calibration) that keeps live ≈ backtest.
|
||||
|
||||
## Acceptance
|
||||
- Candidate spec: `net_IR > 0.21` AND `net_max_drawdown < 7.69%` vs no-limit on
|
||||
the same pred. Size/concentration caps expected to REDUCE deployed capital
|
||||
(record the direction as confirmation of exp 18).
|
||||
- If the floor is a no-op (gates don't bind at this signal) → report that gates
|
||||
are no-ops when the signal is the bottleneck (exp 20 pattern) as a PROVEN
|
||||
clean-lake result.
|
||||
|
||||
## Execution prerequisites
|
||||
- None (uses saved pred + `rd_risk_calibrate`/`rd_backtest`). Trace the A/B as
|
||||
an experiment; record the spec chosen for the next live round.
|
||||
@@ -1,133 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# ABLATION A (baseline): LightGBM with RankIC early-stopping on the 50-ETF SP-5d
|
||||
# panel, using ALL 24 sp_* feature columns (ou,hmm,jump,har,trend,hurst,
|
||||
# signature). Copy of the canonical workflow_lgb_sp5d_rankic.yaml with a
|
||||
# distinct experiment name so the ablation runs are isolated.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/ablate_baseline_all_sp_fields.yaml \
|
||||
# experiment_name=tac-rd-rank-ablate
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
qlib_init:
|
||||
provider_uri: "{{ LAKE }}"
|
||||
region: us
|
||||
expression_cache: null
|
||||
dataset_cache: null
|
||||
|
||||
calendar_provider:
|
||||
class: tac_qlib.data.providers.LakeCalendarProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
instrument_provider:
|
||||
class: tac_qlib.data.providers.LakeInstrumentProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
markets: {}
|
||||
feature_provider:
|
||||
class: tac_qlib.data.providers.LakeFeatureProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
|
||||
exp_manager:
|
||||
class: MLflowExpManager
|
||||
module_path: qlib.workflow.expm
|
||||
kwargs:
|
||||
uri: "sqlite:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-rank-ablate"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seed: 42
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-10
|
||||
fit_start_time: 2015-01-03
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- class: ZScoreNorm
|
||||
kwargs: {}
|
||||
- class: Fillna
|
||||
kwargs: {}
|
||||
segments:
|
||||
train: [2015-01-03, 2025-09-01]
|
||||
valid: [2025-09-03, 2026-01-03]
|
||||
test: [2026-01-04, 2026-08-10]
|
||||
|
||||
record:
|
||||
- class: SignalRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs: {}
|
||||
- class: SigAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
ana_long_short: true
|
||||
ann_scaler: 252
|
||||
- class: PortAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
config:
|
||||
strategy:
|
||||
class: TopkDropoutStrategy
|
||||
module_path: qlib.contrib.strategy
|
||||
kwargs:
|
||||
signal: "<PRED>"
|
||||
topk: 10
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -1,134 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# ABLATION B (generic-only): same panel/model as the baseline, but feature
|
||||
# fields restricted to the model-free / generic stochastic-process families
|
||||
# (jump,har,trend,hurst,signature). Drops the model-specific ou (OU/AR-1
|
||||
# half-life) and hmm (2-state regime) families to test whether the generic
|
||||
# families alone dominate the rank dimension.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/ablate_generic_only_sp_fields.yaml \
|
||||
# experiment_name=tac-rd-rank-ablate
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
qlib_init:
|
||||
provider_uri: "{{ LAKE }}"
|
||||
region: us
|
||||
expression_cache: null
|
||||
dataset_cache: null
|
||||
|
||||
calendar_provider:
|
||||
class: tac_qlib.data.providers.LakeCalendarProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
instrument_provider:
|
||||
class: tac_qlib.data.providers.LakeInstrumentProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
markets: {}
|
||||
feature_provider:
|
||||
class: tac_qlib.data.providers.LakeFeatureProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
|
||||
exp_manager:
|
||||
class: MLflowExpManager
|
||||
module_path: qlib.workflow.expm
|
||||
kwargs:
|
||||
uri: "sqlite:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-rank-ablate"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seed: 42
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-10
|
||||
fit_start_time: 2015-01-03
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- class: ZScoreNorm
|
||||
kwargs: {}
|
||||
- class: Fillna
|
||||
kwargs: {}
|
||||
segments:
|
||||
train: [2015-01-03, 2025-09-01]
|
||||
valid: [2025-09-03, 2026-01-03]
|
||||
test: [2026-01-04, 2026-08-10]
|
||||
|
||||
record:
|
||||
- class: SignalRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs: {}
|
||||
- class: SigAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
ana_long_short: true
|
||||
ann_scaler: 252
|
||||
- class: PortAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
config:
|
||||
strategy:
|
||||
class: TopkDropoutStrategy
|
||||
module_path: qlib.contrib.strategy
|
||||
kwargs:
|
||||
signal: "<PRED>"
|
||||
topk: 10
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -1,141 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# ISOLATION: multi-seed RankIC ensemble, ablate-B generic-only feature set.
|
||||
#
|
||||
# Isolates the ensemble effect on the SP-5d rank signal. Same panel, segments,
|
||||
# history (full backfilled 2016+) and feature set as the exp-9 ablate-B winner
|
||||
# (generic-only sp_* families: jump,har,trend,hurst,signature), but replaces the
|
||||
# single RankICLGBModel with a 5-seed RankICEnsembleLGBModel (42,7,2026,99,123)
|
||||
# that averages per-day predictions.
|
||||
#
|
||||
# Differs from exp-15 (tac-rd-rank-ensemble, mlflow exp 15) ONLY by dropping the
|
||||
# TA subset (rsi_14,roc_10,macd_hist,willr_14,atr_14) and the inter-asset xr_*
|
||||
# features, so any change vs exp-15 is attributable to the feature set alone,
|
||||
# and any change vs exp-9 is attributable to the ensemble + full history alone.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=experiments/workflows/exp12_isolation_ensemble.yaml \
|
||||
# experiment_name=tac-rd-rank-ensemble-isolated
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
qlib_init:
|
||||
provider_uri: "{{ LAKE }}"
|
||||
region: us
|
||||
expression_cache: null
|
||||
dataset_cache: null
|
||||
|
||||
calendar_provider:
|
||||
class: tac_qlib.data.providers.LakeCalendarProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
instrument_provider:
|
||||
class: tac_qlib.data.providers.LakeInstrumentProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
markets: {}
|
||||
feature_provider:
|
||||
class: tac_qlib.data.providers.LakeFeatureProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
|
||||
exp_manager:
|
||||
class: MLflowExpManager
|
||||
module_path: qlib.workflow.expm
|
||||
kwargs:
|
||||
uri: "sqlite:///mlruns.db"
|
||||
default_exp_name: "tac-rd-rank-ensemble-isolated"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICEnsembleLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_ensemble
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seeds: "42,7,2026,99,123"
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-14
|
||||
fit_start_time: 2016-01-04
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- class: ZScoreNorm
|
||||
kwargs: {}
|
||||
- class: Fillna
|
||||
kwargs: {}
|
||||
segments:
|
||||
train: [2016-01-04, 2025-09-01]
|
||||
valid: [2025-09-03, 2026-01-03]
|
||||
test: [2026-01-04, 2026-08-10]
|
||||
|
||||
record:
|
||||
- class: SignalRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs: {}
|
||||
- class: SigAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
ana_long_short: true
|
||||
ann_scaler: 252
|
||||
- class: PortAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
config:
|
||||
strategy:
|
||||
class: TopkDropoutStrategy
|
||||
module_path: qlib.contrib.strategy
|
||||
kwargs:
|
||||
signal: "<PRED>"
|
||||
topk: 10
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -1,141 +0,0 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# EXP 18 - Risk-limit control: reference model + TopkDropout baseline (A).
|
||||
#
|
||||
# Signal/model identical to the reference (tac-rd-rank-ensemble-isolated,
|
||||
# run 0cea66d9...): RankICEnsembleLGBModel (parallel, 5 seeds) on the 50-ETF
|
||||
# SP-5d panel, test 2026-01-04..2026-08-10. This workflow reproduces the
|
||||
# unconstrained TopkDropout baseline net-of-cost so the risk-limited variant
|
||||
# (same pred, liquidity/size/concentration caps) can be compared 1:1.
|
||||
#
|
||||
# The risk_limits spec itself is applied via rd_backtest / rd_strategy_targets
|
||||
# (tool-level param, not a YAML key); this run records the unconstrained
|
||||
# baseline that the limit A/B is measured against.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=experiments/workflows/exp18-risk-limit/a_baseline.yaml \
|
||||
# experiment_name=tac-rd-risk-limit
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
qlib_init:
|
||||
provider_uri: "{{ LAKE }}"
|
||||
region: us
|
||||
expression_cache: null
|
||||
dataset_cache: null
|
||||
|
||||
calendar_provider:
|
||||
class: tac_qlib.data.providers.LakeCalendarProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
instrument_provider:
|
||||
class: tac_qlib.data.providers.LakeInstrumentProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
markets: {}
|
||||
feature_provider:
|
||||
class: tac_qlib.data.providers.LakeFeatureProvider
|
||||
kwargs:
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
|
||||
exp_manager:
|
||||
class: MLflowExpManager
|
||||
module_path: qlib.workflow.expm
|
||||
kwargs:
|
||||
uri: "sqlite:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-risk-limit"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICEnsembleLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_ensemble
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seeds: "42,7,2026,99,123"
|
||||
parallel: 5
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-14
|
||||
fit_start_time: 2016-01-04
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- class: ZScoreNorm
|
||||
kwargs: {}
|
||||
- class: Fillna
|
||||
kwargs: {}
|
||||
segments:
|
||||
train: [2016-01-04, 2025-09-01]
|
||||
valid: [2025-09-03, 2026-01-03]
|
||||
test: [2026-01-04, 2026-08-10]
|
||||
|
||||
record:
|
||||
- class: SignalRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs: {}
|
||||
- class: SigAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
ana_long_short: true
|
||||
ann_scaler: 252
|
||||
- class: PortAnaRecord
|
||||
module_path: qlib.workflow.record_temp
|
||||
kwargs:
|
||||
config:
|
||||
strategy:
|
||||
class: TopkDropoutStrategy
|
||||
module_path: qlib.contrib.strategy
|
||||
kwargs:
|
||||
signal: "<PRED>"
|
||||
topk: 10
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
Reference in New Issue
Block a user