From b872f64ce605b37603cad120a6b707fa573fed59 Mon Sep 17 00:00:00 2001 From: zhaoli Date: Thu, 27 Aug 2026 12:02:21 +0000 Subject: [PATCH] start experiment 63 (exp/63-scheduled-algo-retrain-on-2026-08-26-tac) --- code/MANIFEST.txt | 37 +- .../__pycache__/__init__.cpython-312.pyc | Bin 374 -> 374 bytes .../tac_qlib/contrib/backtest/__init__.py | 1 + .../contrib/backtest/tradeac_exchange.py | 432 ++++++++++++++++++ .../data/__pycache__/__init__.cpython-312.pyc | Bin 216 -> 216 bytes .../data/__pycache__/handler.cpython-312.pyc | Bin 11073 -> 21533 bytes .../tac-qlib/tac_qlib/contrib/data/handler.py | 184 +++++++- .../__pycache__/__init__.cpython-312.pyc | Bin 319 -> 319 bytes .../__pycache__/rank_ensemble.cpython-312.pyc | Bin 9461 -> 9461 bytes .../__pycache__/rank_gbdt.cpython-312.pyc | Bin 12288 -> 12288 bytes .../tac_qlib/contrib/strategy/__init__.py | 3 +- .../__pycache__/__init__.cpython-312.pyc | Bin 234 -> 301 bytes .../__pycache__/long_short.cpython-312.pyc | Bin 0 -> 19672 bytes .../__pycache__/optimal_stop.cpython-312.pyc | Bin 10428 -> 10428 bytes .../contrib/strategy/kelly_dropout.py | 201 ++++++++ .../tac_qlib/contrib/strategy/long_short.py | 361 +++++++++++++++ .../tac_qlib/contrib/strategy/regime_gate.py | 231 ++++++++++ .../contrib/strategy/weekly_rebalance.py | 202 ++++++++ .../data/__pycache__/__init__.cpython-312.pyc | Bin 522 -> 522 bytes .../data/__pycache__/config.cpython-312.pyc | Bin 11464 -> 11507 bytes .../__pycache__/providers.cpython-312.pyc | Bin 13340 -> 13340 bytes code/tac-qlib/tac_qlib/data/config.py | 11 +- 22 files changed, 1639 insertions(+), 24 deletions(-) create mode 100644 code/tac-qlib/tac_qlib/contrib/backtest/__init__.py create mode 100644 code/tac-qlib/tac_qlib/contrib/backtest/tradeac_exchange.py create mode 100644 code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/long_short.cpython-312.pyc create mode 100644 code/tac-qlib/tac_qlib/contrib/strategy/kelly_dropout.py create mode 100644 code/tac-qlib/tac_qlib/contrib/strategy/long_short.py create mode 100644 code/tac-qlib/tac_qlib/contrib/strategy/regime_gate.py create mode 100644 code/tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py diff --git a/code/MANIFEST.txt b/code/MANIFEST.txt index 1ccd108..ea87b82 100644 --- a/code/MANIFEST.txt +++ b/code/MANIFEST.txt @@ -1,27 +1,34 @@ # TradeAC custom-qlib-code snapshot (auto-generated) -# parent repo HEAD : 6a1b05db2ee70d7661d695f5fd0b77b19c70e18a +# parent repo HEAD : 66cf0a149f2593ed54da925632dde905ace97ac0 # 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 + 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 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 + 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 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 + 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 d3f051f3a8650c42fedc7b367b966f7c74fb5789 tac-qlib/tac_qlib/contrib/model/rank_ensemble.py d03e6611338918d4aac5eea4adf26f85a3763652 tac-qlib/tac_qlib/contrib/model/rank_gbdt.py - 4afcf9058231111c412925f4c4b84e81d656db87 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 + 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 + 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 + fe60bacdfedd48617863be31f24b7c7daebfac5a 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 + 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 8d0644f6f0d1efb94798ed444cc73e63b643459b tac-qlib/tac_qlib/data/providers.py diff --git a/code/tac-qlib/tac_qlib/contrib/__pycache__/__init__.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/__pycache__/__init__.cpython-312.pyc index b419ee55ed455a1c45423d1c9025ca5cc0a98576..6c57851807631dfa1a525f87538a1b0a495fd7b2 100644 GIT binary patch delta 20 acmeyy^o@!8G%qg~0}w>)>fOkl%?JQMjRmp* delta 20 acmeyy^o@!8G%qg~0}yo1Zr#Y8%?JQN0tLJP diff --git a/code/tac-qlib/tac_qlib/contrib/backtest/__init__.py b/code/tac-qlib/tac_qlib/contrib/backtest/__init__.py new file mode 100644 index 0000000..2224424 --- /dev/null +++ b/code/tac-qlib/tac_qlib/contrib/backtest/__init__.py @@ -0,0 +1 @@ +from .tradeac_exchange import TradeACExchange diff --git a/code/tac-qlib/tac_qlib/contrib/backtest/tradeac_exchange.py b/code/tac-qlib/tac_qlib/contrib/backtest/tradeac_exchange.py new file mode 100644 index 0000000..0bf40de --- /dev/null +++ b/code/tac-qlib/tac_qlib/contrib/backtest/tradeac_exchange.py @@ -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 + diff --git a/code/tac-qlib/tac_qlib/contrib/data/__pycache__/__init__.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/data/__pycache__/__init__.cpython-312.pyc index 2f6c67620aa2f9e6aaaef3369361d9b3eac3d6ca..1acd2cb845eac1bcee54450004a4af36484544ed 100644 GIT binary patch delta 19 Zcmcb?c!QDqG%qg~0}w>)>Yd1a763Mb1+@SG delta 19 Zcmcb?c!QDqG%qg~0}yo1Zk@<|763Nx1-}3Q diff --git a/code/tac-qlib/tac_qlib/contrib/data/__pycache__/handler.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/data/__pycache__/handler.cpython-312.pyc index fdd5923a70a399e8680913593ff111641947898e..2ef18965f77e8955580334d2edc09bd381204355 100644 GIT binary patch delta 10918 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 E3t^&x4FCWD diff --git a/code/tac-qlib/tac_qlib/contrib/data/handler.py b/code/tac-qlib/tac_qlib/contrib/data/handler.py index 0dd25ef..3bba0f1 100644 --- a/code/tac-qlib/tac_qlib/contrib/data/handler.py +++ b/code/tac-qlib/tac_qlib/contrib/data/handler.py @@ -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 diff --git a/code/tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/model/__pycache__/__init__.cpython-312.pyc index 08dec87ccdf6bb5d2cf611ca3032a4280aaab8cf..c975d2b978f2cc08a388a5d921938704a3dd592d 100644 GIT binary patch delta 20 acmdnbw4aIlG%qg~0}w>)>fOj~!UzC2^#u(8 delta 20 acmdnbw4aIlG%qg~0}yo1Zr#Xj!UzC3Y6TYn diff --git a/code/tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/model/__pycache__/rank_ensemble.cpython-312.pyc index 6fb61946ea9a83dfb560de3717f5fbf482c4c00e..009ebd83c5156ca3d7039a112e0d277dd416ca86 100644 GIT binary patch delta 20 acmezB`PGyAG%qg~0}w>)>fOlwN(BH*ga+UM delta 20 acmezB`PGyAG%qg~0}yo1Zr#ZJN(BH*`3C9$ diff --git a/code/tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/model/__pycache__/rank_gbdt.cpython-312.pyc index 3e80f2e08b661ddd2f58ffe5a6196063fa41ae51..1716b680b5623394229f7600ad4c81ad07fa6a2b 100644 GIT binary patch delta 20 acmZojXh`5b&CAQh00a@cdN*=^)dv7Qg9bVP delta 20 acmZojXh`5b&CAQh00f=0TQ_ol)dv7Q_y$A( diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/__init__.py b/code/tac-qlib/tac_qlib/contrib/strategy/__init__.py index 4afcf90..184f80d 100644 --- a/code/tac-qlib/tac_qlib/contrib/strategy/__init__.py +++ b/code/tac-qlib/tac_qlib/contrib/strategy/__init__.py @@ -1,3 +1,4 @@ from .optimal_stop import OptimalStopControl # noqa: F401 +from .long_short import LongShortTopkStrategy # noqa: F401 -__all__ = ["OptimalStopControl"] +__all__ = ["OptimalStopControl", "LongShortTopkStrategy"] diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/__init__.cpython-312.pyc index 6ad10c2ebe37c16417e67c7aeb731ad1fcb6da2f..9c9f7743970b1a3827bb72768bb6e8be03040759 100644 GIT binary patch delta 207 zcmaFGxR#0cG%qg~0}w>)>do3Tkyo-_1<0Askiw9{n8T3E7{$m4Vl(A1^oo)9G%qg~0}yo1Zq16F$SWx$2INdE!$vknQ$i!{_N}NC*BM=u00ErLGjEszT8FU_S%U|G9Xk;&92MPiJZ+{fa diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/long_short.cpython-312.pyc b/code/tac-qlib/tac_qlib/contrib/strategy/__pycache__/long_short.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e38a7fa8584b80410ccc88e5feff228a7ece38b GIT binary patch literal 19672 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 delta 20 acmdlJxF?YNG%qg~0}yo1Zr#YeQ3C)(H3m}v diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/kelly_dropout.py b/code/tac-qlib/tac_qlib/contrib/strategy/kelly_dropout.py new file mode 100644 index 0000000..896ef74 --- /dev/null +++ b/code/tac-qlib/tac_qlib/contrib/strategy/kelly_dropout.py @@ -0,0 +1,201 @@ +"""Fractional-Kelly dropout strategy for cross-sectional signals. + +Sizing rule variant of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``: +the topk/n_drop SELECTION is identical to the reference, but the buy size is +proportional to the score MAGNITUDE (edge) instead of equal-weight, capped at a +fraction ``cap_frac`` of the equal-weight notional so a single name cannot +over-concentrate the book. + +``cap_frac`` is the fraction of the equal-weight per-name notional that a top +signal can deploy at most (e.g. 0.5 = at most half the equal-weight size). +Names whose score is below the median of the buy set get a proportionally +smaller slice; the residual stays in cash (that is the point of the rule: +throw away less edge per name, deploy less capital when conviction is low). +""" + +from __future__ import annotations + +from typing import List + +import numpy as np +import pandas as pd + +from qlib.backtest import Order +from qlib.backtest.decision import OrderDir, TradeDecisionWO +from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy + +__all__ = ["FractionalKellyDropoutStrategy"] + +DEFAULT_CAP_FRAC = 0.5 + + +class FractionalKellyDropoutStrategy(TopkDropoutStrategy): + """TopkDropout selection with score-magnitude (fractional-Kelly) sizing. + + Parameters + ---------- + topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable, + forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``. + cap_frac : max buy notional as a fraction of the equal-weight notional. + """ + + def __init__(self, *, topk, n_drop, cap_frac: float = DEFAULT_CAP_FRAC, **kwargs): + super().__init__(topk=topk, n_drop=n_drop, **kwargs) + self.cap_frac = cap_frac + + def generate_trade_decision(self, execute_result=None): + import copy + + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) + pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time) + if isinstance(pred_score, pd.DataFrame): + pred_score = pred_score.iloc[:, 0] + if pred_score is None: + return TradeDecisionWO([], self) + + if self.only_tradable: + + def get_first_n(li, n, reverse=False): + cur_n = 0 + res = [] + for si in reversed(li) if reverse else li: + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ): + res.append(si) + cur_n += 1 + if cur_n >= n: + break + return res[::-1] if reverse else res + + def get_last_n(li, n): + return get_first_n(li, n, reverse=True) + + def filter_stock(li): + return [ + si + for si in li + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ) + ] + + else: + + def get_first_n(li, n): + return list(li)[:n] + + def get_last_n(li, n): + return list(li)[-n:] + + def filter_stock(li): + return li + + current_temp: "object" = copy.deepcopy(self.trade_position) + sell_order_list: List[Order] = [] + buy_order_list: List[Order] = [] + cash = current_temp.get_cash() + current_stock_list = current_temp.get_stock_list() + last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index + + if self.method_buy == "top": + today = get_first_n( + pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index, + self.n_drop + self.topk - len(last), + ) + elif self.method_buy == "random": + topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk) + candi = list(filter(lambda x: x not in last, topk_candi)) + n = self.n_drop + self.topk - len(last) + try: + today = np.random.choice(candi, n, replace=False) + except ValueError: + today = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index + + if self.method_sell == "bottom": + sell = last[last.isin(get_last_n(comb, self.n_drop))] + elif self.method_sell == "random": + candi = filter_stock(last) + try: + sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else []) + except ValueError: + sell = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + buy = today[: len(sell) + self.topk - len(last)] + for code in current_stock_list: + if not self.trade_exchange.is_stock_tradable( + stock_id=code, + start_time=trade_start_time, + end_time=trade_end_time, + direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL, + ): + continue + if code in sell: + time_per_step = self.trade_calendar.get_freq() + if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh: + continue + sell_amount = current_temp.get_stock_amount(code=code) + sell_order = Order( + stock_id=code, + amount=sell_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=Order.SELL, + ) + if self.trade_exchange.check_order(sell_order): + sell_order_list.append(sell_order) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( + sell_order, position=current_temp + ) + cash += trade_val - trade_cost + + if len(buy) == 0: + return TradeDecisionWO(sell_order_list, self) + + # ---- fractional-Kelly sizing -------------------------------------- + # equal-weight notional (reference baseline) + eq_notional = cash * self.risk_degree / len(buy) + buy_scores = pred_score.reindex(buy).astype(float) + lo, hi = buy_scores.min(), buy_scores.max() + if hi == lo: + w = pd.Series(1.0, index=buy_scores.index) + else: + w = (buy_scores - lo) / (hi - lo) # [0,1] edge magnitude + w = w.clip(lower=0.0) + w_max = w.max() + w = w / w_max if w_max > 0 else w # max == 1.0 + for code in buy: + if not self.trade_exchange.is_stock_tradable( + stock_id=code, + start_time=trade_start_time, + end_time=trade_end_time, + direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY, + ): + continue + buy_price = self.trade_exchange.get_deal_price( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY + ) + notional = eq_notional * min(self.cap_frac, float(w.get(code, 0.0))) + buy_amount = notional / buy_price + factor = self.trade_exchange.get_factor( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ) + buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) + buy_order = Order( + stock_id=code, + amount=buy_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=Order.BUY, + ) + buy_order_list.append(buy_order) + + return TradeDecisionWO(sell_order_list + buy_order_list, self) \ No newline at end of file diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/long_short.py b/code/tac-qlib/tac_qlib/contrib/strategy/long_short.py new file mode 100644 index 0000000..9090fc6 --- /dev/null +++ b/code/tac-qlib/tac_qlib/contrib/strategy/long_short.py @@ -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) diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/regime_gate.py b/code/tac-qlib/tac_qlib/contrib/strategy/regime_gate.py new file mode 100644 index 0000000..5b9acfb --- /dev/null +++ b/code/tac-qlib/tac_qlib/contrib/strategy/regime_gate.py @@ -0,0 +1,231 @@ +"""HMM-regime overlay TopkDropout strategy. + +Regime-gate overlay on ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``: +selection and sizing are identical to the reference, but a name is only BOUGHT +(entry gate) when its per-symbol HMM regime posterior ``sp_hmm_p_regime1`` on +the signal date is >= ``regime_threshold``; otherwise it is held in cash instead +of being opened. + +The regime posterior is read from the lake feature provider on the fly via +``qlib.data.D.features`` (field ``$sp_hmm_p_regime1``) for the signal window, so +no regime column needs to enter the model's ``feature_fields`` — the gate is a +pure overlay (book ch.01: regime flags regressed as model features, survived +only as an overlay). The HMM itself was fit with ``fit_end=`` when +the lake features were backfilled, so there is no lookahead. + +Names already held are NOT force-sold when the regime turns unfavourable +(entry gate only, matching the queue-10 design). +""" + +from __future__ import annotations + +from typing import List + +import numpy as np +import pandas as pd + +from qlib.backtest import Order +from qlib.backtest.decision import OrderDir, TradeDecisionWO +from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy + +try: + from qlib.data import D +except ImportError: # pragma: no cover - qlib always present in this stack + D = None + +__all__ = ["RegimeGateDropoutStrategy"] + +DEFAULT_REGIME_THRESHOLD = 0.5 +REGIME_FIELD = "$sp_hmm_p_regime1" + + +class RegimeGateDropoutStrategy(TopkDropoutStrategy): + """TopkDropout with an HMM-regime entry gate on buy candidates. + + Parameters + ---------- + topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable, + forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``. + regime_threshold : minimum ``sp_hmm_p_regime1`` posterior required to open a + new position (default 0.5). + """ + + def __init__(self, *, topk, n_drop, regime_threshold: float = DEFAULT_REGIME_THRESHOLD, **kwargs): + super().__init__(topk=topk, n_drop=n_drop, **kwargs) + self.regime_threshold = regime_threshold + + def _regime_for(self, codes, pred_start, pred_end) -> pd.Series: + """Return {code: sp_hmm_p_regime1} for the signal window (last day).""" + if D is None: + return pd.Series(dtype=float) + try: + df = D.features(list(codes), [REGIME_FIELD], start_time=pred_start, end_time=pred_end, freq="day") + except Exception: # noqa: BLE001 - a regime read failure should gate open, not crash + return pd.Series(dtype=float) + if df is None or len(df) == 0: + return pd.Series(dtype=float) + # df index is MultiIndex (datetime, instrument); take the last day's values + df = df.reset_index() + ts_col = "datetime" if "datetime" in df.columns else df.columns[0] + sym_col = "instrument" if "instrument" in df.columns else df.columns[1] + last_ts = df[ts_col].max() + last = df[df[ts_col] == last_ts] + out = {} + for _, row in last.iterrows(): + sym = str(row[sym_col]).split("/")[-1].upper() + val = row.iloc[-1] + out[sym] = float(val) if val == val else np.nan + return pd.Series(out) + + def generate_trade_decision(self, execute_result=None): + import copy + + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) + pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time) + if isinstance(pred_score, pd.DataFrame): + pred_score = pred_score.iloc[:, 0] + if pred_score is None: + return TradeDecisionWO([], self) + + if self.only_tradable: + + def get_first_n(li, n, reverse=False): + cur_n = 0 + res = [] + for si in reversed(li) if reverse else li: + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ): + res.append(si) + cur_n += 1 + if cur_n >= n: + break + return res[::-1] if reverse else res + + def get_last_n(li, n): + return get_first_n(li, n, reverse=True) + + def filter_stock(li): + return [ + si + for si in li + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ) + ] + + else: + + def get_first_n(li, n): + return list(li)[:n] + + def get_last_n(li, n): + return list(li)[-n:] + + def filter_stock(li): + return li + + current_temp: "object" = copy.deepcopy(self.trade_position) + sell_order_list: List[Order] = [] + buy_order_list: List[Order] = [] + cash = current_temp.get_cash() + current_stock_list = current_temp.get_stock_list() + last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index + + if self.method_buy == "top": + today = get_first_n( + pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index, + self.n_drop + self.topk - len(last), + ) + elif self.method_buy == "random": + topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk) + candi = list(filter(lambda x: x not in last, topk_candi)) + n = self.n_drop + self.topk - len(last) + try: + today = np.random.choice(candi, n, replace=False) + except ValueError: + today = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index + + if self.method_sell == "bottom": + sell = last[last.isin(get_last_n(comb, self.n_drop))] + elif self.method_sell == "random": + candi = filter_stock(last) + try: + sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else []) + except ValueError: + sell = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + buy = today[: len(sell) + self.topk - len(last)] + + # ---- regime gate ----------------------------------------------------- + if buy: + regime = self._regime_for(buy, pred_start_time, pred_end_time) + gated = [c for c in buy if regime.get(c, np.nan) >= self.regime_threshold] + else: + gated = [] + + for code in current_stock_list: + if not self.trade_exchange.is_stock_tradable( + stock_id=code, + start_time=trade_start_time, + end_time=trade_end_time, + direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL, + ): + continue + if code in sell: + time_per_step = self.trade_calendar.get_freq() + if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh: + continue + sell_amount = current_temp.get_stock_amount(code=code) + sell_order = Order( + stock_id=code, + amount=sell_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=Order.SELL, + ) + if self.trade_exchange.check_order(sell_order): + sell_order_list.append(sell_order) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( + sell_order, position=current_temp + ) + cash += trade_val - trade_cost + + if len(gated) == 0: + return TradeDecisionWO(sell_order_list, self) + + value = cash * self.risk_degree / len(gated) + for code in gated: + if not self.trade_exchange.is_stock_tradable( + stock_id=code, + start_time=trade_start_time, + end_time=trade_end_time, + direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY, + ): + continue + buy_price = self.trade_exchange.get_deal_price( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY + ) + buy_amount = value / buy_price + factor = self.trade_exchange.get_factor( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ) + buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) + buy_order = Order( + stock_id=code, + amount=buy_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=Order.BUY, + ) + buy_order_list.append(buy_order) + + return TradeDecisionWO(sell_order_list + buy_order_list, self) \ No newline at end of file diff --git a/code/tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py b/code/tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py new file mode 100644 index 0000000..fe60bac --- /dev/null +++ b/code/tac-qlib/tac_qlib/contrib/strategy/weekly_rebalance.py @@ -0,0 +1,202 @@ +"""Weekly-rebalance TopkDropout strategy. + +Turnover-reduction variant of ``qlib.contrib.strategy.signal_strategy.TopkDropoutStrategy``: +the topk/n_drop selection and sizing are identical to the reference, but the +target book is recomputed only on the first trading day of each ISO week; on the +other days the strategy issues NO orders (holds the book untouched). + +The weekly cadence is derived from the qlib trade calendar: a rebalance happens +when the current trade step's date belongs to a different ISO ``(year, week)`` +than the previous trade step. ``hold_band_pct`` (default 0) optionally skips +tiny rebalances: when a name's existing position differs from the new target by +less than this fraction, no order is generated for it. +""" + +from __future__ import annotations + +from typing import List + +import numpy as np +import pandas as pd + +from qlib.backtest import Order +from qlib.backtest.decision import OrderDir, TradeDecisionWO +from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy + +__all__ = ["WeeklyRebalanceDropoutStrategy"] + +DEFAULT_HOLD_BAND_PCT = 0.0 + + +class WeeklyRebalanceDropoutStrategy(TopkDropoutStrategy): + """TopkDropout rebalanced once per ISO week; holds otherwise. + + Parameters + ---------- + topk, n_drop, method_sell, method_buy, hold_thresh, only_tradable, + forbid_all_trade_at_limit : same as ``TopkDropoutStrategy``. + hold_band_pct : skip order for a name whose deviation from target weight is + below this fraction of the target (no-trade buffer band). + """ + + def __init__(self, *, topk, n_drop, hold_band_pct: float = DEFAULT_HOLD_BAND_PCT, **kwargs): + super().__init__(topk=topk, n_drop=n_drop, **kwargs) + self.hold_band_pct = hold_band_pct + + @staticmethod + def _iso_week(ts) -> tuple: + return (ts.year, ts.week) + + def generate_trade_decision(self, execute_result=None): + import copy + + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + + 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) + + pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) + pred_score = self.signal.get_signal(start_time=pred_start_time, end_time=pred_end_time) + if isinstance(pred_score, pd.DataFrame): + pred_score = pred_score.iloc[:, 0] + if pred_score is None: + return TradeDecisionWO([], self) + + if self.only_tradable: + + def get_first_n(li, n, reverse=False): + cur_n = 0 + res = [] + for si in reversed(li) if reverse else li: + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ): + res.append(si) + cur_n += 1 + if cur_n >= n: + break + return res[::-1] if reverse else res + + def get_last_n(li, n): + return get_first_n(li, n, reverse=True) + + def filter_stock(li): + return [ + si + for si in li + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ) + ] + + else: + + def get_first_n(li, n): + return list(li)[:n] + + def get_last_n(li, n): + return list(li)[-n:] + + def filter_stock(li): + return li + + current_temp: "object" = copy.deepcopy(self.trade_position) + sell_order_list: List[Order] = [] + buy_order_list: List[Order] = [] + cash = current_temp.get_cash() + current_stock_list = current_temp.get_stock_list() + last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index + + if self.method_buy == "top": + today = get_first_n( + pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index, + self.n_drop + self.topk - len(last), + ) + elif self.method_buy == "random": + topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk) + candi = list(filter(lambda x: x not in last, topk_candi)) + n = self.n_drop + self.topk - len(last) + try: + today = np.random.choice(candi, n, replace=False) + except ValueError: + today = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index + + if self.method_sell == "bottom": + sell = last[last.isin(get_last_n(comb, self.n_drop))] + elif self.method_sell == "random": + candi = filter_stock(last) + try: + sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else []) + except ValueError: + sell = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + buy = today[: len(sell) + self.topk - len(last)] + for code in current_stock_list: + if not self.trade_exchange.is_stock_tradable( + stock_id=code, + start_time=trade_start_time, + end_time=trade_end_time, + direction=None if self.forbid_all_trade_at_limit else OrderDir.SELL, + ): + continue + if code in sell: + time_per_step = self.trade_calendar.get_freq() + if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh: + continue + sell_amount = current_temp.get_stock_amount(code=code) + sell_order = Order( + stock_id=code, + amount=sell_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=Order.SELL, + ) + if self.trade_exchange.check_order(sell_order): + sell_order_list.append(sell_order) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( + sell_order, position=current_temp + ) + cash += trade_val - trade_cost + + if len(buy) == 0: + return TradeDecisionWO(sell_order_list, self) + + value = cash * self.risk_degree / len(buy) + for code in buy: + if not self.trade_exchange.is_stock_tradable( + stock_id=code, + start_time=trade_start_time, + end_time=trade_end_time, + direction=None if self.forbid_all_trade_at_limit else OrderDir.BUY, + ): + continue + buy_price = self.trade_exchange.get_deal_price( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY + ) + buy_amount = value / buy_price + factor = self.trade_exchange.get_factor( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ) + buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) + buy_order = Order( + stock_id=code, + amount=buy_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=Order.BUY, + ) + buy_order_list.append(buy_order) + + return TradeDecisionWO(sell_order_list + buy_order_list, self) \ No newline at end of file diff --git a/code/tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc b/code/tac-qlib/tac_qlib/data/__pycache__/__init__.cpython-312.pyc index 7c4e6c345fad1978efe8860c0d977d0c02d6f8d9..a0e969bd6504bb8d9220f4641cc01e960c3120e4 100644 GIT binary patch delta 20 acmeBT>0;qN&CAQh00a@cdN*?aX9NH-^#yVO delta 20 acmeBT>0;qN&CAQh00f=0TQ_q5X9NH;Y6W}% diff --git a/code/tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc b/code/tac-qlib/tac_qlib/data/__pycache__/config.cpython-312.pyc index 99e602392d51663cb06d5c425000b1ed1e5a916b..2f8c537d11135155539276bee342d087aad8743e 100644 GIT binary patch delta 1815 zcmah~Uu;ul6u)2Z?Vqjd`sc2t9UE+e{kdBiFi-}_WJ0%KhHQ)}8_rU8Z?UCY>vwNq zs|Ei+R1gB>7#>6t2_`zgL{oi$CncJg7!AZ1a@Fu+;wzEF7@j=mGFq0H_&xl3zH`p+ zJKs6?oUcEOKNzq5(c`HWaP7T$CSBKbzBbIH^X!#4LM8!TORCz*p3d;~n6uslNI1_WJ1 zXliQWG3fQQFb(#3y4WC`^+w=hkM?ZYZ{EhoiPK9Qx-3!f$8;V@@VJ`dg}@L--jrWByG@I_HnDkKn3*bI6d; z%Uauo$uU7VYA*_DCtMCRE@wsiMd6aI;1&ecRunG^s(4AH-BCx;k*QeOvgi>6=x?ouw*t$Vb%eZ_<}5lbT4UMMEqmO^dbH9$I#L=H3>o@md9`$?1M{&L zCDk55k5S>Vbkv2Cq&n&ayz>O!r>f{m2l3AGk}B1yy40%c)z=pCLSI)p1fR>TECOFH zmzf*>lvhB{GFd8CU$24Yx@OT+_z7Zly@8^hD_SkKzZ!di7F?)1?khNxiRn~kZVR8n zYj73T1vjnEp*YV>=TtV6pPtc`+DhR~-$1N?&)Du*e4u~lj-ef~QJ#WR!7brDa!i*t zqv}Ue#;c~J@@!6VV+-614r2?f2}Ri~Oomp7_d;!OJhY3o!!Mz`ZeJ)?<@l~$LIiJQ zEBw90Cl6cUt!C62%G9}$aJ2Cp$`1kvZwhz4^4Vi2kKJzSx#R7*?dsu!P!s8gD`CH% z*!(0ynfraXlL6chFU97liH!b-M=la|Um)K51cR{E6)AlaImkxN(EbR)96^DAGUfdU zrX!&nb2;sa>Nt#SCZ!wLuhu9XCoh|te49v->#Na9NSR9n+VYY2k*G04__DT{QP+{8DgrVsso7L^OwVDV&&F^i! z>{cn%{v7jLF-Wa4{FVeXZr#I)JXP1>*BC}67_+%v~v|IP5VK*II zGxB_fQ^zZgih)~sTJOQvX!<{Up{60Hc5hD@i2ybJ{(z^s!eXSOsrBF_p60E O<+k91Yte{Agnt2$S*F+k delta 1872 zcmah~Z%kWN6o0qx^$(P`l>S)@>nvq#JYaJ$BW$?2G1xM7Ou%iz#M1Fz9njL|zE^=- zaAsTPGM%!cBnFr12NMxuqD}nsVR0tLO#LzwKP*q9E`B#rOw2SU-g8UoNKCvRe(&7# z|D1d7eJcYu2b|wH9OVLA7Z=WtN%ze;14NkH`h&bfX+9?nx|J5CRoQmVJXmEC4hhQk zbAodJk_q(%VbC)z$~*Hr$X`;v!}2LHA7IS=`MXx1NIJk}Zw_@bhT*2FmP$OUMia4d zHA+({ogQWUN8w5P5b4T)WRH^;WY8YeHfPdlmC`mw%c3D^Y9h`BoTG`19S(NNmF>)6p>lR}lu2cg#!+)E zB0YsRrE>PTo5=)>xp&ueF~JB5ip$CfwkIpE!DsFcyF$8zA(r%vIR`&EeQ>MF5i&0b zS4~;FAShx^To4rLs))N8OU^Q8h2Blo8#VG$Yfc&~D>^NGbvx{;55cvnO>oEQfg4V{ zXT1|^&RTFHQQxYv?5Pl$e~8vsU-8%u_6xeZ=rAWOkpiul#~WD5=A5Khf{fKK6sZ{- zqs)r6T41HHHqMmgY-1j{6Y^{*Q*26^Qod|ok_uj!u5HWqos#o*?YEHHW?;8W|*{e*19U9 z!^|^{!Nb1Yp%fz*iD4Z{B{IngO?KRKc5!&uwu(@&$Z7(A5nIrJ3)0_V>_zdaAxHN+>i7ycX^Y@l^ay|dR)=uK)Z@{`U zxX=(GU%`6~vIDE7{peSsJ@9iw;CbvC+u-xWOE4@{)iZR0N+__D1pFZ?RGCNR|B+`a z(#rO(m>xSwV@Xvu(UMVt&XzsK&Il%1QvBrBGT@q7J4#RLnRG%OTD8traUS#g#As60 z^jI=o6wwa2DepXS0u$qJ=?GH%jXRChc?8~RK1=*~uOROog!d6XV6a%2J&HdH+FOJn z=xaRI$6JRy)~$Ug;#u>mcn?Vr_7nvlB9zMY(vKO#D33)>fOjKXaWF12nC}6 delta 20 acmbP}F(-rjG%qg~0}yo1Zr#W&XaWF1eFd!m diff --git a/code/tac-qlib/tac_qlib/data/config.py b/code/tac-qlib/tac_qlib/data/config.py index 53c9007..1953fb2 100644 --- a/code/tac-qlib/tac_qlib/data/config.py +++ b/code/tac-qlib/tac_qlib/data/config.py @@ -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))