Compare commits

..
27 changed files with 204 additions and 1799 deletions
+16 -19
View File
@@ -1,34 +1,31 @@
# TradeAC custom-qlib-code snapshot (auto-generated)
# parent repo HEAD : acc63a5957073310af2c27660d54d76a7863f824
# parent repo HEAD : 7698821bbe6e3687d8b026518e62e7feabe1688f
# tac-qlib/tac_qlib/contrib
# tac-qlib/tac_qlib/data
# per-file hashes (git hash-object):
1b6298c4a5652f2e863cbdc385a1014a570fcd59 tac-qlib/tac_qlib/contrib/__init__.py
6c57851807631dfa1a525f87538a1b0a495fd7b2 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
b419ee55ed455a1c45423d1c9025ca5cc0a98576 tac-qlib/tac_qlib/contrib/__pycache__/__init__.cpython-312.pyc
c76a9f17f680e74eea766eff27f7624359749ed6 tac-qlib/tac_qlib/contrib/data/__init__.py
1acd2cb845eac1bcee54450004a4af36484544ed tac-qlib/tac_qlib/contrib/data/__pycache__/__init__.cpython-312.pyc
2ef18965f77e8955580334d2edc09bd381204355 tac-qlib/tac_qlib/contrib/data/__pycache__/handler.cpython-312.pyc
3bba0f1696e4ab4b3deebec3f31f269b2e713899 tac-qlib/tac_qlib/contrib/data/handler.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
b151d139a0dcde87d74b21e7c4b729176ba5c39b tac-qlib/tac_qlib/contrib/model/__init__.py
c975d2b978f2cc08a388a5d921938704a3dd592d tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc
009ebd83c5156ca3d7039a112e0d277dd416ca86 tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc
1716b680b5623394229f7600ad4c81ad07fa6a2b tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc
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
184f80da8edf944bad3c8fb4d4d3d189bf4f082b tac-qlib/tac_qlib/contrib/strategy/__init__.py
9c9f7743970b1a3827bb72768bb6e8be03040759 tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc
6e38a7fa8584b80410ccc88e5feff228a7ece38b tac-qlib/tac_qlib/contrib/strategy/__pycache__/long_short.cpython-312.pyc
f983d5c2472cd16ef9f14a240674ec0a7f41e81c tac-qlib/tac_qlib/contrib/strategy/__pycache__/optimal_stop.cpython-312.pyc
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
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
92e6e90eb0cd0a25142034560f27adb6b705b1a8 tac-qlib/tac_qlib/data/__init__.py
a0e969bd6504bb8d9220f4641cc01e960c3120e4 tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc
2f8c537d11135155539276bee342d087aad8743e tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc
53cf7828c425f6b5032b206b93a238607111a6ed tac-qlib/tac_qlib/data/__pycache__/providers.cpython-312.pyc
1953fb2a6371525db7f7b0e1c9dfbf3492d82110 tac-qlib/tac_qlib/data/config.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
8d0644f6f0d1efb94798ed444cc73e63b643459b tac-qlib/tac_qlib/data/providers.py
@@ -1 +0,0 @@
from .tradeac_exchange import TradeACExchange
@@ -1,432 +0,0 @@
# 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
+5 -179
View File
@@ -15,9 +15,6 @@ 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
@@ -25,7 +22,6 @@ from qlib.utils import get_callable_kwargs
from ...data.config import (
LakeConfig,
timeframe_for_freq,
FEATURE_FAMILIES,
NON_FEATURE_COLUMNS,
)
@@ -96,7 +92,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 FEATURE_FAMILIES:
for fam in ("ta", "sp"):
fam_dir = feat_dir / f"family={fam}"
if fam_dir.is_dir():
common |= _family_common(fam_dir)
@@ -147,174 +143,6 @@ 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.
@@ -417,12 +245,10 @@ class TACHandler(DataHandlerLP):
return get_common_feature_fields(lake_root, market, timeframe_for_freq(freq))
__all__ = ["TACHandler", "DropAllNaN", "BenchResidual", "BenchBetaResidual", "get_common_feature_fields"]
__all__ = ["TACHandler", "DropAllNaN", "get_common_feature_fields"]
# 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``.
# 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``.
processor_module.DropAllNaN = DropAllNaN
processor_module.BenchResidual = BenchResidual
processor_module.BenchBetaResidual = BenchBetaResidual
@@ -1,4 +1,13 @@
from .kelly_dropout import FractionalKellyDropoutStrategy # noqa: F401
from .optimal_stop import OptimalStopControl # noqa: F401
from .long_short import LongShortTopkStrategy # noqa: F401
from .regime_gate import RegimeGateDropoutStrategy # noqa: F401
from .top_bottom import TopBottomDropoutStrategy # noqa: F401
from .weekly_rebalance import WeeklyRebalanceDropoutStrategy # noqa: F401
__all__ = ["OptimalStopControl", "LongShortTopkStrategy"]
__all__ = [
"OptimalStopControl",
"FractionalKellyDropoutStrategy",
"WeeklyRebalanceDropoutStrategy",
"TopBottomDropoutStrategy",
"RegimeGateDropoutStrategy",
]
@@ -1,361 +0,0 @@
"""Long-short Top-K strategy for cross-sectional signals.
Each day the strategy ranks the cross-section by prediction score and rebalances
to an equal-weight two-sided book: the ``topk`` highest-ranked names go long and
the ``k_short`` lowest-ranked names go short. Net-new shorts are opened by
selling beyond current holdings, which requires a short-aware exchange such as
``tac_qlib.contrib.backtest.tradeac_exchange.TradeACExchange`` with
``allow_short=True`` (borrow limits, margin requirements and borrow fees are
enforced there, not here).
Sizing deploys ``equity * risk_degree`` as gross notional split evenly across
all long and short legs, so the book is approximately market neutral.
``allow_short=False`` disables the short side entirely (long-only ``topk``).
Short eligibility can be restricted further, with static or dynamic gates:
``short_whitelist`` limits shorts to an explicit symbol set; ``short_vol_top_pct``
requires a candidate's trailing realized volatility to rank in the top fraction
of that day's cross-section; ``short_max_mom`` (falling-knife filter) only
allows shorting names whose own trailing momentum is at/below a threshold;
``short_regime_sma`` disables shorts entirely while the benchmark trades above
its moving average (risk-on). Borrow availability itself is enforced by the
exchange (``borrowable`` whitelist / per-symbol caps via ``TradeACExchange``).
Wired into qrun workflows like any ``BaseStrategy`` (see ``PortAnaRecord``
config). Mirrors the API usage of qlib's ``TopkDropoutStrategy``: ``Order`` /
``OrderDir`` from ``qlib.backtest.decision``, ``trade_calendar`` /
``trade_exchange`` / ``trade_position`` injected by the backtest executor.
"""
from __future__ import annotations
import copy
from typing import Dict, List, Optional
import pandas as pd
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import BaseSignalStrategy
from qlib.log import get_module_logger
__all__ = ["LongShortTopkStrategy"]
class LongShortTopkStrategy(BaseSignalStrategy):
"""Equal-weight long-short Top-K strategy over a cross-sectional signal.
Parameters
----------
topk : number of long legs (highest-ranked names).
k_short : number of short legs (lowest-ranked names).
hold_thresh : minimum holding days before a leg may be closed/reduced.
only_tradable : only select candidates tradable on the trade date.
rebalance_tol : skip rebalances smaller than this fraction of a leg's
target notional (turnover control).
allow_short : enable/disable the short side. With ``False`` the bottom-ranked
legs are dropped and the book is long-only ``topk``; pair with
``allow_short=False`` on the exchange for a fully borrow-free run.
Legacy alias ``enable_short`` is accepted.
short_whitelist : optional list of symbols eligible for shorting; candidates
outside the list are skipped (``None`` = all names eligible).
short_vol_window : trailing window (trading days) for realized-vol estimation.
short_vol_top_pct : if set, a short candidate's trailing realized volatility
must rank at or above this percentile of that day's cross-section
(e.g. ``0.5`` = only the more volatile half may be shorted). Candidates
without measurable vol are never shorted.
short_mom_window : trailing window (trading days) for the candidate momentum
used by the falling-knife gate.
short_max_mom : if set, a candidate's trailing ``short_mom_window``-day return
must be <= this value to be shortable (e.g. ``0.0`` = only short names
that are actually falling). Candidates without measurable momentum are
never shorted.
short_regime_symbol : benchmark symbol for the regime gate (default SPY).
short_regime_sma : if set, shorts are only allowed on days where the regime
symbol's last close (strictly before the execution bar) is BELOW its
``short_regime_sma``-day moving average — i.e. shorts are disabled in
risk-on regimes and enabled in drawdowns.
"""
def __init__(
self,
*,
signal=None,
topk: int = 4,
k_short: int = 2,
hold_thresh: int = 1,
only_tradable: bool = True,
rebalance_tol: float = 0.05,
allow_short: Optional[bool] = None,
enable_short: Optional[bool] = None,
short_whitelist: Optional[List[str]] = None,
short_vol_window: int = 20,
short_vol_top_pct: Optional[float] = None,
short_mom_window: int = 20,
short_max_mom: Optional[float] = None,
short_regime_symbol: str = "SPY",
short_regime_sma: Optional[int] = None,
risk_degree: float = 0.95,
trade_exchange=None,
level_infra=None,
common_infra=None,
**kwargs,
):
super().__init__(
signal=signal,
risk_degree=risk_degree,
trade_exchange=trade_exchange,
level_infra=level_infra,
common_infra=common_infra,
**kwargs,
)
if allow_short is None:
allow_short = True if enable_short is None else bool(enable_short)
self.allow_short = bool(allow_short)
self.topk = topk
self.k_short = k_short
self.hold_thresh = hold_thresh
self.only_tradable = only_tradable
self.rebalance_tol = rebalance_tol
self.short_whitelist = set(short_whitelist) if short_whitelist is not None else None
if not 0 < float(short_vol_window) <= 1000:
raise ValueError(f"short_vol_window must be in (0, 1000], got {short_vol_window}")
self.short_vol_window = int(short_vol_window)
if short_vol_top_pct is not None and not 0.0 < float(short_vol_top_pct) <= 1.0:
raise ValueError(f"short_vol_top_pct must be in (0, 1], got {short_vol_top_pct}")
self.short_vol_top_pct = None if short_vol_top_pct is None else float(short_vol_top_pct)
if not 0 < float(short_mom_window) <= 1000:
raise ValueError(f"short_mom_window must be in (0, 1000], got {short_mom_window}")
self.short_mom_window = int(short_mom_window)
self.short_max_mom = None if short_max_mom is None else float(short_max_mom)
self.short_regime_symbol = str(short_regime_symbol)
if short_regime_sma is not None and not 1 < int(short_regime_sma) <= 1000:
raise ValueError(f"short_regime_sma must be in (1, 1000], got {short_regime_sma}")
self.short_regime_sma = None if short_regime_sma is None else int(short_regime_sma)
# per-day caches (keyed by trade date)
self._vol_cache_key: Optional[str] = None
self._vol_cache_val: Dict[str, Dict[str, float]] = {}
self._regime_cache: Dict[str, bool] = {}
# ------------------------------------------------------------------ utils
def _is_tradable(self, code, start, end) -> bool:
if not self.only_tradable:
return True
try:
return self.trade_exchange.is_stock_tradable(stock_id=code, start_time=start, end_time=end)
except TypeError:
return True
def _mark_price(self, code, start, end) -> Optional[float]:
try:
px = self.trade_exchange.get_deal_price(
stock_id=code, start_time=start, end_time=end, direction=OrderDir.BUY
)
except (KeyError, ValueError):
return None
if px is None or px != px or px <= 0:
return None
return float(px)
def _day_stats(self, codes: List[str], trade_start) -> Dict[str, Dict[str, float]]:
"""Per-day cross-sectional stats used by the dynamic short gates.
For each code, returns ``{"vol_rank": r}`` (percentile of trailing
realized vol over ``short_vol_window`` bars across that day's
cross-section) when the vol gate is on, and ``{"mom": m}`` (trailing
``short_mom_window``-bar return) when the falling-knife gate is on.
All series end on the last bar strictly BEFORE the execution bar (no
lookahead). Codes without measurable data are simply absent — such
candidates are never shorted (fail-closed).
"""
if self.short_vol_top_pct is None and self.short_max_mom is None:
return {}
key = str(pd.Timestamp(trade_start))
if self._vol_cache_key == key:
return self._vol_cache_val
out: Dict[str, Dict[str, float]] = {}
try:
from qlib.data import D
end = pd.Timestamp(trade_start)
buf = max(self.short_vol_window, self.short_mom_window) * 3 + 30
df = D.features(
sorted(codes),
["$close"],
start_time=end - pd.Timedelta(days=buf),
end_time=end - pd.Timedelta(days=1),
)
close = df["$close"].unstack(level="instrument") if isinstance(df.index, pd.MultiIndex) else df["$close"]
if self.short_vol_top_pct is not None:
vol = close.pct_change().rolling(self.short_vol_window).std().iloc[-1]
for code, rank in vol.rank(pct=True).dropna().items():
out.setdefault(str(code), {})["vol_rank"] = float(rank)
if self.short_max_mom is not None:
w = min(self.short_mom_window, len(close) - 1)
mom = close.iloc[-1] / close.iloc[-(w + 1)] - 1
for code, m in mom.items():
if m == m:
out.setdefault(str(code), {})["mom"] = float(m)
except Exception as e: # noqa: BLE001 - degrade to fail-closed (no shorts)
get_module_logger(self.__class__.__name__).warning(
f"short gates unavailable ({type(e).__name__}: {e}); no shorts this step"
)
self._vol_cache_key, self._vol_cache_val = key, out
return out
def _regime_ok(self, trade_start) -> bool:
"""True when shorting is allowed by the benchmark-regime gate.
With ``short_regime_sma`` set, shorts are permitted only while the
regime symbol's last close strictly before the execution bar sits below
its moving average (risk-off). Data failure fails closed (no shorts).
"""
if self.short_regime_sma is None:
return True
key = str(pd.Timestamp(trade_start))
cached = self._regime_cache.get(key)
if cached is not None:
return cached
ok = False
try:
from qlib.data import D
end = pd.Timestamp(trade_start)
df = D.features(
[self.short_regime_symbol],
["$close"],
start_time=end - pd.Timedelta(days=int(self.short_regime_sma * 3 + 30)),
end_time=end - pd.Timedelta(days=1),
)
s = df["$close"]
if isinstance(s.index, pd.MultiIndex):
s = s.droplevel("instrument")
sma = s.rolling(self.short_regime_sma).mean().iloc[-1]
px = s.iloc[-1]
ok = bool(px < sma)
except Exception as e: # noqa: BLE001 - degrade to fail-closed (no shorts)
get_module_logger(self.__class__.__name__).warning(
f"regime gate unavailable ({type(e).__name__}: {e}); no shorts this step"
)
self._regime_cache[key] = ok
return ok
# ------------------------------------------------------------- decision
def generate_trade_decision(self, execute_result=None):
trade_step = self.trade_calendar.get_trade_step()
trade_start, trade_end = self.trade_calendar.get_step_time(trade_step)
pred_start, pred_end = self.trade_calendar.get_step_time(trade_step, shift=1)
pred_score = self.signal.get_signal(start_time=pred_start, end_time=pred_end)
if isinstance(pred_score, pd.DataFrame):
pred_score = pred_score.iloc[:, 0]
if pred_score is None or len(pred_score) == 0:
return TradeDecisionWO([], self)
pred_score = pred_score.dropna()
if pred_score.empty:
return TradeDecisionWO([], self)
time_per_step = self.trade_calendar.get_freq()
current_temp = copy.deepcopy(self.trade_position)
# ---- signed current holdings ---------------------------------------
cur_amount: Dict[str, float] = {}
for code in current_temp.get_stock_list():
amt = float(current_temp.get_stock_amount(code))
if abs(amt) > 1e-6:
cur_amount[code] = amt
# ---- targets: top-k long, bottom-k_short short ----------------------
ranked = list(pred_score.sort_values(ascending=False).index)
longs: List[str] = []
for code in ranked:
if len(longs) >= self.topk:
break
if self._is_tradable(code, trade_start, trade_end):
longs.append(code)
shorts: List[str] = []
if self.allow_short and self._regime_ok(trade_start):
stats = self._day_stats(list(ranked), trade_start)
for code in reversed(ranked):
if len(shorts) >= self.k_short:
break
if code in longs:
continue
if not self._is_tradable(code, trade_start, trade_end):
continue
if self.short_whitelist is not None and code not in self.short_whitelist:
continue
st = stats.get(code)
if self.short_vol_top_pct is not None:
rank = None if st is None else st.get("vol_rank")
if rank is None or rank < self.short_vol_top_pct:
continue
if self.short_max_mom is not None:
mom = None if st is None else st.get("mom")
if mom is None or mom > self.short_max_mom:
continue
shorts.append(code)
# ---- marks & equity --------------------------------------------------
marks: Dict[str, float] = {}
for code in set(cur_amount) | set(longs) | set(shorts):
px = self._mark_price(code, trade_start, trade_end)
if px is not None:
marks[code] = px
equity = current_temp.get_cash()
for code, amt in cur_amount.items():
if code in marks:
equity += amt * marks[code]
if equity <= 0:
return TradeDecisionWO([], self)
n_legs = len([c for c in longs if c in marks]) + len([c for c in shorts if c in marks])
if n_legs == 0:
return TradeDecisionWO([], self)
per_leg = equity * self.risk_degree / n_legs
target_signed: Dict[str, float] = {}
for code in longs:
if code in marks:
target_signed[code] = per_leg / marks[code]
for code in shorts:
if code in marks:
target_signed[code] = -(per_leg / marks[code])
# ---- order generation -------------------------------------------------
sell_orders: List[Order] = []
buy_orders: List[Order] = []
def submit(code: str, amount: float, direction: int) -> None:
factor = self.trade_exchange.get_factor(stock_id=code, start_time=trade_start, end_time=trade_end)
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
if amount <= 1e-6:
return
o = Order(stock_id=code, amount=amount, start_time=trade_start, end_time=trade_end, direction=direction)
if self.trade_exchange.check_order(o):
(buy_orders if direction == Order.BUY else sell_orders).append(o)
# close holdings that are no longer targeted (frees cash / unwinds shorts)
for code, amt in cur_amount.items():
if code in target_signed:
continue
if marks.get(code) is None:
continue
if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
continue
submit(code, abs(amt), Order.SELL if amt > 0 else Order.BUY)
# rebalance targeted legs toward their signed target quantity
for code, tgt in target_signed.items():
cur = cur_amount.get(code, 0.0)
delta = tgt - cur
if abs(delta * marks[code]) < max(self.rebalance_tol * per_leg, 1.0):
continue
if delta > 0:
submit(code, delta, Order.BUY)
else:
if cur > 0 and current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh:
continue
submit(code, -delta, Order.SELL)
return TradeDecisionWO(sell_orders + buy_orders, self)
@@ -0,0 +1,169 @@
"""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)
+3 -8
View File
@@ -61,11 +61,6 @@ 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``)."""
@@ -118,12 +113,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|sp|macro`
partitions by timestamp. Returns an empty frame when no
"""All feature columns for a symbol, merging the `family=ta` and
`family=sp` 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 FEATURE_FAMILIES:
for family in ("ta", "sp"):
p = self.features_dir(timeframe) / f"family={family}" / f"symbol={sym}.parquet"
if p.exists():
frames.append(pd.read_parquet(p))
@@ -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
-141
View File
@@ -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
-97
View File
@@ -1,97 +0,0 @@
# Re-run of experiment 16 with validated family=ta and family=sp lake features.
{%- 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 FEATURES = "$open,$high,$low,$close,$vwap,$volume,sma_5,sma_20,ema_12,ema_26,rsi_14,macd,macd_signal,macd_hist,bb_upper,bb_middle,bb_lower,atr_14,adx_14,sp_ret,sp_ou_half_life,sp_ou_revert,sp_ou_zscore,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_down,sp_max_move,sp_max_up,sp_rv1,sp_rv5,sp_rv22,sp_rv_ac1,sp_rv_cv_22,sp_vol_ratio_1_22,sp_vol_ratio_5_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_rskew_5,sp_rskew_22,sp_rkurt_5,sp_rkurt_22,sp_dsv_1,sp_dsv_5,sp_dsv_22,sp_dsv_ratio_1,sp_dsv_ratio_5,sp_dsv_ratio_22,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead,sp_sig_level2_lead_lag_5,sp_sig_level2_lag_lead_5" %}
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-exp16-db-ta-sp" }
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-10
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: "{{ FEATURES }}"
infer_processors:
- { class: DropAllNaN, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { class: ProcessInf, kwargs: {} }
- { class: CSRankNorm, kwargs: {} }
- { class: ZScoreNorm, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { 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
-97
View File
@@ -1,97 +0,0 @@
# General stochastic-process feature ablation: no TA, HMM, or OU fields.
{%- 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 FEATURES = "$open,$high,$low,$close,$vwap,$volume,sp_ret,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_down,sp_max_move,sp_max_up,sp_rv1,sp_rv5,sp_rv22,sp_rv_ac1,sp_rv_cv_22,sp_vol_ratio_1_22,sp_vol_ratio_5_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_rskew_5,sp_rskew_22,sp_rkurt_5,sp_rkurt_22,sp_dsv_1,sp_dsv_5,sp_dsv_22,sp_dsv_ratio_1,sp_dsv_ratio_5,sp_dsv_ratio_22,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead,sp_sig_level2_lead_lag_5,sp_sig_level2_lag_lead_5" %}
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-exp22-stochastic-general" }
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-10
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: "{{ FEATURES }}"
infer_processors:
- { class: DropAllNaN, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { class: ProcessInf, kwargs: {} }
- { class: CSRankNorm, kwargs: {} }
- { class: ZScoreNorm, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { 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
-97
View File
@@ -1,97 +0,0 @@
# Exact compact stochastic feature set requested for a new run in MLflow exp 25.
{%- 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 FEATURES = "$open,$high,$low,$close,$vwap,$volume,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-exp22-stochastic-general" }
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-10
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: "{{ FEATURES }}"
infer_processors:
- { class: DropAllNaN, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { class: ProcessInf, kwargs: {} }
- { class: CSRankNorm, kwargs: {} }
- { class: ZScoreNorm, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { 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
-98
View File
@@ -1,98 +0,0 @@
# Compact stochastic feature set with reduced turnover: n_drop=1 instead of 2.
# Same setup as exp24 (compact baseline) but replacing the TopkDropout n_drop 2 with 1.
{%- 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 FEATURES = "$open,$high,$low,$close,$vwap,$volume,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-exp22-stochastic-general" }
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-10
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: "{{ FEATURES }}"
infer_processors:
- { class: DropAllNaN, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { class: ProcessInf, kwargs: {} }
- { class: CSRankNorm, kwargs: {} }
- { class: ZScoreNorm, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
- { 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: 1, 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